vibeusage 0.2.23 → 0.3.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.
@@ -3,20 +3,10 @@ const path = require("node:path");
3
3
  const fs = require("node:fs/promises");
4
4
 
5
5
  const { readJson } = require("../lib/fs");
6
- const { readCodexNotify, readEveryCodeNotify } = require("../lib/codex-config");
7
- const { isClaudeHookConfigured, buildClaudeHookCommand } = require("../lib/claude-config");
8
- const {
9
- resolveGeminiConfigDir,
10
- resolveGeminiSettingsPath,
11
- isGeminiHookConfigured,
12
- buildGeminiHookCommand,
13
- } = require("../lib/gemini-config");
14
- const { resolveOpencodeConfigDir, isOpencodePluginInstalled } = require("../lib/opencode-config");
15
6
  const { collectLocalSubscriptions } = require("../lib/subscriptions");
16
7
  const { normalizeState: normalizeUploadState } = require("../lib/upload-throttle");
17
8
  const { collectTrackerDiagnostics } = require("../lib/diagnostics");
18
- const { probeOpenclawHookState } = require("../lib/openclaw-hook");
19
- const { probeOpenclawSessionPluginState } = require("../lib/openclaw-session-plugin");
9
+ const { createIntegrationContext, listIntegrations, probeIntegrations } = require("../lib/integrations");
20
10
  const { resolveTrackerPaths } = require("../lib/tracker-paths");
21
11
 
