@ghl-ai/aw 0.1.93 → 0.2.1

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.
@@ -1,105 +1,41 @@
1
- // install-aw-usage-hooks.mjs — Self-sufficient producer-hook install.
1
+ // install-aw-usage-hooks.mjs — install the aw-usage telemetry hooks on `aw init`.
2
2
  //
3
- // Why this exists:
4
- // The aw-usage-*.js producer hooks (which emit telemetry events to the
5
- // live cloud DB) used to live in aw-ecc and required a separate install +
6
- // manual wiring into ~/.claude/settings.json. Devs running
7
- // npm install -g @ghl-ai/aw && aw init
8
- // would NOT get telemetry flowing — there was a silent gap in the
9
- // installer. This module bundles the producer scripts into @ghl-ai/aw
10
- // and writes them + the hook wiring during `aw init` so that
11
- // step-1-then-step-2 is genuinely sufficient.
3
+ // This repo is the single source of truth for the aw-usage hook scripts
4
+ // (libs/aw/hooks/aw-usage/). aw-ecc no longer ships or wires them.
12
5
  //
13
6
  // What it does on `aw init`:
14
- // 1. Copies bundled aw-usage scripts (libs/aw/hooks/aw-usage/*.js) into
15
- // ~/.claude/scripts/hooks/
16
- // 2. Copies the supporting lib files (aw-usage-telemetry.js, aw-pricing.js)
17
- // into ~/.claude/scripts/lib/
18
- // 3. Non-destructively MERGES five hook phases into ~/.claude/settings.json:
19
- // SessionStart, UserPromptSubmit, PostToolUse, PostToolUseFailure, Stop
20
- // (Each phase array gets ONE new entry appended, with `description` so
21
- // it can be identified + re-applied on future runs.)
22
- // 4. Ensures ~/.aw/telemetry/config.json exists with machine_id + enabled
23
- // (so the hooks can self-resolve their config on first fire).
7
+ // 1. Copies the bundled scripts to ~/.aw/aw-usage/{hooks,lib,package.json}
8
+ // — one harness-neutral location used by Claude Code, Codex, git
9
+ // post-commit and the Cursor adapter.
10
+ // 2. Merges the Claude Code entries into ~/.claude/settings.json: one
11
+ // "AW usage telemetry" entry per phase (SessionStart, UserPromptSubmit,
12
+ // PostToolUse, PostToolUseFailure, Stop) plus one
13
+ // "AW-managed: aw-memory stop hooks" entry. Legacy entries pointing at
14
+ // ~/.aw-ecc, ${CLAUDE_PLUGIN_ROOT} or ~/.claude/scripts are replaced;
15
+ // unrelated hooks are kept in order.
16
+ // 3. Ensures ~/.aw/telemetry/config.json exists with machine_id + enabled.
24
17
  //
25
- // What it does NOT do:
26
- // - Overwrite existing hooks (the graphify SessionStart, plugin hooks, etc.
27
- // are preserved verbatim — we only append).
28
- // - Set telemetry_url. Default (omitted) → events go to the production API
29
- // at services.leadconnectorhq.com. Devs can override locally for
30
- // localhost testing.
31
- // - Touch ~/.codex/. Codex hook wiring is handled separately (TODO: extend
32
- // here when we have the same pattern stabilized for Codex).
18
+ // startup.mjs (aw link / pull / startup / post-upgrade) runs the same copy
19
+ // and merge through aw-usage-wiring.mjs, so both writers always agree.
33
20
  //
34
- // Idempotent: re-running `aw init` is a no-op once everything is in place.
21
+ // It does NOT set telemetry_url (default: production API) and does not touch
22
+ // ~/.codex (see hooks/codex-home.mjs). Idempotent: re-running is a no-op.
35
23
 
36
- import { readFileSync, writeFileSync, mkdirSync, copyFileSync, existsSync, readdirSync } from 'node:fs';
24
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
37
25
  import { join, dirname } from 'node:path';
38
- import { fileURLToPath } from 'node:url';
39
26
  import { homedir, hostname, userInfo } from 'node:os';
40
27
  import { createHash } from 'node:crypto';
28
+ import {
29
+ copyAwUsageScripts,
30
+ mergeClaudeAwUsageHooks,
31
+ missingAwUsageFiles,
32
+ } from './aw-usage-wiring.mjs';
41
33
 
