vibeusage 0.3.3 → 0.3.5

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.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Codex CLI token usage tracker (macOS-first, notify-driven).",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -190,7 +190,7 @@ function renderWelcome() {
190
190
  DIVIDER,
191
191
  "",
192
192
  "This tool will:",
193
- " - Analyze your local AI CLI configurations (Codex, Every Code, Claude, Gemini, Opencode, OpenClaw)",
193
+ " - Analyze your local AI CLI configurations (Codex, Every Code, Claude, Gemini, Opencode, Hermes, OpenClaw)",
194
194
  " - Set up lightweight hooks to track your flow state",
195
195
  " - Link your device to your VibeScore account",
196
196
  "",
@@ -6,6 +6,7 @@ const { readJson } = require("../lib/fs");
6
6
  const { collectLocalSubscriptions } = require("../lib/subscriptions");
7
7
  const { normalizeState: normalizeUploadState } = require("../lib/upload-throttle");
8
8
  const { collectTrackerDiagnostics } = require("../lib/diagnostics");
9
+ const { readLastHermesUsageEvent, resolveHermesUsageLedgerPaths } = require("../lib/hermes-usage-ledger");
9
10
  const { createIntegrationContext, listIntegrations, probeIntegrations } = require("../lib/integrations");
10
11
  const { resolveTrackerPaths } = require("../lib/tracker-paths");
11
12
 