22
12
  async function cmdStatus(argv = []) {
@@ -28,7 +18,7 @@ async function cmdStatus(argv = []) {
28
18
  }
29
19
 
30
20
  const home = os.homedir();
31
- const { trackerDir, binDir } = await resolveTrackerPaths({ home });
21
+ const { trackerDir } = await resolveTrackerPaths({ home });
32
22
  const configPath = path.join(trackerDir, "config.json");
33
23
  const queuePath = path.join(trackerDir, "queue.jsonl");
34
24
  const queueStatePath = path.join(trackerDir, "queue.state.json");
@@ -38,17 +28,6 @@ async function cmdStatus(argv = []) {
38
28
  const throttlePath = path.join(trackerDir, "sync.throttle");
39
29
  const uploadThrottlePath = path.join(trackerDir, "upload.throttle.json");
40
30
  const autoRetryPath = path.join(trackerDir, "auto.retry.json");
41
- const codexHome = process.env.CODEX_HOME || path.join(home, ".codex");
42
- const codexConfigPath = path.join(codexHome, "config.toml");
43
- const codeHome = process.env.CODE_HOME || path.join(home, ".code");
44
- const codeConfigPath = path.join(codeHome, "config.toml");
45
- const claudeSettingsPath = path.join(home, ".claude", "settings.json");
46
- const geminiConfigDir = resolveGeminiConfigDir({ home, env: process.env });
47
- const geminiSettingsPath = resolveGeminiSettingsPath({ configDir: geminiConfigDir });
48
- const opencodeConfigDir = resolveOpencodeConfigDir({ home, env: process.env });
49
- const notifyPath = path.join(binDir, "notify.cjs");
50
- const claudeHookCommand = buildClaudeHookCommand(notifyPath);
51
- const geminiHookCommand = buildGeminiHookCommand(notifyPath);
52
31
 
53
32
  const config = await readJson(configPath);
54
33
  const cursors = await readJson(cursorsPath);
@@ -63,27 +42,10 @@ async function cmdStatus(argv = []) {
63
42
  const lastOpenclawSync = (await safeReadText(openclawSignalPath))?.trim() || null;
64
43
  const lastNotifySpawn = parseEpochMsToIso((await safeReadText(throttlePath))?.trim() || null);
65
44
 
66
- const codexNotify = await readCodexNotify(codexConfigPath);
67
- const notifyConfigured = Array.isArray(codexNotify) && codexNotify.length > 0;
68
- const everyCodeNotify = await readEveryCodeNotify(codeConfigPath);
69
- const everyCodeConfigured = Array.isArray(everyCodeNotify) && everyCodeNotify.length > 0;
70
- const claudeHookConfigured = await isClaudeHookConfigured({
71
- settingsPath: claudeSettingsPath,
72
- hookCommand: claudeHookCommand,
73
- });
74
- const geminiHookConfigured = await isGeminiHookConfigured({
75
- settingsPath: geminiSettingsPath,
76
- hookCommand: geminiHookCommand,
77
- });
78
- const opencodePluginConfigured = await isOpencodePluginInstalled({
79
- configDir: opencodeConfigDir,
80
- });
81
- const openclawSessionPluginState = await probeOpenclawSessionPluginState({
82
- home,
83
- trackerDir,
84
- env: process.env,
85
- });
86
- const openclawHookState = await probeOpenclawHookState({ home, trackerDir, env: process.env });
45
+ const integrationContext = await createIntegrationContext({ home, env: process.env });
46
+ const probes = await probeIntegrations(integrationContext);
47
+ const descriptors = new Map(listIntegrations().map((integration) => [integration.name, integration]));
48
+ const probeByName = new Map(probes.map((probe) => [probe.name, probe]));
87
49
 
88
50
  const lastUpload = uploadThrottle.lastSuccessMs
89
51
  ? parseEpochMsToIso(uploadThrottle.lastSuccessMs)
@@ -110,6 +72,13 @@ async function cmdStatus(argv = []) {
110
72
  });
111
73
  const subscriptionLines =
112
74
  subscriptions.length > 0 ? subscriptions.map(formatSubscriptionLine) : [];
75
+ const codexProbe = probeByName.get("codex");
76
+ const everyCodeProbe = probeByName.get("every-code");
77
+ const claudeProbe = probeByName.get("claude");
78
+ const geminiProbe = probeByName.get("gemini");
79
+ const opencodeProbe = probeByName.get("opencode");
80
+ const openclawSessionProbe = probeByName.get("openclaw-session");
81
+ const openclawLegacyProbe = probeByName.get("openclaw-legacy");
113
82
 
114
83
  process.stdout.write(
115
84
  [
@@ -126,13 +95,19 @@ async function cmdStatus(argv = []) {
126
95
  `- Backoff until: ${backoffUntil || "never"}`,
127
96
  lastUploadError ? `- Last upload error: ${lastUploadError}` : null,
128
97
  autoRetryLine,
129
- `- Codex notify: ${notifyConfigured ? JSON.stringify(codexNotify) : "unset"}`,
130
- `- Every Code notify: ${everyCodeConfigured ? JSON.stringify(everyCodeNotify) : "unset"}`,
131
- `- Claude hooks: ${claudeHookConfigured ? "set" : "unset"}`,
132
- `- Gemini hooks: ${geminiHookConfigured ? "set" : "unset"}`,
133
- `- Opencode plugin: ${opencodePluginConfigured ? "set" : "unset"}`,
134
- `- OpenClaw session plugin: ${openclawSessionPluginState?.configured ? "set" : "unset"}`,
135
- `- OpenClaw hook (legacy): ${openclawHookState?.configured ? "set" : "unset"}`,
98
+ `- Codex notify: ${renderIntegrationStatus(descriptors.get("codex"), codexProbe)}`,
99
+ `- Every Code notify: ${renderIntegrationStatus(descriptors.get("every-code"), everyCodeProbe)}`,
100
+ `- Claude hooks: ${renderIntegrationStatus(descriptors.get("claude"), claudeProbe)}`,
101
+ `- Gemini hooks: ${renderIntegrationStatus(descriptors.get("gemini"), geminiProbe)}`,
102
+ `- Opencode plugin: ${renderIntegrationStatus(descriptors.get("opencode"), opencodeProbe)}`,
103
+ `- OpenClaw session plugin: ${renderIntegrationStatus(
104
+ descriptors.get("openclaw-session"),
105
+ openclawSessionProbe,
106
+ )}`,
107
+ `- OpenClaw hook (legacy): ${renderIntegrationStatus(
108
+ descriptors.get("openclaw-legacy"),
109
+ openclawLegacyProbe,
110
+ )}`,
136
111
  ...subscriptionLines,
137
112
  "",
138
113
  ]
@@ -141,6 +116,16 @@ async function cmdStatus(argv = []) {
141
116
  );
142
117
  }
143
118
 
119
+ function renderIntegrationStatus(descriptor, probe) {
120
+ if (!probe) return "unknown";
121
+ if (descriptor && typeof descriptor.renderStatusValue === "function") {
122
+ return descriptor.renderStatusValue(probe);
123
+ }
124
+ if (probe.status === "ready") return "set";
125
+ if (probe.status === "not_installed") return "unset";
126
+ return probe.status || "unknown";
127
+ }
128
+
144
129
  function formatSubscriptionLine(entry = {}) {
145
130
  const tool = String(entry.tool || "");
146
131
  const provider = String(entry.provider || "");
@@ -69,8 +69,9 @@ async function cmdSync(argv) {
69
69
  const opencodeHome = process.env.OPENCODE_HOME || path.join(xdgDataHome, "opencode");
70
70
  const opencodeStorageDir = path.join(opencodeHome, "storage");
71
71
 
72
- // OpenClaw hook integration: allow a hook to request incremental parsing for a single session jsonl.
73
- // We still parse all regular sources so model/source attribution stays complete (e.g. Kimi sessions).
72
+ // OpenClaw session-plugin integration: allow a plugin-triggered sync to request incremental parsing
73
+ // for a single session jsonl. We still parse all regular sources so model/source attribution stays
74
+ // complete (e.g. Kimi sessions).
74
75
  const openclawSignal = opts.fromOpenclaw
75
76
  ? resolveOpenclawSignal({ home, env: process.env })
76
77
  : null;
@@ -119,7 +120,7 @@ async function cmdSync(argv) {
119
120
 
120
121
  let openclawResult = { filesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
121
122
  if (openclawFiles.length > 0) {
122
- // Only runs when explicitly triggered by OpenClaw hooks.
123
+ // Only runs when explicitly triggered by OpenClaw session-plugin events.
123
124
  openclawResult = await parseOpenclawIncremental({
124
125
  sessionFiles: openclawFiles,
125
126
  cursors,
@@ -2,73 +2,27 @@ const os = require("node:os");
2
2
  const path = require("node:path");
3
3
  const fs = require("node:fs/promises");
4
4
 
5
- const { restoreCodexNotify, restoreEveryCodeNotify } = require("../lib/codex-config");
6
- const { removeClaudeHook, buildClaudeHookCommand } = require("../lib/claude-config");
7
- const {
8
- resolveGeminiConfigDir,
9
- resolveGeminiSettingsPath,
10
- buildGeminiHookCommand,
11
- removeGeminiHook,
12
- } = require("../lib/gemini-config");
13
- const { resolveOpencodeConfigDir, removeOpencodePlugin } = require("../lib/opencode-config");
14
- const { removeOpenclawHookConfig } = require("../lib/openclaw-hook");
15
- const { removeOpenclawSessionPluginConfig } = require("../lib/openclaw-session-plugin");
16
5
  const { resolveTrackerPaths } = require("../lib/tracker-paths");
6
+ const { createIntegrationContext, uninstallIntegrations } = require("../lib/integrations");
17
7
 
18
8
  async function cmdUninstall(argv) {
19
9
  const opts = parseArgs(argv);
20
10
  const home = os.homedir();
21
11
  const { trackerDir, binDir } = await resolveTrackerPaths({ home });
22
- const codexHome = process.env.CODEX_HOME || path.join(home, ".codex");
23
- const codexConfigPath = path.join(codexHome, "config.toml");
24
- const codeHome = process.env.CODE_HOME || path.join(home, ".code");
25
- const codeConfigPath = path.join(codeHome, "config.toml");
26
- const claudeSettingsPath = path.join(home, ".claude", "settings.json");
27
- const geminiConfigDir = resolveGeminiConfigDir({ home, env: process.env });
28
- const geminiSettingsPath = resolveGeminiSettingsPath({ configDir: geminiConfigDir });
29
- const opencodeConfigDir = resolveOpencodeConfigDir({ home, env: process.env });
30
12
  const notifyPath = path.join(binDir, "notify.cjs");
31
- const notifyOriginalPath = path.join(trackerDir, "codex_notify_original.json");
32
- const codeNotifyOriginalPath = path.join(trackerDir, "code_notify_original.json");
33
- const codexNotifyCmd = ["/usr/bin/env", "node", notifyPath];
34
- const codeNotifyCmd = ["/usr/bin/env", "node", notifyPath, "--source=every-code"];
35
- const claudeHookCommand = buildClaudeHookCommand(notifyPath);
36
- const geminiHookCommand = buildGeminiHookCommand(notifyPath);
37
-
38
- const codexConfigExists = await isFile(codexConfigPath);
39
- const codeConfigExists = await isFile(codeConfigPath);
40
- const claudeConfigExists = await isFile(claudeSettingsPath);
41
- const geminiConfigExists = await isDir(geminiConfigDir);
42
- const opencodeConfigExists = await isDir(opencodeConfigDir);
43
- const codexRestore = codexConfigExists
44
- ? await restoreCodexNotify({
45
- codexConfigPath,
46
- notifyOriginalPath,
47
- notifyCmd: codexNotifyCmd,
48
- })
49
- : { restored: false, skippedReason: "config-missing" };
50
- const codeRestore = codeConfigExists
51
- ? await restoreEveryCodeNotify({
52
- codeConfigPath,
53
- notifyOriginalPath: codeNotifyOriginalPath,
54
- notifyCmd: codeNotifyCmd,
55
- })
56
- : { restored: false, skippedReason: "config-missing" };
57
- const claudeRemove = claudeConfigExists
58
- ? await removeClaudeHook({ settingsPath: claudeSettingsPath, hookCommand: claudeHookCommand })
59
- : { removed: false, skippedReason: "config-missing" };
60
- const geminiRemove = geminiConfigExists
61
- ? await removeGeminiHook({ settingsPath: geminiSettingsPath, hookCommand: geminiHookCommand })
62
- : { removed: false, skippedReason: "config-missing" };
63
- const opencodeRemove = opencodeConfigExists
64
- ? await removeOpencodePlugin({ configDir: opencodeConfigDir })
65
- : { removed: false, skippedReason: "config-missing" };
66
- const openclawSessionPluginRemove = await removeOpenclawSessionPluginConfig({
13
+ const integrationContext = await createIntegrationContext({
67
14
  home,
68
- trackerDir,
69
15
  env: process.env,
16
+ trackerPaths: { trackerDir, binDir, rootDir: path.dirname(trackerDir) },
17
+ notifyPath,
70
18
  });
71
- const openclawHookRemove = await removeOpenclawHookConfig({ home, trackerDir, env: process.env });
19
+ const codexConfigExists = await isFile(integrationContext.codex.configPath);
20
+ const codeConfigExists = await isFile(integrationContext.everyCode.configPath);
21
+ const claudeConfigExists = await isFile(integrationContext.claude.settingsPath);
22
+ const geminiConfigExists = await isDir(integrationContext.gemini.configDir);
23
+ const opencodeConfigExists = await isDir(integrationContext.opencode.configDir);
24
+ const integrationResults = await uninstallIntegrations(integrationContext);
25
+ const resultByName = new Map(integrationResults.map((result) => [result.name, result]));
72
26
 
73
27
  // Remove installed notify handler.
74
28
  await fs.unlink(notifyPath).catch(() => {});
@@ -83,53 +37,73 @@ async function cmdUninstall(argv) {
83
37
  process.stdout.write(
84
38
  [
85
39
  "Uninstalled:",
86
- codexConfigExists
87
- ? codexRestore?.restored
88
- ? `- Codex notify restored: ${codexConfigPath}`
89
- : codexRestore?.skippedReason === "no-backup-not-installed"
90
- ? "- Codex notify: skipped (no backup; not installed)"
91
- : "- Codex notify: no change"
92
- : "- Codex notify: skipped (config.toml not found)",
93
- codeConfigExists
94
- ? codeRestore?.restored
95
- ? `- Every Code notify restored: ${codeConfigPath}`
96
- : codeRestore?.skippedReason === "no-backup-not-installed"
97
- ? "- Every Code notify: skipped (no backup; not installed)"
98
- : "- Every Code notify: no change"
99
- : "- Every Code notify: skipped (config.toml not found)",
100
- claudeConfigExists
101
- ? claudeRemove?.removed
102
- ? `- Claude hooks removed: ${claudeSettingsPath}`
103
- : claudeRemove?.skippedReason === "hook-missing"
104
- ? "- Claude hooks: no change"
105
- : "- Claude hooks: skipped"
106
- : "- Claude hooks: skipped (settings.json not found)",
107
- geminiConfigExists
108
- ? geminiRemove?.removed
109
- ? `- Gemini hooks removed: ${geminiSettingsPath}`
110
- : geminiRemove?.skippedReason === "hook-missing"
111
- ? "- Gemini hooks: no change"
112
- : "- Gemini hooks: skipped"
113
- : `- Gemini hooks: skipped (${geminiConfigDir} not found)`,
114
- opencodeConfigExists
115
- ? opencodeRemove?.removed
116
- ? `- Opencode plugin removed: ${opencodeConfigDir}`
117
- : opencodeRemove?.skippedReason === "plugin-missing"
118
- ? "- Opencode plugin: no change"
119
- : opencodeRemove?.skippedReason === "unexpected-content"
120
- ? "- Opencode plugin: skipped (unexpected content)"
121
- : "- Opencode plugin: skipped"
122
- : `- Opencode plugin: skipped (${opencodeConfigDir} not found)`,
123
- openclawSessionPluginRemove?.removed
124
- ? `- OpenClaw session plugin removed: ${openclawSessionPluginRemove.openclawConfigPath}`
125
- : openclawSessionPluginRemove?.skippedReason === "openclaw-config-missing"
126
- ? "- OpenClaw session plugin: skipped (openclaw config not found)"
127
- : "- OpenClaw session plugin: no change",
128
- openclawHookRemove?.removed
129
- ? `- OpenClaw hook (legacy) removed: ${openclawHookRemove.openclawConfigPath}`
130
- : openclawHookRemove?.skippedReason === "openclaw-config-missing"
131
- ? "- OpenClaw hook (legacy): skipped (openclaw config not found)"
132
- : "- OpenClaw hook (legacy): no change",
40
+ renderRestoreLine({
41
+ exists: codexConfigExists,
42
+ result: resultByName.get("codex"),
43
+ missingText: "- Codex notify: skipped (config.toml not found)",
44
+ restoredText: (result) =>
45
+ `- Codex notify restored: ${result.detail || integrationContext.codex.configPath}`,
46
+ noChangeText: "- Codex notify: no change",
47
+ skippedText: "- Codex notify: skipped (no backup; not installed)",
48
+ }),
49
+ renderRestoreLine({
50
+ exists: codeConfigExists,
51
+ result: resultByName.get("every-code"),
52
+ missingText: "- Every Code notify: skipped (config.toml not found)",
53
+ restoredText: (result) =>
54
+ `- Every Code notify restored: ${result.detail || integrationContext.everyCode.configPath}`,
55
+ noChangeText: "- Every Code notify: no change",
56
+ skippedText: "- Every Code notify: skipped (no backup; not installed)",
57
+ }),
58
+ renderHookLine({
59
+ exists: claudeConfigExists,
60
+ result: resultByName.get("claude"),
61
+ missingText: "- Claude hooks: skipped (settings.json not found)",
62
+ removedText: (result) =>
63
+ `- Claude hooks removed: ${result.detail || integrationContext.claude.settingsPath}`,
64
+ noChangeText: "- Claude hooks: no change",
65
+ skippedText: "- Claude hooks: skipped",
66
+ }),
67
+ renderHookLine({
68
+ exists: geminiConfigExists,
69
+ result: resultByName.get("gemini"),
70
+ missingText: `- Gemini hooks: skipped (${integrationContext.gemini.configDir} not found)`,
71
+ removedText: (result) =>
72
+ `- Gemini hooks removed: ${result.detail || integrationContext.gemini.settingsPath}`,
73
+ noChangeText: "- Gemini hooks: no change",
74
+ skippedText: "- Gemini hooks: skipped",
75
+ }),
76
+ renderHookLine({
77
+ exists: opencodeConfigExists,
78
+ result: resultByName.get("opencode"),
79
+ missingText: `- Opencode plugin: skipped (${integrationContext.opencode.configDir} not found)`,
80
+ removedText: (result) =>
81
+ `- Opencode plugin removed: ${result.detail || integrationContext.opencode.configDir}`,
82
+ noChangeText: "- Opencode plugin: no change",
83
+ skippedText: "- Opencode plugin: skipped (unexpected content)",
84
+ }),
85
+ renderHookLine({
86
+ exists: true,
87
+ result: resultByName.get("openclaw-session"),
88
+ missingText: "- OpenClaw session plugin: skipped (openclaw config not found)",
89
+ removedText: (result) =>
90
+ `- OpenClaw session plugin removed: ${result.detail || result.openclawConfigPath || "unknown"}`,
91
+ noChangeText: "- OpenClaw session plugin: no change",
92
+ skippedText: "- OpenClaw session plugin: no change",
93
+ unreadableText: (result) =>
94
+ `- OpenClaw session plugin: skipped (${result.detail || "openclaw config unreadable"})`,
95
+ }),
96
+ renderHookLine({
97
+ exists: true,
98
+ result: resultByName.get("openclaw-legacy"),
99
+ missingText: "- OpenClaw hook (legacy): skipped (openclaw config not found)",
100
+ removedText: (result) =>
101
+ `- OpenClaw hook (legacy) removed: ${result.detail || result.openclawConfigPath || "unknown"}`,
102
+ noChangeText: "- OpenClaw hook (legacy): no change",
103
+ skippedText: "- OpenClaw hook (legacy): no change",
104
+ unreadableText: (result) =>
105
+ `- OpenClaw hook (legacy): skipped (${result.detail || "openclaw config unreadable"})`,
106
+ }),
133
107
  opts.purge ? `- Purged: ${path.join(home, ".vibeusage")}` : "- Purge: skipped (use --purge)",
134
108
  "",
135
109
  ].join("\n"),
@@ -157,6 +131,49 @@ async function isFile(p) {
157
131
  }
158
132
  }
159
133
 
134
+ function renderRestoreLine({ exists, result, missingText, restoredText, noChangeText, skippedText }) {
135
+ if (!exists) return missingText;
136
+ if (!result) return noChangeText;
137
+ if (result.status === "restored" || result.status === "removed") {
138
+ return restoredText(result);
139
+ }
140
+ if (result.skippedReason === "no-backup-not-installed") {
141
+ return skippedText;
142
+ }
143
+ return noChangeText;
144
+ }
145
+
146
+ function renderHookLine({
147
+ exists,
148
+ result,
149
+ missingText,
150
+ removedText,
151
+ noChangeText,
152
+ skippedText,
153
+ unreadableText = null,
154
+ }) {
155
+ if (!exists) return missingText;
156
+ if (!result) return noChangeText;
157
+ if (result.status === "removed" || result.status === "updated") {
158
+ return removedText(result);
159
+ }
160
+ if (result.skippedReason === "hook-missing") {
161
+ return noChangeText;
162
+ }
163
+ if (result.skippedReason === "unexpected-content") {
164
+ return skippedText;
165
+ }
166
+ if (result.skippedReason === "openclaw-config-missing") {
167
+ return missingText;
168
+ }
169
+ if (result.skippedReason === "openclaw-config-unreadable") {
170
+ return typeof unreadableText === "function"
171
+ ? unreadableText(result)
172
+ : unreadableText || skippedText;
173
+ }
174
+ return noChangeText;
175
+ }
176
+
160
177
  async function isDir(p) {
161
178
  try {
162
179
  const st = await fs.stat(p);
@@ -3,57 +3,64 @@ const path = require("node:path");
3
3
 
4
4
  const { ensureDir, readJson, writeJson } = require("./fs");
5
5
 
6
- const DEFAULT_EVENT = "SessionEnd";
6
+ const DEFAULT_EVENTS = ["Stop", "SessionEnd"];
7
7
 
8
- async function upsertClaudeHook({ settingsPath, hookCommand, event = DEFAULT_EVENT }) {
8
+ async function upsertClaudeHook({ settingsPath, hookCommand, events = DEFAULT_EVENTS }) {
9
9
  const existing = await readJson(settingsPath);
10
10
  const settings = normalizeSettings(existing);
11
11
  const hooks = normalizeHooks(settings.hooks);
12
- const entries = normalizeEntries(hooks[event]);
13
-
14
- const normalized = normalizeEntriesForCommand(entries, hookCommand);
15
- if (normalized.changed) {
16
- const nextHooks = { ...hooks, [event]: normalized.entries };
17
- const nextSettings = { ...settings, hooks: nextHooks };
18
- const backupPath = await writeClaudeSettings({ settingsPath, settings: nextSettings });
19
- return { changed: true, backupPath };
20
- }
12
+ const targetEvents = normalizeEvents(events);
13
+ let changed = false;
14
+ const nextHooks = { ...hooks };
15
+
16
+ for (const event of targetEvents) {
17
+ const entries = normalizeEntries(nextHooks[event]);
18
+ const normalized = normalizeEntriesForCommand(entries, hookCommand);
19
+ if (normalized.changed) {
20
+ nextHooks[event] = normalized.entries;
21
+ changed = true;
22
+ continue;
23
+ }
24
+
25
+ if (hasHook(entries, hookCommand)) continue;
21
26
 
22
- if (hasHook(entries, hookCommand)) {
23
- return { changed: false, backupPath: null };
27
+ nextHooks[event] = entries.concat([{ hooks: [{ type: "command", command: hookCommand }] }]);
28
+ changed = true;
24
29
  }
25
30
 
26
- const nextEntries = entries.concat([{ hooks: [{ type: "command", command: hookCommand }] }]);
27
- const nextHooks = { ...hooks, [event]: nextEntries };
28
- const nextSettings = { ...settings, hooks: nextHooks };
31
+ if (!changed) return { changed: false, backupPath: null };
29
32
 
33
+ const nextSettings = { ...settings, hooks: nextHooks };
30
34
  const backupPath = await writeClaudeSettings({ settingsPath, settings: nextSettings });
31
35
  return { changed: true, backupPath };
32
36
  }
33
37
 
34
- async function removeClaudeHook({ settingsPath, hookCommand, event = DEFAULT_EVENT }) {
38
+ async function removeClaudeHook({ settingsPath, hookCommand, events = DEFAULT_EVENTS }) {
35
39
  const existing = await readJson(settingsPath);
36
40
  if (!existing) return { removed: false, skippedReason: "settings-missing" };
37
41
 
38
42
  const settings = normalizeSettings(existing);
39
43
  const hooks = normalizeHooks(settings.hooks);
40
- const entries = normalizeEntries(hooks[event]);
41
- if (entries.length === 0) return { removed: false, skippedReason: "hook-missing" };
42
-
44
+ const targetEvents = normalizeEvents(events);
43
45
  let removed = false;
44
- const nextEntries = [];
45
- for (const entry of entries) {
46
- const res = stripHookFromEntry(entry, hookCommand);
47
- if (res.removed) removed = true;
48
- if (res.entry) nextEntries.push(res.entry);
46
+ const nextHooks = { ...hooks };
47
+ for (const event of targetEvents) {
48
+ const entries = normalizeEntries(hooks[event]);
49
+ if (entries.length === 0) continue;
50
+
51
+ const nextEntries = [];
52
+ for (const entry of entries) {
53
+ const res = stripHookFromEntry(entry, hookCommand);
54
+ if (res.removed) removed = true;
55
+ if (res.entry) nextEntries.push(res.entry);
56
+ }
57
+
58
+ if (nextEntries.length > 0) nextHooks[event] = nextEntries;
59
+ else delete nextHooks[event];
49
60
  }
50
61
 
51
62
  if (!removed) return { removed: false, skippedReason: "hook-missing" };
52
63
 
53
- const nextHooks = { ...hooks };
54
- if (nextEntries.length > 0) nextHooks[event] = nextEntries;
55
- else delete nextHooks[event];
56
-
57
64
  const nextSettings = { ...settings };
58
65
  if (Object.keys(nextHooks).length > 0) nextSettings.hooks = nextHooks;
59
66
  else delete nextSettings.hooks;
@@ -62,13 +69,31 @@ async function removeClaudeHook({ settingsPath, hookCommand, event = DEFAULT_EVE
62
69
  return { removed: true, skippedReason: null, backupPath };
63
70
  }
64
71
 
65
- async function isClaudeHookConfigured({ settingsPath, hookCommand, event = DEFAULT_EVENT }) {
72
+ async function isClaudeHookConfigured({ settingsPath, hookCommand, events = DEFAULT_EVENTS }) {
73
+ const probe = await probeClaudeHook({ settingsPath, hookCommand, events });
74
+ return probe.configured;
75
+ }
76
+
77
+ async function probeClaudeHook({ settingsPath, hookCommand, events = DEFAULT_EVENTS }) {
66
78
  const settings = await readJson(settingsPath);
67
- if (!settings || typeof settings !== "object") return false;
79
+ if (!settings || typeof settings !== "object") {
80
+ return { configured: false, anyPresent: false, eventStates: {} };
81
+ }
68
82
  const hooks = settings.hooks;
69
- if (!hooks || typeof hooks !== "object") return false;
70
- const entries = normalizeEntries(hooks[event]);
71
- return hasHook(entries, hookCommand);
83
+ if (!hooks || typeof hooks !== "object") {
84
+ return { configured: false, anyPresent: false, eventStates: {} };
85
+ }
86
+ const targetEvents = normalizeEvents(events);
87
+ const eventStates = {};
88
+ for (const event of targetEvents) {
89
+ eventStates[event] = hasHook(normalizeEntries(hooks[event]), hookCommand);
90
+ }
91
+ const anyPresent = Object.values(eventStates).some(Boolean);
92
+ return {
93
+ configured: targetEvents.every((event) => eventStates[event] === true),
94
+ anyPresent,
95
+ eventStates,
96
+ };
72
97
  }
73
98
 
74
99
  function buildClaudeHookCommand(notifyPath) {
@@ -88,9 +113,27 @@ function normalizeEntries(raw) {
88
113
  return Array.isArray(raw) ? raw.slice() : [];
89
114
  }
90
115
 
116
+ function normalizeEvents(raw) {
117
+ const values = Array.isArray(raw) ? raw : [raw];
118
+ const out = [];
119
+ for (const value of values) {
120
+ if (typeof value !== "string") continue;
121
+ const normalized = value.trim();
122
+ if (!normalized || out.includes(normalized)) continue;
123
+ out.push(normalized);
124
+ }
125
+ return out.length > 0 ? out : DEFAULT_EVENTS.slice();
126
+ }
127
+
91
128
  function normalizeCommand(cmd) {
92
129
  if (Array.isArray(cmd)) return cmd.map((v) => String(v)).join("\u0000");
93
- if (typeof cmd === "string") return cmd.trim();
130
+ if (typeof cmd === "string") {
131
+ const raw = cmd.trim();
132
+ if (!raw) return null;
133
+ const parsed = splitShellCommand(raw);
134
+ if (parsed) return parsed.join("\u0000");
135
+ return raw;
136
+ }
94
137
  return null;
95
138
  }
96
139
 
@@ -166,6 +209,56 @@ function quoteArg(value) {
166
209
  return `"${v.replace(/"/g, '\\"')}"`;
167
210
  }
168
211
 
212
+ function splitShellCommand(raw) {
213
+ const parts = [];
214
+ let current = "";
215
+ let quote = null;
216
+ let escaping = false;
217
+
218
+ for (let i = 0; i < raw.length; i += 1) {
219
+ const ch = raw[i];
220
+
221
+ if (escaping) {
222
+ current += ch;
223
+ escaping = false;
224
+ continue;
225
+ }
226
+
227
+ if (ch === "\\") {
228
+ escaping = true;
229
+ continue;
230
+ }
231
+
232
+ if (quote) {
233
+ if (ch === quote) {
234
+ quote = null;
235
+ } else {
236
+ current += ch;
237
+ }
238
+ continue;
239
+ }
240
+
241
+ if (ch === '"' || ch === "'") {
242
+ quote = ch;
243
+ continue;
244
+ }
245
+
246
+ if (/\s/.test(ch)) {
247
+ if (current) {
248
+ parts.push(current);
249
+ current = "";
250
+ }
251
+ continue;
252
+ }
253
+
254
+ current += ch;
255
+ }
256
+
257
+ if (escaping || quote) return null;
258
+ if (current) parts.push(current);
259
+ return parts.length > 0 ? parts : null;
260
+ }
261
+
169
262
  async function writeClaudeSettings({ settingsPath, settings }) {
170
263
  await ensureDir(path.dirname(settingsPath));
171
264
  let backupPath = null;
@@ -183,8 +276,10 @@ async function writeClaudeSettings({ settingsPath, settings }) {
183
276
  }
184
277
 
185
278
  module.exports = {
279
+ DEFAULT_EVENTS,
186
280
  upsertClaudeHook,
187
281
  removeClaudeHook,
188
282
  isClaudeHookConfigured,
283
+ probeClaudeHook,
189
284
  buildClaudeHookCommand,
190
285
  };