42
- const __dirname = dirname(fileURLToPath(import.meta.url));
43
- // Bundled layout (mirrors aw-ecc/scripts/) so the scripts' relative
44
- // `require('../lib/aw-usage-telemetry')` paths resolve correctly:
45
- // libs/aw/hooks/aw-usage/
46
- // ├── package.json ("type": "commonjs" — overrides parent ESM)
47
- // ├── hooks/ (7 scripts)
48
- // └── lib/ (aw-usage-telemetry.js, aw-pricing.js)
49
- const BUNDLED_ROOT = join(__dirname, 'hooks', 'aw-usage');
50
- const BUNDLED_HOOKS_DIR = join(BUNDLED_ROOT, 'hooks');
51
- const BUNDLED_LIB_DIR = join(BUNDLED_ROOT, 'lib');
52
34
  const HOME = homedir();
53
-
54
- // IDE targets we install into. Claude Code is the only fully-wired target.
55
- // Cursor + Codex are deliberately deferred — copying scripts without wiring
56
- // the matching hooks.json was misleading (events never fired). A follow-up
57
- // PR will wire ~/.cursor/hooks/hooks.json properly.
58
- const IDE_TARGETS = [
59
- { name: 'Claude Code', configFile: join(HOME, '.claude', 'settings.json'), scriptsRoot: join(HOME, '.claude', 'scripts'), shouldWireConfig: true },
60
- ];
61
-
62
- const HOOK_SCRIPTS = [
63
- 'aw-usage-prompt-submit.js',
64
- 'aw-usage-post-tool-use.js',
65
- 'aw-usage-post-tool-use-failure.js',
66
- 'aw-usage-session-start.js',
67
- 'aw-usage-stop.js',
68
- 'aw-usage-telemetry-send.js',
69
- 'aw-usage-commit-created.js',
70
- ];
71
-
72
- const LIB_FILES = [
73
- 'aw-usage-telemetry.js',
74
- 'aw-pricing.js',
75
- ];
76
-
77
- // Hook description marker — used to detect prior install + idempotent re-apply.
78
- const MARKER = 'AW usage telemetry';
35
+ const CLAUDE_SETTINGS = join(HOME, '.claude', 'settings.json');
79
36
 
80
37
  // ── Helpers ──────────────────────────────────────────────────────────
81
38
 