@@ -20,6 +21,7 @@ async function cmdStatus(argv = []) {
20
21
  const home = os.homedir();
21
22
  const { trackerDir } = await resolveTrackerPaths({ home });
22
23
  const configPath = path.join(trackerDir, "config.json");
24
+ const { ledgerPath: hermesLedgerPath } = resolveHermesUsageLedgerPaths({ trackerDir });
23
25
  const queuePath = path.join(trackerDir, "queue.jsonl");
24
26
  const queueStatePath = path.join(trackerDir, "queue.state.json");
25
27
  const cursorsPath = path.join(trackerDir, "cursors.json");
@@ -80,8 +82,10 @@ async function cmdStatus(argv = []) {
80
82
  const claudeProbe = probeByName.get("claude");
81
83
  const geminiProbe = probeByName.get("gemini");
82
84
  const opencodeProbe = probeByName.get("opencode");
85
+ const hermesProbe = probeByName.get("hermes");
83
86
  const openclawSessionProbe = probeByName.get("openclaw-session");
84
87
  const opencodeDbPresent = Boolean((await safeStat(opencodeDbPath))?.isFile?.());
88
+ const hermesLedgerPresent = Boolean((await safeStat(hermesLedgerPath))?.isFile?.());
85
89
  const opencodeSqliteState =
86
90
  cursors?.opencodeSqlite && typeof cursors.opencodeSqlite === "object"
87
91
  ? cursors.opencodeSqlite
@@ -90,6 +94,15 @@ async function cmdStatus(argv = []) {
90
94
  typeof opencodeSqliteState.lastStatus === "string" && opencodeSqliteState.lastStatus.trim()
91
95
  ? opencodeSqliteState.lastStatus.trim()
92
96
  : "never_checked";
97
+ const hermesLedgerState =
98
+ cursors?.hermesLedger && typeof cursors.hermesLedger === "object" ? cursors.hermesLedger : {};
99
+ const hermesLastLedgerEvent = await readLastHermesUsageEvent({ trackerDir });
100
+ const hermesLastEventAt =
101
+ typeof hermesLastLedgerEvent?.emitted_at === "string"
102
+ ? hermesLastLedgerEvent.emitted_at
103
+ : typeof hermesLedgerState.lastEventAt === "string"
104
+ ? hermesLedgerState.lastEventAt
105
+ : "never";
93
106
 
94
107
  process.stdout.write(
95
108
  [
@@ -108,9 +121,12 @@ async function cmdStatus(argv = []) {
108
121
  autoRetryLine,
109
122
  `- Codex notify: ${renderIntegrationStatus(descriptors.get("codex"), codexProbe)}`,
110
123
  `- Every Code notify: ${renderIntegrationStatus(descriptors.get("every-code"), everyCodeProbe)}`,
111
- `- Claude hooks: ${renderIntegrationStatus(descriptors.get("claude"), claudeProbe)}`,
124
+ `- Claude plugin: ${renderIntegrationStatus(descriptors.get("claude"), claudeProbe)}`,
112
125
  `- Gemini hooks: ${renderIntegrationStatus(descriptors.get("gemini"), geminiProbe)}`,
113
126
  `- Opencode plugin: ${renderIntegrationStatus(descriptors.get("opencode"), opencodeProbe)}`,
127
+ `- Hermes plugin: ${renderIntegrationStatus(descriptors.get("hermes"), hermesProbe)}`,
128
+ `- Hermes ledger: ${hermesLedgerPresent ? "present" : "missing"}`,
129
+ `- Hermes last ledger event: ${hermesLastEventAt}`,
114
130
  `- OpenCode SQLite DB: ${opencodeDbPresent ? "present" : "missing"}`,
115
131
  `- OpenCode SQLite reader: ${opencodeSqliteReader}`,
116
132
  `- OpenClaw session plugin: ${renderIntegrationStatus(
@@ -22,6 +22,7 @@ const {
22
22
  } = require("../lib/rollout");
23
23
  const { drainQueueToCloud } = require("../lib/uploader");
24
24
  const { readOpenclawUsageLedger } = require("../lib/openclaw-usage-ledger");
25
+ const { readHermesUsageLedger } = require("../lib/hermes-usage-ledger");
25
26
  const { collectLocalSubscriptions } = require("../lib/subscriptions");
26
27
  const { createProgress, renderBar, formatNumber, formatBytes } = require("../lib/progress");
27
28
  const { syncHeartbeat } = require("../lib/vibeusage-api");
@@ -114,6 +115,12 @@ async function cmdSync(argv) {
114
115
  },
115
116
  });
116
117
 
118
+ const hermesResult = await parseHermesUsageLedger({
119
+ trackerDir,
120
+ cursors,
121
+ queuePath,
122
+ });
123
+
117
124
  const openclawResult = opts.fromOpenclaw
118
125
  ? await parseOpenclawSanitizedLedger({
119
126
  trackerDir,
@@ -369,12 +376,14 @@ async function cmdSync(argv) {
369
376
  if (!opts.auto) {
370
377
  const totalParsed =
371
378
  parseResult.filesProcessed +
379
+ hermesResult.filesProcessed +
372
380
  openclawResult.filesProcessed +
373
381
  claudeResult.filesProcessed +
374
382
  geminiResult.filesProcessed +
375
383
  opencodeResult.filesProcessed;
376
384
  const totalBuckets =
377
385
  parseResult.bucketsQueued +
386
+ hermesResult.bucketsQueued +
378
387
  openclawResult.bucketsQueued +
379
388
  claudeResult.bucketsQueued +
380
389
  geminiResult.bucketsQueued +
@@ -436,6 +445,78 @@ function parseArgs(argv) {
436
445
 
437
446
  module.exports = { cmdSync };
438
447
 
448
+ async function parseHermesUsageLedger({ trackerDir, cursors, queuePath }) {
449
+ const ledgerCursor =
450
+ cursors?.hermesLedger && typeof cursors.hermesLedger === "object" ? cursors.hermesLedger : {};
451
+ const offset = Math.max(0, Number(ledgerCursor.offset || 0));
452
+ const { events, endOffset } = await readHermesUsageLedger({ trackerDir, offset });
453
+
454
+ const hourlyState = normalizeHourlyState(cursors?.hourly);
455
+ const touchedBuckets = new Set();
456
+ let eventsAggregated = 0;
457
+
458
+ for (const event of events) {
459
+ if (!event || typeof event !== "object") continue;
460
+ if (event.type !== "usage") continue;
461
+ const bucketStart = toUtcHalfHourStart(event.emitted_at);
462
+ if (!bucketStart) continue;
463
+
464
+ const model =
465
+ typeof event.model === "string" && event.model.trim() ? event.model.trim() : "unknown";
466
+ const source = "hermes";
467
+ const delta = {
468
+ input_tokens: Math.max(0, Number(event.input_tokens || 0)),
469
+ cached_input_tokens: Math.max(
470
+ 0,
471
+ Number(event.cache_read_tokens || 0) + Number(event.cache_write_tokens || 0),
472
+ ),
473
+ output_tokens: Math.max(0, Number(event.output_tokens || 0)),
474
+ reasoning_output_tokens: Math.max(0, Number(event.reasoning_tokens || 0)),
475
+ total_tokens: Math.max(0, Number(event.total_tokens || 0)),
476
+ };
477
+
478
+ if (
479
+ delta.input_tokens === 0 &&
480
+ delta.cached_input_tokens === 0 &&
481
+ delta.output_tokens === 0 &&
482
+ delta.reasoning_output_tokens === 0 &&
483
+ delta.total_tokens === 0
484
+ ) {
485
+ continue;
486
+ }
487
+
488
+ const bucket = getHourlyBucket(hourlyState, source, model, bucketStart);
489
+ addTotals(bucket.totals, delta);
490
+ touchedBuckets.add(bucketKey(source, model, bucketStart));
491
+ eventsAggregated += 1;
492
+ }
493
+
494
+ const bucketsQueued = await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets });
495
+ const lastUsageEvent = [...events].reverse().find((event) => {
496
+ if (!event || event.type !== "usage") return false;
497
+ return Boolean(toUtcHalfHourStart(event.emitted_at));
498
+ });
499
+ hourlyState.updatedAt = new Date().toISOString();
500
+ cursors.hourly = hourlyState;
501
+ cursors.hermesLedger = {
502
+ version: 1,
503
+ offset: endOffset,
504
+ updatedAt: new Date().toISOString(),
505
+ lastEventAt:
506
+ typeof lastUsageEvent?.emitted_at === "string"
507
+ ? lastUsageEvent.emitted_at
508
+ : typeof ledgerCursor.lastEventAt === "string"
509
+ ? ledgerCursor.lastEventAt
510
+ : null,
511
+ };
512
+
513
+ return {
514
+ filesProcessed: endOffset > offset ? 1 : 0,
515
+ eventsAggregated,
516
+ bucketsQueued,
517
+ };
518
+ }
519
+
439
520
  async function parseOpenclawSanitizedLedger({ trackerDir, cursors, queuePath }) {
440
521
  const ledgerCursor =
441
522
  cursors?.openclawLedger && typeof cursors.openclawLedger === "object"
@@ -18,9 +18,10 @@ async function cmdUninstall(argv) {
18
18
  });
19
19
  const codexConfigExists = await isFile(integrationContext.codex.configPath);
20
20
  const codeConfigExists = await isFile(integrationContext.everyCode.configPath);
21
- const claudeConfigExists = await isFile(integrationContext.claude.settingsPath);
21
+ const claudeConfigExists = await isDir(integrationContext.claude.configDir);
22
22
  const geminiConfigExists = await isDir(integrationContext.gemini.configDir);
23
23
  const opencodeConfigExists = await isDir(integrationContext.opencode.configDir);
24
+ const hermesConfigExists = await isDir(integrationContext.hermes.hermesHome);
24
25
  const integrationResults = await uninstallIntegrations(integrationContext);
25
26
  const resultByName = new Map(integrationResults.map((result) => [result.name, result]));
26
27
 
@@ -58,11 +59,11 @@ async function cmdUninstall(argv) {
58
59
  renderHookLine({
59
60
  exists: claudeConfigExists,
60
61
  result: resultByName.get("claude"),
61
- missingText: "- Claude hooks: skipped (settings.json not found)",
62
+ missingText: `- Claude plugin: skipped (${integrationContext.claude.configDir} not found)`,
62
63
  removedText: (result) =>
63
- `- Claude hooks removed: ${result.detail || integrationContext.claude.settingsPath}`,
64
- noChangeText: "- Claude hooks: no change",
65
- skippedText: "- Claude hooks: skipped",
64
+ `- Claude plugin removed: ${result.detail || integrationContext.claude.settingsPath}`,
65
+ noChangeText: "- Claude plugin: no change",
66
+ skippedText: "- Claude plugin: skipped",
66
67
  }),
67
68
  renderHookLine({
68
69
  exists: geminiConfigExists,
@@ -82,6 +83,15 @@ async function cmdUninstall(argv) {
82
83
  noChangeText: "- Opencode plugin: no change",
83
84
  skippedText: "- Opencode plugin: skipped (unexpected content)",
84
85
  }),
86
+ renderHookLine({
87
+ exists: hermesConfigExists,
88
+ result: resultByName.get("hermes"),
89
+ missingText: `- Hermes plugin: skipped (${integrationContext.hermes.hermesHome} not found)`,
90
+ removedText: (result) =>
91
+ `- Hermes plugin removed: ${result.detail || integrationContext.hermes.pluginDir}`,
92
+ noChangeText: "- Hermes plugin: no change",
93
+ skippedText: "- Hermes plugin: skipped (unexpected content)",
94
+ }),
85
95
  renderHookLine({
86
96
  exists: true,
87
97
  result: resultByName.get("openclaw-session"),
@@ -120,6 +130,15 @@ async function isFile(p) {
120
130
  }
121
131
  }
122
132
 
133
+ async function isDir(p) {
134
+ try {
135
+ const st = await fs.stat(p);
136
+ return st.isDirectory();
137
+ } catch (_e) {
138
+ return false;
139
+ }
140
+ }
141
+
123
142
  function renderRestoreLine({ exists, result, missingText, restoredText, noChangeText, skippedText }) {
124
143
  if (!exists) return missingText;
125
144
  if (!result) return noChangeText;
@@ -0,0 +1,381 @@
1
+ const os = require("node:os");
2
+ const path = require("node:path");
3
+ const fs = require("node:fs/promises");
4
+ const cp = require("node:child_process");
5
+
6
+ const { ensureDir, readJsonStrict, writeFileAtomic } = require("./fs");
7
+ const { buildClaudeHookCommand } = require("./claude-config");
8
+
9
+ const CLAUDE_PLUGIN_MARKETPLACE_NAME = "vibeusage-local";
10
+ const CLAUDE_PLUGIN_ID = "vibeusage-claude-sync";
11
+ const CLAUDE_PLUGIN_VERSION = "0.0.0";
12
+
13
+ function resolveClaudePluginPaths({ home = os.homedir(), trackerDir } = {}) {
14
+ if (!trackerDir) throw new Error("trackerDir is required");
15
+
16
+ const claudeDir = path.join(home, ".claude");
17
+ const pluginsDir = path.join(claudeDir, "plugins");
18
+ const marketplaceDir = path.join(trackerDir, "claude-marketplace");
19
+ const pluginRootDir = path.join(marketplaceDir, "plugins", CLAUDE_PLUGIN_ID);
20
+ const pluginRef = `${CLAUDE_PLUGIN_ID}@${CLAUDE_PLUGIN_MARKETPLACE_NAME}`;
21
+
22
+ return {
23
+ claudeDir,
24
+ settingsPath: path.join(claudeDir, "settings.json"),
25
+ pluginsDir,
26
+ knownMarketplacesPath: path.join(pluginsDir, "known_marketplaces.json"),
27
+ installedPluginsPath: path.join(pluginsDir, "installed_plugins.json"),
28
+ marketplaceDir,
29
+ marketplaceManifestPath: path.join(marketplaceDir, ".claude-plugin", "marketplace.json"),
30
+ pluginRootDir,
31
+ pluginManifestPath: path.join(pluginRootDir, ".claude-plugin", "plugin.json"),
32
+ pluginHooksPath: path.join(pluginRootDir, "hooks", "hooks.json"),
33
+ pluginRef,
34
+ };
35
+ }
36
+
37
+ async function ensureClaudePluginFiles({ trackerDir, notifyPath } = {}) {
38
+ if (!trackerDir || !notifyPath) {
39
+ throw new Error("trackerDir and notifyPath are required");
40
+ }
41
+
42
+ const paths = resolveClaudePluginPaths({ trackerDir });
43
+
44
+ await ensureDir(path.dirname(paths.marketplaceManifestPath));
45
+ await ensureDir(path.dirname(paths.pluginManifestPath));
46
+ await ensureDir(path.dirname(paths.pluginHooksPath));
47
+
48
+ await writeFileAtomic(paths.marketplaceManifestPath, buildMarketplaceManifest());
49
+ await writeFileAtomic(paths.pluginManifestPath, buildPluginManifest());
50
+ await writeFileAtomic(paths.pluginHooksPath, buildPluginHooks({ notifyPath }));
51
+
52
+ return paths;
53
+ }
54
+
55
+ async function probeClaudePluginState({ home = os.homedir(), trackerDir } = {}) {
56
+ const paths = resolveClaudePluginPaths({ home, trackerDir });
57
+ const settings = await readJsonStrict(paths.settingsPath);
58
+ const known = await readJsonStrict(paths.knownMarketplacesPath);
59
+ const installed = await readJsonStrict(paths.installedPluginsPath);
60
+ const pluginFilesReady =
61
+ (await isFile(paths.marketplaceManifestPath)) &&
62
+ (await isFile(paths.pluginManifestPath)) &&
63
+ (await isFile(paths.pluginHooksPath));
64
+
65
+ if (settings.status === "invalid" || settings.status === "error") {
66
+ return unreadableState(paths, "Claude settings unreadable");
67
+ }
68
+ if (known.status === "invalid" || known.status === "error") {
69
+ return unreadableState(paths, "Claude marketplace registry unreadable");
70
+ }
71
+ if (installed.status === "invalid" || installed.status === "error") {
72
+ return unreadableState(paths, "Claude plugin registry unreadable");
73
+ }
74
+
75
+ const enabled = settings.value?.enabledPlugins?.[paths.pluginRef] === true;
76
+ const declaredMarketplace = marketplaceMatchesPath({
77
+ marketplaceEntry: known.value?.[CLAUDE_PLUGIN_MARKETPLACE_NAME],
78
+ marketplaceDir: paths.marketplaceDir,
79
+ });
80
+ const installedEntries = Array.isArray(installed.value?.plugins?.[paths.pluginRef])
81
+ ? installed.value.plugins[paths.pluginRef]
82
+ : [];
83
+ const installedUserEntry =
84
+ installedEntries.find((entry) => String(entry?.scope || "") === "user") || null;
85
+
86
+ return {
87
+ configured: Boolean(enabled && installedUserEntry && pluginFilesReady && declaredMarketplace),
88
+ enabled,
89
+ installed: Boolean(installedUserEntry),
90
+ marketplaceDeclared: declaredMarketplace,
91
+ pluginFilesReady,
92
+ pluginRef: paths.pluginRef,
93
+ unreadable: false,
94
+ detail: null,
95
+ ...paths,
96
+ };
97
+ }
98
+
99
+ async function installClaudePlugin({
100
+ home = os.homedir(),
101
+ trackerDir,
102
+ notifyPath,
103
+ env = process.env,
104
+ } = {}) {
105
+ const paths = resolveClaudePluginPaths({ home, trackerDir });
106
+ await ensureClaudePluginFiles({ trackerDir, notifyPath });
107
+
108
+ const initialState = await probeClaudePluginState({ home, trackerDir });
109
+ if (initialState.unreadable) {
110
+ return { configured: false, skippedReason: "claude-config-unreadable", ...initialState };
111
+ }
112
+
113
+ const marketplaceCmd = initialState.marketplaceDeclared
114
+ ? ["plugin", "marketplace", "update", CLAUDE_PLUGIN_MARKETPLACE_NAME]
115
+ : ["plugin", "marketplace", "add", paths.marketplaceDir, "--scope", "user"];
116
+ const marketplaceResult = runClaudeCli(marketplaceCmd, env);
117
+ if (marketplaceResult.skippedReason) {
118
+ return { configured: false, ...paths, ...marketplaceResult };
119
+ }
120
+
121
+ let actionResult;
122
+ if (!initialState.installed) {
123
+ actionResult = runClaudeCli(["plugin", "install", paths.pluginRef, "--scope", "user"], env);
124
+ } else if (!initialState.enabled) {
125
+ actionResult = runClaudeCli(["plugin", "enable", paths.pluginRef, "--scope", "user"], env);
126
+ } else {
127
+ actionResult = runClaudeCli(["plugin", "update", paths.pluginRef, "--scope", "user"], env);
128
+ }
129
+ if (actionResult.skippedReason) {
130
+ return { configured: false, ...paths, ...actionResult };
131
+ }
132
+
133
+ const nextState = await probeClaudePluginState({ home, trackerDir });
134
+ return {
135
+ configured: nextState.configured,
136
+ changed: !initialState.configured || !initialState.enabled || !initialState.marketplaceDeclared,
137
+ stdout: `${marketplaceResult.stdout || ""}\n${actionResult.stdout || ""}`.trim(),
138
+ stderr: `${marketplaceResult.stderr || ""}\n${actionResult.stderr || ""}`.trim(),
139
+ ...nextState,
140
+ };
141
+ }
142
+
143
+ async function removeClaudePluginConfig({
144
+ home = os.homedir(),
145
+ trackerDir,
146
+ env = process.env,
147
+ } = {}) {
148
+ const paths = resolveClaudePluginPaths({ home, trackerDir });
149
+ const initialState = await probeClaudePluginState({ home, trackerDir });
150
+ const hadMarketplaceDir = await isDir(paths.marketplaceDir);
151
+
152
+ let changed = false;
153
+ let skippedReason = null;
154
+ if (initialState.installed || initialState.enabled) {
155
+ const uninstallResult = runClaudeCli(["plugin", "uninstall", paths.pluginRef, "--scope", "user"], env);
156
+ if (uninstallResult.skippedReason) {
157
+ return { removed: false, ...paths, ...uninstallResult };
158
+ }
159
+ changed = true;
160
+ }
161
+
162
+ const siblingRefs = await listMarketplaceSiblingPluginRefs({
163
+ installedPluginsPath: paths.installedPluginsPath,
164
+ marketplaceName: CLAUDE_PLUGIN_MARKETPLACE_NAME,
165
+ excludePluginRef: paths.pluginRef,
166
+ });
167
+
168
+ const shouldRemoveMarketplace =
169
+ initialState.marketplaceDeclared && siblingRefs.unreadable !== true && siblingRefs.refs.length === 0;
170
+
171
+ if (shouldRemoveMarketplace) {
172
+ const removeMarketplaceResult = runClaudeCli(
173
+ ["plugin", "marketplace", "remove", CLAUDE_PLUGIN_MARKETPLACE_NAME],
174
+ env,
175
+ );
176
+ if (removeMarketplaceResult.skippedReason && removeMarketplaceResult.skippedReason !== "claude-cli-error") {
177
+ return { removed: changed, ...paths, ...removeMarketplaceResult };
178
+ }
179
+ changed = true;
180
+ } else if (!changed) {
181
+ skippedReason = "plugin-missing";
182
+ }
183
+
184
+ if (shouldRemoveMarketplace) {
185
+ await fs.rm(paths.marketplaceDir, { recursive: true, force: true }).catch(() => {});
186
+ }
187
+ return {
188
+ removed: changed || (hadMarketplaceDir && shouldRemoveMarketplace),
189
+ skippedReason,
190
+ siblingPluginRefs: siblingRefs.refs,
191
+ ...paths,
192
+ };
193
+ }
194
+
195
+ function runClaudeCli(args, env = process.env) {
196
+ let res;
197
+ try {
198
+ res = cp.spawnSync("claude", args, {
199
+ env,
200
+ encoding: "utf8",
201
+ timeout: 30_000,
202
+ });
203
+ } catch (err) {
204
+ return {
205
+ code: 1,
206
+ skippedReason: err?.code === "ENOENT" ? "claude-cli-missing" : "claude-cli-error",
207
+ error: err?.message || String(err),
208
+ stdout: "",
209
+ stderr: "",
210
+ };
211
+ }
212
+
213
+ if (res.error?.code === "ENOENT") {
214
+ return {
215
+ code: 1,
216
+ skippedReason: "claude-cli-missing",
217
+ error: res.error.message,
218
+ stdout: res.stdout || "",
219
+ stderr: res.stderr || "",
220
+ };
221
+ }
222
+
223
+ if ((res.status || 0) !== 0) {
224
+ return {
225
+ code: Number(res.status || 1),
226
+ skippedReason: "claude-cli-error",
227
+ error: (res.stderr || res.stdout || "").trim() || "claude plugin command failed",
228
+ stdout: res.stdout || "",
229
+ stderr: res.stderr || "",
230
+ };
231
+ }
232
+
233
+ return {
234
+ code: 0,
235
+ stdout: res.stdout || "",
236
+ stderr: res.stderr || "",
237
+ };
238
+ }
239
+
240
+ function buildMarketplaceManifest() {
241
+ return `${JSON.stringify(
242
+ {
243
+ $schema: "https://anthropic.com/claude-code/marketplace.schema.json",
244
+ name: CLAUDE_PLUGIN_MARKETPLACE_NAME,
245
+ description: "Local VibeUsage Claude plugin marketplace.",
246
+ owner: {
247
+ name: "VibeUsage",
248
+ email: "support@vibeusage.cc",
249
+ },
250
+ version: CLAUDE_PLUGIN_VERSION,
251
+ plugins: [
252
+ {
253
+ name: CLAUDE_PLUGIN_ID,
254
+ source: `./plugins/${CLAUDE_PLUGIN_ID}`,
255
+ description: "Trigger VibeUsage Claude notify bridge on Claude session lifecycle events.",
256
+ version: CLAUDE_PLUGIN_VERSION,
257
+ strict: false,
258
+ },
259
+ ],
260
+ },
261
+ null,
262
+ 2,
263
+ )}\n`;
264
+ }
265
+
266
+ function buildPluginManifest() {
267
+ return `${JSON.stringify(
268
+ {
269
+ name: CLAUDE_PLUGIN_ID,
270
+ description: "Trigger VibeUsage Claude notify bridge on Claude session lifecycle events.",
271
+ version: CLAUDE_PLUGIN_VERSION,
272
+ },
273
+ null,
274
+ 2,
275
+ )}\n`;
276
+ }
277
+
278
+ function buildPluginHooks({ notifyPath }) {
279
+ const hookCommand = buildClaudeHookCommand(notifyPath);
280
+ return `${JSON.stringify(
281
+ {
282
+ description: "Run VibeUsage Claude notify bridge on Stop and SessionEnd events.",
283
+ hooks: {
284
+ Stop: [
285
+ {
286
+ hooks: [{ type: "command", command: hookCommand }],
287
+ },
288
+ ],
289
+ SessionEnd: [
290
+ {
291
+ hooks: [{ type: "command", command: hookCommand }],
292
+ },
293
+ ],
294
+ },
295
+ },
296
+ null,
297
+ 2,
298
+ )}\n`;
299
+ }
300
+
301
+ function unreadableState(paths, detail) {
302
+ return {
303
+ configured: false,
304
+ enabled: false,
305
+ installed: false,
306
+ marketplaceDeclared: false,
307
+ pluginFilesReady: false,
308
+ pluginRef: paths.pluginRef,
309
+ unreadable: true,
310
+ detail,
311
+ ...paths,
312
+ };
313
+ }
314
+
315
+ function marketplaceMatchesPath({ marketplaceEntry, marketplaceDir } = {}) {
316
+ if (!marketplaceEntry || typeof marketplaceEntry !== "object") return false;
317
+
318
+ const source = marketplaceEntry.source;
319
+ if (!source || typeof source !== "object") return false;
320
+ if (source.source !== "path") return false;
321
+ if (typeof source.path !== "string" || source.path.length === 0) return false;
322
+
323
+ return path.resolve(source.path) === path.resolve(marketplaceDir);
324
+ }
325
+
326
+ async function listMarketplaceSiblingPluginRefs({
327
+ installedPluginsPath,
328
+ marketplaceName,
329
+ excludePluginRef,
330
+ } = {}) {
331
+ const installed = await readJsonStrict(installedPluginsPath);
332
+ if (installed.status === "invalid") {
333
+ return { unreadable: true, refs: [] };
334
+ }
335
+
336
+ const plugins =
337
+ installed.value?.plugins && typeof installed.value.plugins === "object"
338
+ ? installed.value.plugins
339
+ : {};
340
+ const refs = Object.entries(plugins)
341
+ .filter(([ref, entries]) => {
342
+ if (ref === excludePluginRef) return false;
343
+ if (!ref.endsWith(`@${marketplaceName}`)) return false;
344
+ return Array.isArray(entries) && entries.length > 0;
345
+ })
346
+ .map(([ref]) => ref);
347
+
348
+ return { unreadable: false, refs };
349
+ }
350
+
351
+ function isNonEmptyObject(value) {
352
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
353
+ }
354
+
355
+ async function isFile(targetPath) {
356
+ try {
357
+ const stat = await fs.stat(targetPath);
358
+ return stat.isFile();
359
+ } catch (_err) {
360
+ return false;
361
+ }
362
+ }
363
+
364
+ async function isDir(targetPath) {
365
+ try {
366
+ const stat = await fs.stat(targetPath);
367
+ return stat.isDirectory();
368
+ } catch (_err) {
369
+ return false;
370
+ }
371
+ }
372
+
373
+ module.exports = {
374
+ CLAUDE_PLUGIN_MARKETPLACE_NAME,
375
+ CLAUDE_PLUGIN_ID,
376
+ resolveClaudePluginPaths,
377
+ ensureClaudePluginFiles,
378
+ probeClaudePluginState,
379
+ installClaudePlugin,
380
+ removeClaudePluginConfig,
381
+ };