82
- function safeMkdir(dir) {
83
- try { mkdirSync(dir, { recursive: true }); } catch { /* best effort */ }
84
- }
85
-
86
- // Tri-state result so callers can distinguish a real failure from a no-op:
87
- // 'copied' — wrote bytes
88
- // 'unchanged' — destination already matches source byte-for-byte
89
- // 'failed' — source missing OR write/read threw (permissions, EBUSY, …)
90
- function copyIfChanged(src, dest) {
91
- if (!existsSync(src)) return 'failed';
92
- try {
93
- if (existsSync(dest)) {
94
- const a = readFileSync(src);
95
- const b = readFileSync(dest);
96
- if (a.equals(b)) return 'unchanged';
97
- }
98
- copyFileSync(src, dest);
99
- return 'copied';
100
- } catch { return 'failed'; }
101
- }
102
-
103
39
  function generateMachineId() {
104
40
  return createHash('sha256')
105
41
  .update(`${hostname()}:${userInfo().username}`)
@@ -110,7 +46,7 @@ function generateMachineId() {
110
46
 
111
47
  function ensureTelemetryConfig() {
112
48
  const cfgPath = join(HOME, '.aw', 'telemetry', 'config.json');
113
- safeMkdir(dirname(cfgPath));
49
+ mkdirSync(dirname(cfgPath), { recursive: true });
114
50
  let cfg = {};
115
51
  if (existsSync(cfgPath)) {
116
52
  try { cfg = JSON.parse(readFileSync(cfgPath, 'utf8')); } catch { cfg = {}; }
@@ -130,93 +66,33 @@ function ensureTelemetryConfig() {
130
66
 
131
67
  // ── settings.json hook merge ─────────────────────────────────────────
132
68
 
133
- function settingsHookEntry(scriptName, description, scriptsRoot, withMatcher) {
134
- // Use $HOME so the path is portable across machines. Shell expansion
135
- // applies when Claude invokes the command.
136
- const portablePath = scriptsRoot
137
- .replace(HOME, '$HOME')
138
- .split(/[\\/]/).join('/');
139
- const cmd = `node "${portablePath}/hooks/${scriptName}"`;
140
- const entry = {
141
- hooks: [{
142
- type: 'command',
143
- command: cmd,
144
- async: true,
145
- timeout: 10,
146
- }],
147
- description,
148
- };
149
- if (withMatcher) entry.matcher = '*';
150
- return entry;
151
- }
152
-
153
- function alreadyWired(phaseArray, expectedCommand) {
154
- if (!Array.isArray(phaseArray)) return false;
155
- return phaseArray.some(group =>
156
- Array.isArray(group?.hooks) &&
157
- group.hooks.some(h => h?.command === expectedCommand)
158
- );
159
- }
160
-
161
- function isLegacyAwUsageHookGroup(group, expectedCommand, scriptName) {
162
- if (!Array.isArray(group?.hooks)) return false;
163
- const commands = group.hooks
164
- .map(h => h?.command)
165
- .filter(command => typeof command === 'string');
166
- const hasExpected = commands.includes(expectedCommand);
167
- const hasLegacyAwUsage = commands.some((command) => {
168
- const normalized = command.split('\\').join('/');
169
- return command.includes(scriptName) &&
170
- (normalized.includes('/.aw-ecc/') || command.includes('${CLAUDE_PLUGIN_ROOT}'));
171
- });
172
- return !hasExpected && hasLegacyAwUsage;
173
- }
174
-
175
- function mergeSettings(configFile, scriptsRoot) {
176
- safeMkdir(dirname(configFile));
177
- let cfg = {};
178
- if (existsSync(configFile)) {
179
- try { cfg = JSON.parse(readFileSync(configFile, 'utf8')); } catch { cfg = {}; }
180
- }
181
- cfg.hooks = cfg.hooks && typeof cfg.hooks === 'object' && !Array.isArray(cfg.hooks)
182
- ? cfg.hooks
183
- : {};
184
-
185
- const phases = [
186
- { name: 'SessionStart', script: 'aw-usage-session-start.js', desc: `${MARKER}: session_start`, matcher: false },
187
- { name: 'UserPromptSubmit', script: 'aw-usage-prompt-submit.js', desc: `${MARKER}: prompt_submitted with slash-command detection`, matcher: false },
188
- { name: 'PostToolUse', script: 'aw-usage-post-tool-use.js', desc: `${MARKER}: skill/agent/error/test/sdlc events`, matcher: true },
189
- { name: 'PostToolUseFailure', script: 'aw-usage-post-tool-use-failure.js', desc: `${MARKER}: tool_error on failure`, matcher: true },
190
- { name: 'Stop', script: 'aw-usage-stop.js', desc: `${MARKER}: response_completed with tokens + cost`, matcher: true },
191
- ];
192
-
193
- let changed = 0;
194
- for (const phase of phases) {
195
- // Normalize: existing config may have a non-array value here from a manual
196
- // edit or another tool — don't trust truthiness, demand an array.
197
- cfg.hooks[phase.name] = Array.isArray(cfg.hooks[phase.name]) ? cfg.hooks[phase.name] : [];
198
- const entry = settingsHookEntry(phase.script, phase.desc, scriptsRoot, phase.matcher);
199
- const before = cfg.hooks[phase.name].length;
200
- cfg.hooks[phase.name] = cfg.hooks[phase.name].filter(group =>
201
- !isLegacyAwUsageHookGroup(group, entry.hooks[0].command, phase.script)
202
- );
203
- changed += before - cfg.hooks[phase.name].length;
204
- if (!alreadyWired(cfg.hooks[phase.name], entry.hooks[0].command)) {
205
- cfg.hooks[phase.name].push(entry);
206
- changed++;
207
- }
69
+ // Returns null when the file exists but is not a JSON object, so callers
70
+ // never overwrite (and wipe) a settings.json they cannot understand.
71
+ function readSettings(configFile) {
72
+ if (!existsSync(configFile)) return {};
73
+ try {
74
+ const parsed = JSON.parse(readFileSync(configFile, 'utf8'));
75
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
76
+ } catch {
77
+ return null;
208
78
  }
79
+ }
209
80
 
210
- if (changed > 0) {
211
- writeFileSync(configFile, JSON.stringify(cfg, null, 2) + '\n');
212
- }
213
- return { added: changed, configFile };
81
+ /** @returns {'written'|'unchanged'|'unreadable'} */
82
+ function mergeSettings(configFile, installed) {
83
+ const cfg = readSettings(configFile);
84
+ if (cfg === null) return 'unreadable';
85
+ const hooks = mergeClaudeAwUsageHooks(cfg.hooks, { installed });
86
+ if (JSON.stringify(hooks) === JSON.stringify(cfg.hooks)) return 'unchanged';
87
+ mkdirSync(dirname(configFile), { recursive: true });
88
+ writeFileSync(configFile, JSON.stringify({ ...cfg, hooks }, null, 2) + '\n');
89
+ return 'written';
214
90
  }
215
91
 
216
92
  // ── Main entry ───────────────────────────────────────────────────────
217
93
 
218
94
  /**
219
- * Install the aw-usage producer hooks into ~/.claude/.
95
+ * Install the aw-usage hooks into ~/.aw/aw-usage and ~/.claude/settings.json.
220
96
  * Idempotent — safe to re-run.
221
97
  *
222
98
  * @returns {{
@@ -227,61 +103,34 @@ function mergeSettings(configFile, scriptsRoot) {
227
103
  * libUnchanged: number,
228
104
  * libFailed: number,
229
105
  * targetsConfigured: string[],
106
+ * settingsUnreadable: string | null,
230
107
  * telemetryConfigInitialized: boolean,
231
108
  * }}
232
109
  */
233
110
  export function installAwUsageHooks() {
111
+ const copy = copyAwUsageScripts(HOME);
234
112
  const result = {
235
- scriptsCopied: 0,
236
- scriptsUnchanged: 0,
237
- scriptsFailed: 0,
238
- libCopied: 0,
239
- libUnchanged: 0,
240
- libFailed: 0,
113
+ scriptsCopied: copy.byDir.hooks.copied,
114
+ scriptsUnchanged: copy.byDir.hooks.unchanged,
115
+ scriptsFailed: copy.byDir.hooks.failed,
116
+ libCopied: copy.byDir.lib.copied,
117
+ libUnchanged: copy.byDir.lib.unchanged,
118
+ // package.json is part of the lib layer: it makes require() work.
119
+ libFailed: copy.byDir.lib.failed + copy.byDir.root.failed,
241
120
  targetsConfigured: [],
121
+ settingsUnreadable: null,
242
122
  telemetryConfigInitialized: false,
243
123
  };
244
124
 
245
- if (!existsSync(BUNDLED_HOOKS_DIR) || !existsSync(BUNDLED_LIB_DIR)) {
246
- // Bundled scripts missing — nothing to install. Caller can decide.
247
- return result;
248
- }
249
-
250
- // 1. Copy scripts + lib to each IDE target
251
- for (const target of IDE_TARGETS) {
252
- const hooksDest = join(target.scriptsRoot, 'hooks');
253
- const libDest = join(target.scriptsRoot, 'lib');
254
- safeMkdir(hooksDest);
255
- safeMkdir(libDest);
256
-
257
- for (const name of HOOK_SCRIPTS) {
258
- const src = join(BUNDLED_HOOKS_DIR, name);
259
- const dest = join(hooksDest, name);
260
- const r = copyIfChanged(src, dest);
261
- if (r === 'copied') result.scriptsCopied++;
262
- else if (r === 'unchanged') result.scriptsUnchanged++;
263
- else result.scriptsFailed++;
264
- }
265
- for (const name of LIB_FILES) {
266
- const src = join(BUNDLED_LIB_DIR, name);
267
- const dest = join(libDest, name);
268
- const r = copyIfChanged(src, dest);
269
- if (r === 'copied') result.libCopied++;
270
- else if (r === 'unchanged') result.libUnchanged++;
271
- else result.libFailed++;
272
- }
273
-
274
- // 2. Merge hook wiring into settings.json (Claude only for now)
275
- if (target.shouldWireConfig && target.configFile) {
276
- const { added, configFile } = mergeSettings(target.configFile, target.scriptsRoot);
277
- if (added > 0) result.targetsConfigured.push(`${target.name}:${configFile}`);
278
- }
279
- }
280
-
281
- // 3. Bootstrap telemetry config
282
- const { changed } = ensureTelemetryConfig();
283
- result.telemetryConfigInitialized = changed;
125
+ // If any file failed to copy, mergeSettings keeps the existing entries
126
+ // instead of pointing Claude at an incomplete ~/.aw/aw-usage. A file can
127
+ // exist and still fail to copy (unreadable, read-only), so check both.
128
+ const installed = copy.failed === 0 && missingAwUsageFiles(HOME).length === 0;
129
+ const merge = mergeSettings(CLAUDE_SETTINGS, installed);
130
+ if (merge === 'written') result.targetsConfigured.push(`Claude Code:${CLAUDE_SETTINGS}`);
131
+ if (merge === 'unreadable') result.settingsUnreadable = CLAUDE_SETTINGS;
284
132
 
133
+ result.telemetryConfigInitialized = ensureTelemetryConfig().changed;
285
134
  return result;
286
135
  }
287
136
 
@@ -297,7 +146,8 @@ export function formatAwUsageHooksInstallReport(result) {
297
146
  // Surface failures even when everything else is a no-op, so an install that
298
147
  // silently lost files doesn't masquerade as "already up to date".
299
148
  const failedTotal = (result.scriptsFailed || 0) + (result.libFailed || 0);
300
- if (failedTotal > 0) parts.push(`⚠ ${failedTotal} failed (check ~/.claude/scripts/ permissions)`);
149
+ if (failedTotal > 0) parts.push(`⚠ ${failedTotal} failed (check ~/.aw/aw-usage/ permissions)`);
150
+ if (result.settingsUnreadable) parts.push(`⚠ ${result.settingsUnreadable} is not valid JSON, left unchanged`);
301
151
  if (parts.length === 0) return 'aw-usage hooks already up to date';
302
152
  return `aw-usage hooks: ${parts.join(' · ')}`;
303
153
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghl-ai/aw",
3
- "version": "0.1.93",
3
+ "version": "0.2.1",
4
4
  "description": "Agentic Workspace CLI — pull, push & manage agents, skills and commands from the registry",
5
5
  "type": "module",
6
6
  "bin": {
@@ -36,6 +36,7 @@
36
36
  "update.mjs",
37
37
  "hooks.mjs",
38
38
  "hooks/",
39
+ "aw-usage-wiring.mjs",
39
40
  "install-aw-usage-hooks.mjs",
40
41
  "startup.mjs",
41
42
  "ecc.mjs",
package/startup.mjs CHANGED
@@ -9,6 +9,17 @@ import {
9
9
  getCodexHomePhaseDefinitions,
10
10
  isManagedCodexHomeEntry,
11
11
  } from './hooks/codex-home.mjs';
12
+ import {
13
+ copyAwUsageScripts,
14
+ ensureAwUsageVersion,
15
+ mergeClaudeAwUsageHooks,
16
+ missingAwUsageFiles,
17
+ removeClaudeAwUsageHooks,
18
+ warnIncompleteAwUsageInstall,
19
+ warnStuckAwUsageFiles,
20
+ warnUnreadableClaudeSettings,
21
+ } from './aw-usage-wiring.mjs';
22
+ import { codexHooksFeatureEnabled, ensureCodexHooksFeature } from './codex.mjs';
12
23
 
13
24
  const STARTUP_PREFS_FILENAME = 'startup-preferences.json';
14
25
  const ENABLED_MODE = 'enabled';
@@ -33,34 +44,6 @@ const CLAUDE_ROUTER_COMMAND =
33
44
  'for f in "$HOME/.aw/.aw_registry/aw/skills/using-aw-skills/hooks/session-start.sh" "$HOME/.aw_registry/aw/skills/using-aw-skills/hooks/session-start.sh" "$HOME/.aw/.aw_registry/platform/core/skills/using-aw-skills/hooks/session-start.sh" "$HOME/.aw_registry/platform/core/skills/using-aw-skills/hooks/session-start.sh"; do [ -f "$f" ] && exec bash "$f"; done; exit 0';
34
45
  const CLAUDE_REMINDER_COMMAND =
35
46
  'bash -lc \'exec bash "${CLAUDE_PLUGIN_ROOT:-$HOME/.claude}/scripts/hooks/session-start-rules-context.sh"\'';
36
- const CLAUDE_TELEMETRY_DESCRIPTION = 'AW usage telemetry';
37
- const CLAUDE_TELEMETRY_HOOKS = [
38
- {
39
- phase: 'SessionStart',
40
- matcher: undefined,
41
- command: 'test -f "$HOME/.aw-ecc/scripts/hooks/aw-usage-session-start.js" && node "$HOME/.aw-ecc/scripts/hooks/aw-usage-session-start.js" || true',
42
- },
43
- {
44
- phase: 'PostToolUse',
45
- matcher: 'Skill|Agent|Shell|Bash',
46
- command: 'test -f "$HOME/.aw-ecc/scripts/hooks/aw-usage-post-tool-use.js" && node "$HOME/.aw-ecc/scripts/hooks/aw-usage-post-tool-use.js" || true',
47
- },
48
- {
49
- phase: 'Stop',
50
- matcher: undefined,
51
- command: 'STDIN="$(cat)"; test -f "$HOME/.aw-ecc/scripts/hooks/aw-usage-stop.js" && printf \'%s\' "$STDIN" | node "$HOME/.aw-ecc/scripts/hooks/aw-usage-stop.js" || true; test -f "$HOME/.aw-ecc/scripts/hooks/aw-memory-intent-capture.js" && printf \'%s\' "$STDIN" | node "$HOME/.aw-ecc/scripts/hooks/aw-memory-intent-capture.js" || true; test -f "$HOME/.aw-ecc/scripts/hooks/aw-memory-sync.js" && printf \'%s\' "$STDIN" | node "$HOME/.aw-ecc/scripts/hooks/aw-memory-sync.js" || true',
52
- },
53
- {
54
- phase: 'PostToolUseFailure',
55
- matcher: undefined,
56
- command: 'test -f "$HOME/.aw-ecc/scripts/hooks/aw-usage-post-tool-use-failure.js" && node "$HOME/.aw-ecc/scripts/hooks/aw-usage-post-tool-use-failure.js" || true',
57
- },
58
- {
59
- phase: 'UserPromptSubmit',
60
- matcher: undefined,
61
- command: 'test -f "$HOME/.aw-ecc/scripts/hooks/aw-usage-prompt-submit.js" && node "$HOME/.aw-ecc/scripts/hooks/aw-usage-prompt-submit.js" || true',
62
- },
63
- ];
64
47
  const CURSOR_SESSION_START_COMMAND = 'node .cursor/hooks/session-start.js';
65
48
  const CURSOR_SESSION_START_DESCRIPTION = 'Load previous context and detect environment';
66
49
  const REPO_CURSOR_SESSION_START_COMMAND = 'bash "$(git rev-parse --show-toplevel)/hooks/aw-session-start"';
@@ -257,86 +240,42 @@ function hasCommandHook(entry, command) {
257
240
  );
258
241
  }
259
242
 
260
- function isManagedClaudeTelemetryEntry(entry) {
261
- if (entry?.description === CLAUDE_TELEMETRY_DESCRIPTION) return true;
262
- // Also match legacy entries (no description) by command pattern
263
- const cmds = Array.isArray(entry?.hooks) ? entry.hooks.map(h => h?.command || '') : [];
264
- return cmds.some(c => c.includes('aw-usage-'));
265
- }
266
-
267
- function buildClaudeTelemetryEntry(hookDef) {
268
- const entry = {
269
- hooks: [
270
- {
271
- type: 'command',
272
- command: hookDef.command,
273
- },
274
- ],
275
- description: CLAUDE_TELEMETRY_DESCRIPTION,
276
- };
277
- if (hookDef.matcher) {
278
- entry.matcher = hookDef.matcher;
279
- }
280
- return entry;
281
- }
282
-
283
- /**
284
- * Resolve the filesystem path from a telemetry hook command.
285
- * Commands look like: node "$HOME/.aw-ecc/scripts/hooks/aw-usage-stop.js"
286
- */
287
- function resolveHookScriptPath(command, homeDir) {
288
- const match = command.match(/"\$HOME\/([^"]+)"/);
289
- if (match) return join(homeDir, match[1]);
290
- const matchBare = command.match(/\$HOME\/(\S+)/);
291
- if (matchBare) return join(homeDir, matchBare[1]);
243
+ // null = settings.json exists but is not a JSON object. Every Claude writer
244
+ // here returns early on null so an unreadable file is never replaced.
245
+ function readClaudeSettings(settingsPath) {
246
+ if (!existsSync(settingsPath)) return {};
247
+ const config = readJson(settingsPath, null);
248
+ if (isObject(config)) return config;
249
+ warnUnreadableClaudeSettings(settingsPath);
292
250
  return null;
293
251
  }
294
252
 
253
+ // Claude aw-usage telemetry + AW memory Stop entries. The shape and the merge
254
+ // live in aw-usage-wiring.mjs so this writer and install-aw-usage-hooks.mjs
255
+ // always produce the same settings.json.
295
256
  function enableClaudeTelemetryHooks(homeDir = homedir()) {
296
- const settingsPath = join(homeDir, '.claude', 'settings.json');
297
- const config = readJson(settingsPath, {});
298
- ensureHooksObject(config);
299
-
300
- let changed = false;
301
-
302
- for (const hookDef of CLAUDE_TELEMETRY_HOOKS) {
303
- const current = Array.isArray(config.hooks[hookDef.phase]) ? config.hooks[hookDef.phase] : [];
304
- const managed = current.filter(isManagedClaudeTelemetryEntry);
305
-
306
- // Only register hooks whose script files actually exist on disk.
307
- // On version downgrade the aw-ecc clone may not have them — registering
308
- // hooks for missing scripts causes MODULE_NOT_FOUND crashes.
309
- const scriptPath = resolveHookScriptPath(hookDef.command, homeDir);
310
- if (scriptPath && !existsSync(scriptPath)) {
311
- // Script missing — remove any stale telemetry entries for this phase
312
- if (managed.length > 0) {
313
- const cleaned = current.filter(e => !isManagedClaudeTelemetryEntry(e));
314
- if (cleaned.length > 0) {
315
- config.hooks[hookDef.phase] = cleaned;
316
- } else {
317
- delete config.hooks[hookDef.phase];
318
- }
319
- changed = true;
320
- }
321
- continue;
322
- }
257
+ // Refresh the scripts first: Codex wrappers and git hooks run them too,
258
+ // and this path runs after every aw link / pull / upgrade.
259
+ const copy = copyAwUsageScripts(homeDir);
260
+ const absent = missingAwUsageFiles(homeDir);
261
+ // On disk but not refreshed (read-only, unreadable): the old content stays.
262
+ const stuck = copy.missing.filter(rel => !absent.includes(rel));
263
+ if (absent.length > 0) warnIncompleteAwUsageInstall(absent);
264
+ // The version warning already names a stuck package.json, with both versions.
265
+ const versionWarned = absent.length === 0 && !ensureAwUsageVersion(homeDir);
266
+ const unreported = versionWarned ? stuck.filter(rel => rel !== 'package.json') : stuck;
267
+ if (unreported.length > 0) warnStuckAwUsageFiles(unreported);
323
268
 
324
- // If exactly one properly described entry exists, nothing to do
325
- if (managed.length === 1 && managed[0].description === CLAUDE_TELEMETRY_DESCRIPTION) continue;
326
-
327
- // Remove all legacy/duplicate telemetry entries, then add the canonical one
328
- const cleaned = current.filter(e => !isManagedClaudeTelemetryEntry(e));
329
- config.hooks[hookDef.phase] = [...cleaned, buildClaudeTelemetryEntry(hookDef)];
330
- changed = true;
331
- }
332
-
333
- if (!changed) return [];
269
+ const settingsPath = join(homeDir, '.claude', 'settings.json');
270
+ const config = readClaudeSettings(settingsPath);
271
+ if (config === null) return [];
334
272
 
335
- if (isEmptyObject(config.hooks)) {
336
- delete config.hooks;
337
- }
273
+ // Old and new scripts do not mix, so a stuck file blocks wiring like an absent one.
274
+ const installed = absent.length === 0 && stuck.length === 0;
275
+ const hooks = mergeClaudeAwUsageHooks(config.hooks, { installed });
276
+ if (JSON.stringify(hooks) === JSON.stringify(config.hooks)) return [];
338
277
 
339
- writeJson(settingsPath, config);
278
+ writeJson(settingsPath, { ...config, hooks });
340
279
  return [settingsPath];
341
280
  }
342
281
 
@@ -344,36 +283,17 @@ function disableClaudeTelemetryHooks(homeDir = homedir()) {
344
283
  const settingsPath = join(homeDir, '.claude', 'settings.json');
345
284
  if (!existsSync(settingsPath)) return [];
346
285
 
347
- const config = readJson(settingsPath, {});
348
- if (!isObject(config.hooks)) return [];
349
-
350
- let changed = false;
351
-
352
- for (const hookDef of CLAUDE_TELEMETRY_HOOKS) {
353
- const current = Array.isArray(config.hooks[hookDef.phase]) ? config.hooks[hookDef.phase] : null;
354
- if (!current) continue;
286
+ const config = readClaudeSettings(settingsPath);
287
+ if (config === null || !isObject(config.hooks)) return [];
355
288
 
356
- const filtered = current.filter(entry => !isManagedClaudeTelemetryEntry(entry));
357
- if (filtered.length !== current.length) {
358
- changed = true;
359
- if (filtered.length > 0) {
360
- config.hooks[hookDef.phase] = filtered;
361
- } else {
362
- delete config.hooks[hookDef.phase];
363
- }
364
- }
365
- }
366
-
367
- if (!changed) return [];
289
+ const hooks = removeClaudeAwUsageHooks(config.hooks);
290
+ if (JSON.stringify(hooks) === JSON.stringify(config.hooks)) return [];
368
291
 
369
- if (isEmptyObject(config.hooks)) {
370
- delete config.hooks;
371
- }
372
-
373
- if (!pruneEmptySettingsFile(settingsPath, config)) {
374
- writeJson(settingsPath, config);
292
+ const next = { ...config, hooks };
293
+ if (isEmptyObject(hooks)) delete next.hooks;
294
+ if (!pruneEmptySettingsFile(settingsPath, next)) {
295
+ writeJson(settingsPath, next);
375
296
  }
376
-
377
297
  return [settingsPath];
378
298
  }
379
299
 
@@ -410,7 +330,8 @@ function isManagedCodexStopEntry(entry) {
410
330
 
411
331
  function disableClaudeStartup(homeDir = homedir()) {
412
332
  const settingsPath = join(homeDir, '.claude', 'settings.json');
413
- const config = readJson(settingsPath, {});
333
+ const config = readClaudeSettings(settingsPath);
334
+ if (config === null) return [];
414
335
  ensureHooksObject(config);
415
336
 
416
337
  const before = JSON.stringify(config.hooks);
@@ -481,7 +402,8 @@ export function purgeManagedClaudeStartupHooks(homeDir = homedir()) {
481
402
 
482
403
  function enableClaudeStartup(homeDir = homedir()) {
483
404
  const settingsPath = join(homeDir, '.claude', 'settings.json');
484
- const config = readJson(settingsPath, {});
405
+ const config = readClaudeSettings(settingsPath);
406
+ if (config === null) return [];
485
407
  ensureHooksObject(config);
486
408
 
487
409
  const before = JSON.stringify(config.hooks);
@@ -551,40 +473,17 @@ function hasCodexHooksEnabled(homeDir = homedir()) {
551
473
  if (!existsSync(configPath)) return false;
552
474
 
553
475
  try {
554
- return /^\s*codex_hooks\s*=\s*true\b/m.test(readFileSync(configPath, 'utf8'));
476
+ return codexHooksFeatureEnabled(readFileSync(configPath, 'utf8'));
555
477
  } catch {
556
478
  return false;
557
479
  }
558
480
  }
559
481
 
560
- function ensureCodexHookEnabled(content) {
561
- if (/^\s*codex_hooks\s*=\s*true\b/m.test(content)) {
562
- return content;
563
- }
564
-
565
- if (/^\s*codex_hooks\s*=\s*false\b/m.test(content)) {
566
- return content.replace(/^\s*codex_hooks\s*=\s*false\b.*$/m, 'codex_hooks = true');
567
- }
568
-
569
- const featuresHeaderMatch = content.match(/^\[features\]\s*$/m);
570
- if (featuresHeaderMatch && featuresHeaderMatch.index !== undefined) {
571
- const insertAt = featuresHeaderMatch.index + featuresHeaderMatch[0].length;
572
- return `${content.slice(0, insertAt)}\ncodex_hooks = true${content.slice(insertAt)}`;
573
- }
574
-
575
- const trimmed = content.trimEnd();
576
- if (trimmed.length === 0) {
577
- return '[features]\ncodex_hooks = true\n';
578
- }
579
-
580
- return `${trimmed}\n\n[features]\ncodex_hooks = true\n`;
581
- }
582
-
583
482
  function enableCodexStartup(homeDir = homedir()) {
584
483
  const updatedFiles = [];
585
484
  const configPath = codexConfigPath(homeDir);
586
485
  const currentConfig = existsSync(configPath) ? readFileSync(configPath, 'utf8') : '';
587
- const nextConfig = ensureCodexHookEnabled(currentConfig);
486
+ const nextConfig = ensureCodexHooksFeature(currentConfig);
588
487
 
589
488
  if (!existsSync(configPath) || nextConfig !== currentConfig) {
590
489
  mkdirSync(dirname(configPath), { recursive: true });