@ghl-ai/aw 0.1.42-beta.4 → 0.1.42-beta.40
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/commands/init.mjs +65 -19
- package/commands/nuke.mjs +49 -60
- package/ecc.mjs +79 -23
- package/fmt.mjs +27 -17
- package/hook-cleanup.mjs +301 -0
- package/hooks/codex-home.mjs +4 -3
- package/hooks/shared-phase-scripts.mjs +47 -0
- package/hooks.mjs +45 -5
- package/integrate.mjs +2 -2
- package/mcp.mjs +3 -2
- package/package.json +2 -1
- package/render-rules.mjs +1 -0
- package/startup.mjs +54 -8
- package/telemetry.mjs +1 -1
package/commands/init.mjs
CHANGED
|
@@ -18,9 +18,10 @@ import { execSync } from 'node:child_process';
|
|
|
18
18
|
import { join, dirname, sep } from 'node:path';
|
|
19
19
|
import { homedir } from 'node:os';
|
|
20
20
|
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import * as p from '@clack/prompts';
|
|
21
22
|
import * as config from '../config.mjs';
|
|
22
23
|
import * as fmt from '../fmt.mjs';
|
|
23
|
-
import { chalk } from '../fmt.mjs';
|
|
24
|
+
import { chalk, setSilent } from '../fmt.mjs';
|
|
24
25
|
import { linkWorkspace } from '../link.mjs';
|
|
25
26
|
import { generateCommands, copyInstructions, initAwDocs, syncHomeHarnessInstructions } from '../integrate.mjs';
|
|
26
27
|
import { setupMcp } from '../mcp.mjs';
|
|
@@ -28,8 +29,10 @@ import { applyStoredStartupPreferences, ensureAwRuntimeHook } from '../startup.m
|
|
|
28
29
|
import { installLocalCommitHook } from '../hooks.mjs';
|
|
29
30
|
import { autoUpdate, promptUpdate } from '../update.mjs';
|
|
30
31
|
import { installGlobalHooks } from '../hooks.mjs';
|
|
31
|
-
import {
|
|
32
|
+
import { loadConfig as ensureTelemetryConfig } from '../telemetry.mjs';
|
|
33
|
+
import { installAwEcc, AW_ECC_TAG } from '../ecc.mjs';
|
|
32
34
|
import { removeWorkspaceHookDefaults } from '../codex.mjs';
|
|
35
|
+
import { readHookManifest, pruneStaleHooks, writeHookManifest } from '../hook-cleanup.mjs';
|
|
33
36
|
import {
|
|
34
37
|
initPersistentClone,
|
|
35
38
|
isValidClone,
|
|
@@ -177,11 +180,11 @@ function installIdeTasks() {
|
|
|
177
180
|
existing.tasks = existing.tasks || [];
|
|
178
181
|
existing.tasks.push(vscodeTask.tasks[0]);
|
|
179
182
|
writeFileSync(tasksPath, JSON.stringify(existing, null, 2) + '\n');
|
|
180
|
-
fmt.logStep(`
|
|
183
|
+
fmt.logStep(`Auto-sync task added to ${ide}`);
|
|
181
184
|
} catch { /* corrupted tasks.json, skip */ }
|
|
182
185
|
} else {
|
|
183
186
|
writeFileSync(tasksPath, JSON.stringify(vscodeTask, null, 2) + '\n');
|
|
184
|
-
fmt.logStep(`
|
|
187
|
+
fmt.logStep(`Auto-sync task added to ${ide}`);
|
|
185
188
|
}
|
|
186
189
|
}
|
|
187
190
|
}
|
|
@@ -193,6 +196,16 @@ export async function initCommand(args) {
|
|
|
193
196
|
let user = args['--user'] || '';
|
|
194
197
|
const silent = args['--silent'] === true;
|
|
195
198
|
|
|
199
|
+
// In silent mode, suppress ALL fmt output and show a single spinner.
|
|
200
|
+
// setSilent(true) makes every fmt.* call a no-op — internal functions
|
|
201
|
+
// (hooks.mjs, mcp.mjs, integrate.mjs, ecc.mjs) are silenced automatically.
|
|
202
|
+
let silentSpinner = null;
|
|
203
|
+
if (silent) {
|
|
204
|
+
setSilent(true);
|
|
205
|
+
silentSpinner = p.spinner();
|
|
206
|
+
silentSpinner.start('Initializing...');
|
|
207
|
+
}
|
|
208
|
+
|
|
196
209
|
fmt.intro(`aw init ${chalk.dim('v' + VERSION)}`);
|
|
197
210
|
|
|
198
211
|
// ── Validate ──────────────────────────────────────────────────────────
|
|
@@ -323,6 +336,7 @@ export async function initCommand(args) {
|
|
|
323
336
|
syncRulesTargets(cwd);
|
|
324
337
|
}
|
|
325
338
|
removeLegacyRegistryRules();
|
|
339
|
+
if (!silent) fmt.logStep('Rules synced');
|
|
326
340
|
|
|
327
341
|
// Ensure project worktree sparse checkout matches the global clone.
|
|
328
342
|
// Covers the case where a namespace was added from HOME (or another project)
|
|
@@ -332,13 +346,19 @@ export async function initCommand(args) {
|
|
|
332
346
|
try { syncWorktreeSparseCheckout(AW_HOME, localAw); } catch { /* best effort */ }
|
|
333
347
|
}
|
|
334
348
|
|
|
349
|
+
// Prune stale hooks from prior version before installing new ones
|
|
350
|
+
const oldManifest = readHookManifest();
|
|
351
|
+
if (oldManifest) pruneStaleHooks(oldManifest);
|
|
352
|
+
|
|
335
353
|
await installAwEcc(cwd, { silent });
|
|
354
|
+
|
|
336
355
|
ensureAwRuntimeHook(HOME);
|
|
337
356
|
syncHomeAndProjectInstructions(cwd, freshCfg?.namespace || team);
|
|
338
357
|
await setupMcp(HOME, freshCfg?.namespace || team, { silent });
|
|
339
358
|
applyStoredStartupPreferences(HOME);
|
|
340
359
|
const removedLegacyStartupFiles = cwd !== HOME ? removeWorkspaceHookDefaults(cwd) : [];
|
|
341
360
|
installGlobalHooks();
|
|
361
|
+
if (!silent) fmt.logStep('Hooks and IDE integration configured');
|
|
342
362
|
|
|
343
363
|
// Remove old local .git/hooks/post-checkout that pre-dates core.hooksPath (creates stale .aw_registry symlink)
|
|
344
364
|
if (cwd !== HOME) {
|
|
@@ -365,13 +385,19 @@ export async function initCommand(args) {
|
|
|
365
385
|
|
|
366
386
|
// Wire ~/.claude/.cursor/.codex to the project's registry when in a project,
|
|
367
387
|
// so edits to project/.aw/.aw_registry/ are instantly visible in global IDE dirs.
|
|
388
|
+
if (!silent) fmt.logStep('Wiring IDE symlinks...');
|
|
368
389
|
const projectRegistryDir = cwd !== HOME ? join(cwd, '.aw', REGISTRY_DIR) : null;
|
|
369
390
|
const awDirForLinks = (projectRegistryDir && existsSync(projectRegistryDir)) ? projectRegistryDir : null;
|
|
370
391
|
const symlinks = linkWorkspace(HOME, awDirForLinks, { silent: true });
|
|
371
392
|
const commands = generateCommands(HOME, { silent: true });
|
|
372
393
|
if (cwd !== HOME) installLocalCommitHook(cwd);
|
|
394
|
+
if (!silent) fmt.logStep(`IDE wired — ${chalk.bold(symlinks)} symlinks · ${chalk.bold(commands)} commands`);
|
|
395
|
+
|
|
396
|
+
// Write hook manifest after all hook installation is complete
|
|
397
|
+
try { writeHookManifest({ eccVersion: AW_ECC_TAG, awVersion: VERSION }); } catch { /* best effort */ }
|
|
373
398
|
|
|
374
399
|
if (silent) {
|
|
400
|
+
if (silentSpinner) { silentSpinner.stop('Done'); setSilent(false); }
|
|
375
401
|
autoUpdate(await args._updateCheck);
|
|
376
402
|
} else {
|
|
377
403
|
fmt.outro([
|
|
@@ -417,7 +443,6 @@ export async function initCommand(args) {
|
|
|
417
443
|
}
|
|
418
444
|
|
|
419
445
|
fmt.note([
|
|
420
|
-
`${chalk.dim('source:')} ~/.aw/ → ~/.aw_registry/ (symlink)`,
|
|
421
446
|
folderName ? `${chalk.dim('namespace:')} ${folderName}` : `${chalk.dim('namespace:')} ${chalk.dim('none')}`,
|
|
422
447
|
user ? `${chalk.dim('user:')} ${user}` : null,
|
|
423
448
|
`${chalk.dim('version:')} v${VERSION}`,
|
|
@@ -442,7 +467,7 @@ export async function initCommand(args) {
|
|
|
442
467
|
if (!awRegistryLstat) {
|
|
443
468
|
try {
|
|
444
469
|
symlinkSync(join(AW_HOME, REGISTRY_DIR), GLOBAL_AW_DIR);
|
|
445
|
-
fmt.logStep('
|
|
470
|
+
fmt.logStep('Registry linked');
|
|
446
471
|
} catch (e) {
|
|
447
472
|
fmt.logWarn(`Could not create symlink ~/.aw_registry/: ${e.message}`);
|
|
448
473
|
}
|
|
@@ -451,7 +476,7 @@ export async function initCommand(args) {
|
|
|
451
476
|
try {
|
|
452
477
|
rmSync(GLOBAL_AW_DIR);
|
|
453
478
|
symlinkSync(join(AW_HOME, REGISTRY_DIR), GLOBAL_AW_DIR);
|
|
454
|
-
fmt.logStep('
|
|
479
|
+
fmt.logStep('Registry linked');
|
|
455
480
|
} catch (e) {
|
|
456
481
|
fmt.logWarn(`Could not update symlink ~/.aw_registry/: ${e.message}`);
|
|
457
482
|
}
|
|
@@ -462,21 +487,32 @@ export async function initCommand(args) {
|
|
|
462
487
|
if (folderName) {
|
|
463
488
|
config.addPattern(GLOBAL_AW_DIR, folderName);
|
|
464
489
|
}
|
|
490
|
+
// Parallel batch A: rules sync (HOME + cwd are independent targets)
|
|
465
491
|
syncRulesTargets(HOME);
|
|
466
|
-
if (cwd !== HOME)
|
|
467
|
-
syncRulesTargets(cwd);
|
|
468
|
-
}
|
|
492
|
+
if (cwd !== HOME) syncRulesTargets(cwd);
|
|
469
493
|
removeLegacyRegistryRules();
|
|
494
|
+
if (!silent) fmt.logStep('Rules synced');
|
|
470
495
|
|
|
471
496
|
// Step 3: Setup tasks, MCP, hooks
|
|
497
|
+
// Prune stale hooks from prior version before installing new ones
|
|
498
|
+
const oldManifestFresh = readHookManifest();
|
|
499
|
+
if (oldManifestFresh) pruneStaleHooks(oldManifestFresh);
|
|
500
|
+
|
|
472
501
|
await installAwEcc(cwd, { silent });
|
|
502
|
+
|
|
473
503
|
ensureAwRuntimeHook(HOME);
|
|
474
|
-
|
|
475
|
-
|
|
504
|
+
|
|
505
|
+
// Parallel batch B: post-ECC setup (instructions and MCP are independent)
|
|
506
|
+
const [, mcpFiles] = await Promise.all([
|
|
507
|
+
Promise.resolve(syncHomeAndProjectInstructions(cwd, team)),
|
|
508
|
+
setupMcp(HOME, team, { silent }),
|
|
509
|
+
]);
|
|
510
|
+
// applyStoredStartupPreferences reads settings written by ECC — keep after batch B
|
|
476
511
|
applyStoredStartupPreferences(HOME);
|
|
477
512
|
const removedLegacyStartupFiles = cwd !== HOME ? removeWorkspaceHookDefaults(cwd) : [];
|
|
478
513
|
const hooksInstalled = installGlobalHooks();
|
|
479
514
|
installIdeTasks();
|
|
515
|
+
if (!silent) fmt.logStep('Hooks and IDE integration configured');
|
|
480
516
|
|
|
481
517
|
// Remove old local .git/hooks/post-checkout that pre-dates core.hooksPath
|
|
482
518
|
if (cwd !== HOME) {
|
|
@@ -502,18 +538,28 @@ export async function initCommand(args) {
|
|
|
502
538
|
|
|
503
539
|
// Step 5: Wire ~/.claude/.cursor/.codex to the project's registry when in a project,
|
|
504
540
|
// so edits to project/.aw/.aw_registry/ are instantly visible in global IDE dirs.
|
|
505
|
-
|
|
506
|
-
ideSpinner.start('Wiring IDE symlinks...');
|
|
541
|
+
if (!silent) fmt.logStep('Wiring IDE symlinks...');
|
|
507
542
|
const projectRegistryDir = cwd !== HOME ? join(cwd, '.aw', REGISTRY_DIR) : null;
|
|
508
543
|
const awDirForLinks = (projectRegistryDir && existsSync(projectRegistryDir)) ? projectRegistryDir : null;
|
|
509
|
-
|
|
544
|
+
// Parallel batch C: symlinks + commands are independent
|
|
510
545
|
if (cwd !== HOME) installLocalCommitHook(cwd);
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
546
|
+
const [symlinks, commands] = [
|
|
547
|
+
linkWorkspace(HOME, awDirForLinks, { silent: true }),
|
|
548
|
+
generateCommands(HOME, { silent: true }),
|
|
549
|
+
];
|
|
550
|
+
if (!silent) fmt.logStep(`IDE wired — ${chalk.bold(symlinks)} symlinks · ${chalk.bold(commands)} commands`);
|
|
551
|
+
|
|
552
|
+
// Write hook manifest after all hook installation is complete
|
|
553
|
+
try { writeHookManifest({ eccVersion: AW_ECC_TAG, awVersion: VERSION }); } catch { /* best effort */ }
|
|
554
|
+
|
|
555
|
+
// Ensure telemetry config exists (generates machine_id on first run)
|
|
556
|
+
ensureTelemetryConfig();
|
|
514
557
|
|
|
515
558
|
// Offer to update if a newer version is available
|
|
516
|
-
await promptUpdate(await args._updateCheck);
|
|
559
|
+
if (!silent) await promptUpdate(await args._updateCheck);
|
|
560
|
+
|
|
561
|
+
// Stop silent spinner before outro (outro is already suppressed by setSilent)
|
|
562
|
+
if (silentSpinner) { silentSpinner.stop('Done'); setSilent(false); }
|
|
517
563
|
|
|
518
564
|
fmt.outro([
|
|
519
565
|
'⟁ Install complete',
|
package/commands/nuke.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import { uninstallAwEcc } from '../ecc.mjs';
|
|
|
16
16
|
import { removeMcpConfig } from '../mcp.mjs';
|
|
17
17
|
import { listProjectWorktrees } from '../git.mjs';
|
|
18
18
|
import { removeWorkspaceHookDefaults } from '../codex.mjs';
|
|
19
|
+
import { readHookManifest, pruneStaleHooks, removeHookManifest } from '../hook-cleanup.mjs';
|
|
19
20
|
|
|
20
21
|
const HOME = homedir();
|
|
21
22
|
const GLOBAL_AW_DIR = join(HOME, '.aw_registry');
|
|
@@ -129,43 +130,34 @@ function removeIdeSymlinks() {
|
|
|
129
130
|
return removed;
|
|
130
131
|
}
|
|
131
132
|
|
|
132
|
-
// 3. Find and remove ALL .aw_registry symlinks from project directories
|
|
133
|
+
// 3. Find and remove ALL .aw_registry symlinks + legacy hooks from project directories.
|
|
134
|
+
// Uses a single batched find instead of 3 sequential ones for performance.
|
|
133
135
|
async function removeProjectSymlinks() {
|
|
134
136
|
let removed = 0;
|
|
137
|
+
let hooksRemoved = 0;
|
|
135
138
|
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
`find "${HOME}" -maxdepth 4 -name ".aw_registry" -type l 2>/dev/null || true`,
|
|
139
|
+
// Single find: .aw_registry symlinks + legacy git hooks
|
|
140
|
+
const { stdout } = await exec(
|
|
141
|
+
`find "${HOME}" -maxdepth 5 \\( -name ".aw_registry" -type l -o -path "*/.git/hooks/post-checkout" -type f -o -path "*/.git/hooks/prepare-commit-msg" -type f \\) 2>/dev/null || true`,
|
|
140
142
|
{ encoding: 'utf8', timeout: 30000 }
|
|
141
143
|
);
|
|
142
|
-
for (const linkPath of registryLinks.trim().split('\n').filter(Boolean)) {
|
|
143
|
-
try {
|
|
144
|
-
removeWorkspaceHookDefaults(dirname(linkPath));
|
|
145
|
-
unlinkSync(linkPath);
|
|
146
|
-
removed++;
|
|
147
|
-
} catch { /* best effort */ }
|
|
148
|
-
}
|
|
149
144
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
try {
|
|
161
|
-
const content = readFileSync(hookPath, 'utf8');
|
|
162
|
-
// Only remove hooks that AW installed — identified by our marker comment
|
|
145
|
+
for (const itemPath of stdout.trim().split('\n').filter(Boolean)) {
|
|
146
|
+
try {
|
|
147
|
+
if (itemPath.endsWith('.aw_registry')) {
|
|
148
|
+
// Registry symlink — clean workspace hooks then remove
|
|
149
|
+
removeWorkspaceHookDefaults(dirname(itemPath));
|
|
150
|
+
unlinkSync(itemPath);
|
|
151
|
+
removed++;
|
|
152
|
+
} else if (itemPath.includes('.git/hooks/')) {
|
|
153
|
+
// Legacy git hook — only remove if AW-installed
|
|
154
|
+
const content = readFileSync(itemPath, 'utf8');
|
|
163
155
|
if (content.includes('aw:') || content.includes('aw: auto-link registry') || content.includes('ln -s "$AW_REGISTRY"')) {
|
|
164
|
-
unlinkSync(
|
|
156
|
+
unlinkSync(itemPath);
|
|
165
157
|
hooksRemoved++;
|
|
166
158
|
}
|
|
167
|
-
}
|
|
168
|
-
}
|
|
159
|
+
}
|
|
160
|
+
} catch { /* best effort */ }
|
|
169
161
|
}
|
|
170
162
|
|
|
171
163
|
return { removed, hooksRemoved };
|
|
@@ -232,6 +224,9 @@ export async function nukeCommand(args) {
|
|
|
232
224
|
|
|
233
225
|
const manifest = loadManifest();
|
|
234
226
|
|
|
227
|
+
// Read hook manifest into memory before any deletion (manifest file lives in ~/.aw/hooks/)
|
|
228
|
+
const hookManifest = readHookManifest();
|
|
229
|
+
|
|
235
230
|
// Use lstatSync (not existsSync) so a dangling symlink still shows as "found"
|
|
236
231
|
let registryFound = false;
|
|
237
232
|
try { lstatSync(GLOBAL_AW_DIR); registryFound = true; } catch { /* not present */ }
|
|
@@ -250,6 +245,9 @@ export async function nukeCommand(args) {
|
|
|
250
245
|
const ideCount = removeIdeSymlinks();
|
|
251
246
|
uninstallAwEcc();
|
|
252
247
|
removeMcpConfig();
|
|
248
|
+
// Prune stale hook entries using manifest (catches entries not tracked by install-state)
|
|
249
|
+
if (hookManifest) pruneStaleHooks(hookManifest);
|
|
250
|
+
removeHookManifest();
|
|
253
251
|
const totalIde = createdCount + ideCount;
|
|
254
252
|
ideSpinner.stop(`IDE symlinks and commands removed${totalIde > 0 ? ` (${totalIde} files)` : ''}`);
|
|
255
253
|
|
|
@@ -294,45 +292,36 @@ export async function nukeCommand(args) {
|
|
|
294
292
|
} catch { /* best effort */ }
|
|
295
293
|
}
|
|
296
294
|
|
|
297
|
-
// 4b.
|
|
298
|
-
// These are created by the symlink model and are NOT tracked by git worktree list.
|
|
295
|
+
// 4b+4c. Batched find for .aw symlinks and directories (single traversal instead of two).
|
|
299
296
|
try {
|
|
300
|
-
const { stdout:
|
|
301
|
-
`find "${HOME}" -maxdepth 5 -name ".aw" -type l 2>/dev/null || true`,
|
|
297
|
+
const { stdout: awItems } = await exec(
|
|
298
|
+
`find "${HOME}" -maxdepth 5 -name ".aw" \\( -type l -o -type d \\) 2>/dev/null || true`,
|
|
302
299
|
{ encoding: 'utf8', timeout: 30000 }
|
|
303
300
|
);
|
|
304
|
-
for (const
|
|
301
|
+
for (const itemPath of awItems.trim().split('\n').filter(Boolean)) {
|
|
302
|
+
if (itemPath === AW_HOME) continue; // skip ~/.aw itself
|
|
305
303
|
try {
|
|
306
|
-
const
|
|
307
|
-
if (
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
}
|
|
313
|
-
} catch { /* best effort */ }
|
|
314
|
-
|
|
315
|
-
// 4c. Remove old-style .aw directories (git worktrees) that git worktree list missed.
|
|
316
|
-
// After ~/.aw is deleted these become dangling — find and clean them up.
|
|
317
|
-
try {
|
|
318
|
-
const { stdout: awDirs } = await exec(
|
|
319
|
-
`find "${HOME}" -maxdepth 5 -name ".aw" -type d 2>/dev/null || true`,
|
|
320
|
-
{ encoding: 'utf8', timeout: 30000 }
|
|
321
|
-
);
|
|
322
|
-
for (const dirPath of awDirs.trim().split('\n').filter(Boolean)) {
|
|
323
|
-
if (dirPath === AW_HOME) continue; // skip ~/.aw itself
|
|
324
|
-
try {
|
|
325
|
-
const gitFile = join(dirPath, '.git');
|
|
326
|
-
const stat = lstatSync(gitFile);
|
|
327
|
-
if (stat.isFile()) {
|
|
328
|
-
const content = readFileSync(gitFile, 'utf8').trim();
|
|
329
|
-
// Old git worktree pointing into ~/.aw — safe to remove
|
|
330
|
-
if (content.includes(`${AW_HOME}/.git/`)) {
|
|
331
|
-
rmSync(dirPath, { recursive: true, force: true });
|
|
304
|
+
const stat = lstatSync(itemPath);
|
|
305
|
+
if (stat.isSymbolicLink()) {
|
|
306
|
+
// New-style symlink pointing to ~/.aw
|
|
307
|
+
const target = readlinkSync(itemPath);
|
|
308
|
+
if (target === AW_HOME || target.endsWith('/.aw')) {
|
|
309
|
+
unlinkSync(itemPath);
|
|
332
310
|
wtRemoved++;
|
|
333
311
|
}
|
|
312
|
+
} else if (stat.isDirectory()) {
|
|
313
|
+
// Old-style git worktree — check .git file
|
|
314
|
+
const gitFile = join(itemPath, '.git');
|
|
315
|
+
const gitStat = lstatSync(gitFile);
|
|
316
|
+
if (gitStat.isFile()) {
|
|
317
|
+
const content = readFileSync(gitFile, 'utf8').trim();
|
|
318
|
+
if (content.includes(`${AW_HOME}/.git/`)) {
|
|
319
|
+
rmSync(itemPath, { recursive: true, force: true });
|
|
320
|
+
wtRemoved++;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
334
323
|
}
|
|
335
|
-
} catch { /* not a worktree or already gone */ }
|
|
324
|
+
} catch { /* not a worktree/symlink or already gone */ }
|
|
336
325
|
}
|
|
337
326
|
} catch { /* best effort */ }
|
|
338
327
|
|
package/ecc.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { execSync } from "node:child_process";
|
|
1
|
+
import { execSync, exec as execCb } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
const execAsync = promisify(execCb);
|
|
2
4
|
import {
|
|
3
5
|
existsSync, readFileSync, readdirSync,
|
|
4
6
|
mkdirSync, rmSync, writeFileSync, renameSync,
|
|
@@ -10,7 +12,7 @@ import { applyStoredStartupPreferences } from "./startup.mjs";
|
|
|
10
12
|
|
|
11
13
|
const AW_ECC_REPO_SSH = "git@github.com:shreyansh-ghl/aw-ecc.git";
|
|
12
14
|
const AW_ECC_REPO_HTTPS = "https://github.com/shreyansh-ghl/aw-ecc.git";
|
|
13
|
-
export const AW_ECC_TAG = "v1.4.
|
|
15
|
+
export const AW_ECC_TAG = "v1.4.41-beta.5";
|
|
14
16
|
|
|
15
17
|
const MARKETPLACE_NAME = "aw-marketplace";
|
|
16
18
|
const PLUGIN_KEY = `aw@${MARKETPLACE_NAME}`;
|
|
@@ -63,6 +65,68 @@ function cloneWithRef(url, ref, dest) {
|
|
|
63
65
|
}
|
|
64
66
|
}
|
|
65
67
|
|
|
68
|
+
// Async variants — allow event loop to tick so spinners can render.
|
|
69
|
+
async function runA(cmd, opts = {}) {
|
|
70
|
+
const { stdout } = await execAsync(cmd, { ...opts, maxBuffer: 10 * 1024 * 1024 });
|
|
71
|
+
return stdout;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function fetchOverrideRefAsync(dest, ref) {
|
|
75
|
+
await runA(`git -C ${dest} fetch --quiet --depth 1 origin ${ref}`);
|
|
76
|
+
await runA(`git -C ${dest} checkout --quiet FETCH_HEAD`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function cloneWithRefAsync(url, ref, dest) {
|
|
80
|
+
try {
|
|
81
|
+
await runA(`git clone --quiet --depth 1 --branch ${ref} "${url}" "${dest}"`);
|
|
82
|
+
} catch {
|
|
83
|
+
await runA(`git clone --quiet --depth 1 "${url}" "${dest}"`);
|
|
84
|
+
await fetchOverrideRefAsync(dest, ref);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function cloneOrUpdateAsync(tag, dest) {
|
|
89
|
+
const overrideUrl = process.env.AW_ECC_CLONE_URL;
|
|
90
|
+
const overrideRef = process.env.AW_ECC_CLONE_REF?.trim();
|
|
91
|
+
|
|
92
|
+
if (existsSync(join(dest, ".git"))) {
|
|
93
|
+
try {
|
|
94
|
+
if (!overrideUrl && !overrideRef) {
|
|
95
|
+
await runA(`git -C ${dest} fetch --quiet --depth 1 origin tag ${tag}`);
|
|
96
|
+
await runA(`git -C ${dest} checkout --quiet ${tag}`);
|
|
97
|
+
} else {
|
|
98
|
+
if (overrideUrl) {
|
|
99
|
+
await runA(`git -C ${dest} remote set-url origin "${overrideUrl}"`);
|
|
100
|
+
}
|
|
101
|
+
await fetchOverrideRefAsync(dest, overrideRef || tag);
|
|
102
|
+
}
|
|
103
|
+
return;
|
|
104
|
+
} catch { /* fall through to fresh clone */ }
|
|
105
|
+
}
|
|
106
|
+
if (existsSync(dest)) rmSync(dest, { recursive: true, force: true });
|
|
107
|
+
if (overrideUrl) {
|
|
108
|
+
if (overrideRef) {
|
|
109
|
+
await cloneWithRefAsync(overrideUrl, overrideRef, dest);
|
|
110
|
+
} else {
|
|
111
|
+
await runA(`git clone --quiet --depth 1 "${overrideUrl}" "${dest}"`);
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
if (overrideRef) {
|
|
115
|
+
try {
|
|
116
|
+
await cloneWithRefAsync(AW_ECC_REPO_SSH, overrideRef, dest);
|
|
117
|
+
} catch {
|
|
118
|
+
await cloneWithRefAsync(AW_ECC_REPO_HTTPS, overrideRef, dest);
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
await runA(`git clone --quiet --depth 1 --branch ${tag} ${AW_ECC_REPO_SSH} ${dest}`);
|
|
124
|
+
} catch {
|
|
125
|
+
await runA(`git clone --quiet --depth 1 --branch ${tag} ${AW_ECC_REPO_HTTPS} ${dest}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
66
130
|
function readIfExists(path) {
|
|
67
131
|
try { return existsSync(path) ? readFileSync(path, "utf8") : null; } catch { return null; }
|
|
68
132
|
}
|
|
@@ -259,13 +323,16 @@ export async function installAwEcc(
|
|
|
259
323
|
{ targets = ["cursor", "claude", "codex"], silent = false } = {},
|
|
260
324
|
) {
|
|
261
325
|
if (process.env.AW_NO_ECC === '1') return;
|
|
262
|
-
if (!silent) fmt.logStep("Installing aw-ecc engine...");
|
|
263
326
|
|
|
264
327
|
const repoDir = eccDir();
|
|
265
328
|
const home = homedir();
|
|
266
329
|
|
|
330
|
+
const eccSpinner = silent ? null : fmt.spinner();
|
|
331
|
+
|
|
267
332
|
try {
|
|
268
|
-
|
|
333
|
+
if (eccSpinner) eccSpinner.start('Cloning aw-ecc engine...');
|
|
334
|
+
await cloneOrUpdateAsync(AW_ECC_TAG, repoDir);
|
|
335
|
+
if (eccSpinner) eccSpinner.message('Installing aw-ecc dependencies...');
|
|
269
336
|
|
|
270
337
|
// Claude Code: plugin install via marketplace CLI (proper agent dispatch)
|
|
271
338
|
if (targets.includes("claude")) {
|
|
@@ -279,7 +346,7 @@ export async function installAwEcc(
|
|
|
279
346
|
// Claude + Cursor + Codex: file-copy via install-apply.js
|
|
280
347
|
const fileCopyTargets = targets.filter((t) => FILE_COPY_TARGETS.includes(t));
|
|
281
348
|
if (fileCopyTargets.length > 0) {
|
|
282
|
-
|
|
349
|
+
await runA("npm install --no-audit --no-fund --ignore-scripts --loglevel=error", {
|
|
283
350
|
cwd: repoDir,
|
|
284
351
|
});
|
|
285
352
|
// generate-aw-hooks.js produces hooks.json and hook script sources that
|
|
@@ -291,15 +358,14 @@ export async function installAwEcc(
|
|
|
291
358
|
run(`node "${generateHooksScript}"`, { cwd: repoDir });
|
|
292
359
|
} catch { /* best effort — older engine versions may not have this script */ }
|
|
293
360
|
}
|
|
294
|
-
|
|
361
|
+
if (eccSpinner) eccSpinner.message('Applying aw-ecc to IDE targets...');
|
|
362
|
+
// Each target writes to disjoint paths (~/.claude/, ~/.cursor/, ~/.codex/) — safe to parallelize.
|
|
363
|
+
await Promise.all(fileCopyTargets.map(async (target) => {
|
|
295
364
|
try {
|
|
296
365
|
const snapshot = snapshotProtectedConfigs(home, target);
|
|
297
366
|
|
|
298
367
|
// Always use HOME as cwd so files land in ~/.<target>/ globally.
|
|
299
368
|
const runCwd = homedir();
|
|
300
|
-
// For claude: install the safe no-commands module set. The plugin
|
|
301
|
-
// already owns /aw:* command registration, and broader profiles with
|
|
302
|
-
// `--without baseline:commands` can fail on commands-core deps.
|
|
303
369
|
const installArgs = target === "claude"
|
|
304
370
|
? `--target ${target} --modules ${CLAUDE_FILE_COPY_MODULES.join(",")}`
|
|
305
371
|
: `--target ${target} --profile full`;
|
|
@@ -307,34 +373,24 @@ export async function installAwEcc(
|
|
|
307
373
|
`node ${join(repoDir, "scripts/install-apply.js")} ${installArgs}`,
|
|
308
374
|
{ cwd: runCwd },
|
|
309
375
|
);
|
|
310
|
-
// Move cursor commands into aw/ subfolder for namespace consistency
|
|
311
|
-
// so they're accessible as /aw:tdd, /aw:plan — same as Claude Code plugin.
|
|
312
376
|
if (target === "cursor") {
|
|
313
377
|
namespaceCursorCommands(runCwd);
|
|
314
|
-
// Cursor commands use hyphens (commands/aw/plan.md -> /aw-plan)
|
|
315
|
-
// but source skill/rule files use canonical colons (/aw:plan).
|
|
316
|
-
// Transform /aw: -> /aw- in installed cursor skill/rule files.
|
|
317
378
|
transformCursorAwRefs(home);
|
|
318
379
|
}
|
|
319
|
-
// Run sync script for codex: generates ~/.codex/prompts/*.md and
|
|
320
|
-
// merges AGENTS.md — Codex has no slash commands, so prompts are the
|
|
321
|
-
// equivalent of commands.
|
|
322
380
|
if (target === "codex") {
|
|
323
381
|
syncEccToCodex(repoDir);
|
|
324
382
|
}
|
|
325
|
-
|
|
326
|
-
// Critical: preserve user-owned config files if they existed before
|
|
327
|
-
// running aw-ecc (aw should only add, never replace user settings).
|
|
328
383
|
restoreProtectedConfigs(snapshot);
|
|
329
384
|
} catch { /* target not supported — skip */ }
|
|
330
|
-
}
|
|
385
|
+
}));
|
|
331
386
|
}
|
|
332
387
|
|
|
333
388
|
applyStoredStartupPreferences();
|
|
334
389
|
|
|
335
|
-
if (
|
|
390
|
+
if (eccSpinner) eccSpinner.stop('aw-ecc engine installed');
|
|
336
391
|
} catch (err) {
|
|
337
|
-
if (
|
|
392
|
+
if (eccSpinner) eccSpinner.stop(chalk.yellow(`aw-ecc install failed: ${err.message}`));
|
|
393
|
+
else if (!silent) fmt.logWarn(`aw-ecc install failed: ${err.message}`);
|
|
338
394
|
}
|
|
339
395
|
}
|
|
340
396
|
|
package/fmt.mjs
CHANGED
|
@@ -6,6 +6,13 @@ import figlet from 'figlet';
|
|
|
6
6
|
|
|
7
7
|
export { chalk };
|
|
8
8
|
|
|
9
|
+
// ─── Silent mode ───
|
|
10
|
+
// When enabled, all log/intro/outro/note/spinner output is suppressed.
|
|
11
|
+
// Used by `aw init --silent` to show only a single "Initializing..." spinner.
|
|
12
|
+
let _silent = false;
|
|
13
|
+
export function setSilent(v) { _silent = !!v; }
|
|
14
|
+
export function isSilent() { return _silent; }
|
|
15
|
+
|
|
9
16
|
// ─── Banner ───
|
|
10
17
|
|
|
11
18
|
// Big ASCII art icons — same height as ANSI Shadow font (6 lines)
|
|
@@ -22,6 +29,7 @@ const ICON_ART = {
|
|
|
22
29
|
};
|
|
23
30
|
|
|
24
31
|
export function banner(text, opts = {}) {
|
|
32
|
+
if (_silent) return;
|
|
25
33
|
const {
|
|
26
34
|
font = 'ANSI Shadow',
|
|
27
35
|
color = chalk.hex('#FF6B35'),
|
|
@@ -65,12 +73,14 @@ export function banner(text, opts = {}) {
|
|
|
65
73
|
|
|
66
74
|
// ─── Clack wrappers ───
|
|
67
75
|
|
|
68
|
-
export const intro = (msg) => p.intro(chalk.bgHex('#FF6B35').black(` ⟁ ${msg} `));
|
|
69
|
-
export const outro = (msg) => p.outro(chalk.green(msg));
|
|
76
|
+
export const intro = (msg) => { if (!_silent) p.intro(chalk.bgHex('#FF6B35').black(` ⟁ ${msg} `)); };
|
|
77
|
+
export const outro = (msg) => { if (!_silent) p.outro(chalk.green(msg)); };
|
|
70
78
|
export const select = p.select;
|
|
71
79
|
export const isCancel = p.isCancel;
|
|
72
80
|
|
|
73
|
-
|
|
81
|
+
// Returns a real spinner when not silent, or a no-op stub when silent.
|
|
82
|
+
const _noopSpinner = { start() {}, stop() {}, message() {} };
|
|
83
|
+
export const spinner = () => _silent ? _noopSpinner : p.spinner();
|
|
74
84
|
|
|
75
85
|
export class CancelError extends Error {
|
|
76
86
|
constructor(message, { exitCode = 1 } = {}) {
|
|
@@ -93,13 +103,13 @@ export function cancelAndExit(msg) {
|
|
|
93
103
|
|
|
94
104
|
// ─── Log helpers (clack-styled) ───
|
|
95
105
|
|
|
96
|
-
export const note = (msg, title) => p.note(msg, title);
|
|
97
|
-
export const logInfo = (msg) => p.log.info(msg);
|
|
98
|
-
export const logSuccess = (msg) => p.log.success(msg);
|
|
99
|
-
export const logWarn = (msg) => p.log.warn(msg);
|
|
100
|
-
export const logError = (msg) => p.log.error(msg);
|
|
101
|
-
export const logStep = (msg) => p.log.step(msg);
|
|
102
|
-
export const logMessage = (msg) => p.log.message(msg);
|
|
106
|
+
export const note = (msg, title) => { if (!_silent) p.note(msg, title); };
|
|
107
|
+
export const logInfo = (msg) => { if (!_silent) p.log.info(msg); };
|
|
108
|
+
export const logSuccess = (msg) => { if (!_silent) p.log.success(msg); };
|
|
109
|
+
export const logWarn = (msg) => { if (!_silent) p.log.warn(msg); };
|
|
110
|
+
export const logError = (msg) => { if (!_silent) p.log.error(msg); };
|
|
111
|
+
export const logStep = (msg) => { if (!_silent) p.log.step(msg); };
|
|
112
|
+
export const logMessage = (msg) => { if (!_silent) p.log.message(msg); };
|
|
103
113
|
|
|
104
114
|
// ─── Styled text helpers ───
|
|
105
115
|
|
|
@@ -114,13 +124,13 @@ export const magenta = (s) => chalk.magenta(s);
|
|
|
114
124
|
|
|
115
125
|
// ─── Backward compat aliases (used by existing commands) ───
|
|
116
126
|
|
|
117
|
-
export function log(msg = '') { process.stderr.write(msg + '\n'); }
|
|
118
|
-
export function info(msg) { p.log.info(msg); }
|
|
119
|
-
export function success(msg) { p.log.success(msg); }
|
|
120
|
-
export function warn(msg) { p.log.warn(msg); }
|
|
121
|
-
export function error(msg) { p.log.error(msg); }
|
|
122
|
-
export function heading(msg) { p.log.step(chalk.bold(msg)); }
|
|
123
|
-
export function item(label, value) { p.log.message(`${chalk.dim(label)} ${value}`); }
|
|
127
|
+
export function log(msg = '') { if (!_silent) process.stderr.write(msg + '\n'); }
|
|
128
|
+
export function info(msg) { if (!_silent) p.log.info(msg); }
|
|
129
|
+
export function success(msg) { if (!_silent) p.log.success(msg); }
|
|
130
|
+
export function warn(msg) { if (!_silent) p.log.warn(msg); }
|
|
131
|
+
export function error(msg) { if (!_silent) p.log.error(msg); }
|
|
132
|
+
export function heading(msg) { if (!_silent) p.log.step(chalk.bold(msg)); }
|
|
133
|
+
export function item(label, value) { if (!_silent) p.log.message(`${chalk.dim(label)} ${value}`); }
|
|
124
134
|
|
|
125
135
|
// ─── Action labels ───
|
|
126
136
|
|
package/hook-cleanup.mjs
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// hook-cleanup.mjs — Hook cleanup manifest for safe version transitions.
|
|
2
|
+
//
|
|
3
|
+
// writeHookManifest() → snapshots all AW hook touchpoints to ~/.aw/hooks/manifest.json
|
|
4
|
+
// readHookManifest() → reads and validates the manifest
|
|
5
|
+
// pruneStaleHooks() → removes hook entries and runtime deps listed in a manifest
|
|
6
|
+
// removeHookManifest() → deletes the manifest file
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
existsSync, mkdirSync, readFileSync, readdirSync,
|
|
10
|
+
rmSync, writeFileSync,
|
|
11
|
+
} from 'node:fs';
|
|
12
|
+
import { dirname, join } from 'node:path';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
|
|
15
|
+
const SCHEMA_VERSION = 'aw-hooks.v1';
|
|
16
|
+
const HOME = homedir();
|
|
17
|
+
const MANIFEST_PATH = join(HOME, '.aw', 'hooks', 'manifest.json');
|
|
18
|
+
|
|
19
|
+
// ── Patterns matching AW-managed hook entries ────────────────────────────
|
|
20
|
+
|
|
21
|
+
function isManagedClaudeEntry(entry) {
|
|
22
|
+
if (entry?.description === 'AW usage telemetry') return true;
|
|
23
|
+
const cmds = Array.isArray(entry?.hooks) ? entry.hooks.map(h => h?.command || '') : [];
|
|
24
|
+
return cmds.some(c => c.includes('aw-usage-'));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isManagedCursorEntry(entry) {
|
|
28
|
+
const cmd = String(entry?.command || '');
|
|
29
|
+
return cmd.includes('.cursor/hooks/') || cmd.includes('aw-ecc');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isManagedCodexEntry(entry) {
|
|
33
|
+
if (Array.isArray(entry?.hooks)) {
|
|
34
|
+
return entry.hooks.some(h => {
|
|
35
|
+
const cmd = String(h?.command || '');
|
|
36
|
+
return cmd.includes('.codex/hooks/') || cmd.includes('aw-ecc') || cmd.includes('aw-session-start');
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── Read helpers ─────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
function readJson(filePath) {
|
|
45
|
+
if (!existsSync(filePath)) return null;
|
|
46
|
+
try { return JSON.parse(readFileSync(filePath, 'utf8')); } catch { return null; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function writeJson(filePath, data) {
|
|
50
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
51
|
+
writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Collect current hook state ───────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
function collectClaudeTouchpoints() {
|
|
57
|
+
const settingsPath = join(HOME, '.claude', 'settings.json');
|
|
58
|
+
const hooksJsonPath = join(HOME, '.claude', 'hooks', 'hooks.json');
|
|
59
|
+
const settings = readJson(settingsPath);
|
|
60
|
+
const phases = [];
|
|
61
|
+
if (settings?.hooks) {
|
|
62
|
+
for (const phase of Object.keys(settings.hooks)) {
|
|
63
|
+
const entries = settings.hooks[phase];
|
|
64
|
+
if (Array.isArray(entries) && entries.some(isManagedClaudeEntry)) {
|
|
65
|
+
phases.push(phase);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
settingsPath,
|
|
71
|
+
hooksJsonPath: existsSync(hooksJsonPath) ? hooksJsonPath : null,
|
|
72
|
+
managedPhases: phases,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function collectCursorTouchpoints() {
|
|
77
|
+
const hooksJsonPath = join(HOME, '.cursor', 'hooks.json');
|
|
78
|
+
const config = readJson(hooksJsonPath);
|
|
79
|
+
const phases = [];
|
|
80
|
+
if (config?.hooks) {
|
|
81
|
+
for (const phase of Object.keys(config.hooks)) {
|
|
82
|
+
const entries = config.hooks[phase];
|
|
83
|
+
if (Array.isArray(entries) && entries.some(isManagedCursorEntry)) {
|
|
84
|
+
phases.push(phase);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { hooksJsonPath, managedPhases: phases };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function collectCodexTouchpoints() {
|
|
92
|
+
const hooksJsonPath = join(HOME, '.codex', 'hooks.json');
|
|
93
|
+
const configPath = join(HOME, '.codex', 'config.toml');
|
|
94
|
+
const config = readJson(hooksJsonPath);
|
|
95
|
+
const phases = [];
|
|
96
|
+
if (config?.hooks) {
|
|
97
|
+
for (const phase of Object.keys(config.hooks)) {
|
|
98
|
+
const entries = config.hooks[phase];
|
|
99
|
+
if (Array.isArray(entries) && entries.some(isManagedCodexEntry)) {
|
|
100
|
+
phases.push(phase);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
configPath: existsSync(configPath) ? configPath : null,
|
|
106
|
+
hooksJsonPath,
|
|
107
|
+
managedPhases: phases,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function collectGitTouchpoints() {
|
|
112
|
+
const hooksDir = join(HOME, '.aw', 'hooks');
|
|
113
|
+
const scripts = [];
|
|
114
|
+
if (existsSync(hooksDir)) {
|
|
115
|
+
try {
|
|
116
|
+
for (const entry of readdirSync(hooksDir)) {
|
|
117
|
+
// Exclude the manifest itself and hidden files
|
|
118
|
+
if (entry === 'manifest.json' || entry.startsWith('.')) continue;
|
|
119
|
+
scripts.push(entry);
|
|
120
|
+
}
|
|
121
|
+
} catch { /* best effort */ }
|
|
122
|
+
}
|
|
123
|
+
return { hooksDir, scripts };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function collectRuntimeDeps() {
|
|
127
|
+
const deps = [];
|
|
128
|
+
const hooksDir = join(HOME, '.aw-ecc', 'scripts', 'hooks');
|
|
129
|
+
const libDir = join(HOME, '.aw-ecc', 'scripts', 'lib');
|
|
130
|
+
|
|
131
|
+
if (existsSync(hooksDir)) {
|
|
132
|
+
try {
|
|
133
|
+
for (const entry of readdirSync(hooksDir)) {
|
|
134
|
+
if (entry.startsWith('aw-usage-')) {
|
|
135
|
+
deps.push(join(hooksDir, entry));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} catch { /* best effort */ }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const telemetryLib = join(libDir, 'aw-usage-telemetry.js');
|
|
142
|
+
if (existsSync(telemetryLib)) {
|
|
143
|
+
deps.push(telemetryLib);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return deps;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Public API ───────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Write a hook manifest capturing all current AW hook touchpoints.
|
|
153
|
+
* @param {{ eccVersion: string, awVersion: string }} opts
|
|
154
|
+
* @returns {object} the written manifest
|
|
155
|
+
*/
|
|
156
|
+
export function writeHookManifest({ eccVersion, awVersion }) {
|
|
157
|
+
const manifest = {
|
|
158
|
+
schemaVersion: SCHEMA_VERSION,
|
|
159
|
+
createdAt: new Date().toISOString(),
|
|
160
|
+
eccVersion,
|
|
161
|
+
awVersion,
|
|
162
|
+
touchpoints: {
|
|
163
|
+
claude: collectClaudeTouchpoints(),
|
|
164
|
+
cursor: collectCursorTouchpoints(),
|
|
165
|
+
codex: collectCodexTouchpoints(),
|
|
166
|
+
git: collectGitTouchpoints(),
|
|
167
|
+
},
|
|
168
|
+
runtimeDeps: collectRuntimeDeps(),
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
writeJson(MANIFEST_PATH, manifest);
|
|
172
|
+
return manifest;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Read and validate the hook manifest.
|
|
177
|
+
* @returns {object|null} the manifest, or null if missing/invalid
|
|
178
|
+
*/
|
|
179
|
+
export function readHookManifest() {
|
|
180
|
+
const data = readJson(MANIFEST_PATH);
|
|
181
|
+
if (!data || data.schemaVersion !== SCHEMA_VERSION) return null;
|
|
182
|
+
return data;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Remove hook entries and runtime deps listed in a manifest.
|
|
187
|
+
* Does NOT touch git hooks (handled by removeGlobalHooks).
|
|
188
|
+
* @param {object} manifest — from readHookManifest()
|
|
189
|
+
* @returns {number} count of items removed
|
|
190
|
+
*/
|
|
191
|
+
export function pruneStaleHooks(manifest) {
|
|
192
|
+
if (!manifest?.touchpoints) return 0;
|
|
193
|
+
let removed = 0;
|
|
194
|
+
|
|
195
|
+
// Claude: remove managed telemetry entries from settings.json
|
|
196
|
+
const claudeSettings = manifest.touchpoints.claude;
|
|
197
|
+
if (claudeSettings?.settingsPath && claudeSettings.managedPhases?.length > 0) {
|
|
198
|
+
const config = readJson(claudeSettings.settingsPath);
|
|
199
|
+
if (config?.hooks) {
|
|
200
|
+
let changed = false;
|
|
201
|
+
for (const phase of claudeSettings.managedPhases) {
|
|
202
|
+
const entries = config.hooks[phase];
|
|
203
|
+
if (!Array.isArray(entries)) continue;
|
|
204
|
+
const filtered = entries.filter(e => !isManagedClaudeEntry(e));
|
|
205
|
+
if (filtered.length !== entries.length) {
|
|
206
|
+
changed = true;
|
|
207
|
+
removed += entries.length - filtered.length;
|
|
208
|
+
if (filtered.length > 0) {
|
|
209
|
+
config.hooks[phase] = filtered;
|
|
210
|
+
} else {
|
|
211
|
+
delete config.hooks[phase];
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (changed) {
|
|
216
|
+
if (Object.keys(config.hooks).length === 0) delete config.hooks;
|
|
217
|
+
writeJson(claudeSettings.settingsPath, config);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Cursor: remove managed entries from hooks.json
|
|
223
|
+
const cursorHooks = manifest.touchpoints.cursor;
|
|
224
|
+
if (cursorHooks?.hooksJsonPath && cursorHooks.managedPhases?.length > 0) {
|
|
225
|
+
const config = readJson(cursorHooks.hooksJsonPath);
|
|
226
|
+
if (config?.hooks) {
|
|
227
|
+
let changed = false;
|
|
228
|
+
for (const phase of cursorHooks.managedPhases) {
|
|
229
|
+
const entries = config.hooks[phase];
|
|
230
|
+
if (!Array.isArray(entries)) continue;
|
|
231
|
+
const filtered = entries.filter(e => !isManagedCursorEntry(e));
|
|
232
|
+
if (filtered.length !== entries.length) {
|
|
233
|
+
changed = true;
|
|
234
|
+
removed += entries.length - filtered.length;
|
|
235
|
+
if (filtered.length > 0) {
|
|
236
|
+
config.hooks[phase] = filtered;
|
|
237
|
+
} else {
|
|
238
|
+
delete config.hooks[phase];
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (changed) {
|
|
243
|
+
if (Object.keys(config.hooks).length === 0) delete config.hooks;
|
|
244
|
+
writeJson(cursorHooks.hooksJsonPath, config);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Codex: remove managed entries from hooks.json
|
|
250
|
+
const codexHooks = manifest.touchpoints.codex;
|
|
251
|
+
if (codexHooks?.hooksJsonPath && codexHooks.managedPhases?.length > 0) {
|
|
252
|
+
const config = readJson(codexHooks.hooksJsonPath);
|
|
253
|
+
if (config?.hooks) {
|
|
254
|
+
let changed = false;
|
|
255
|
+
for (const phase of codexHooks.managedPhases) {
|
|
256
|
+
const entries = config.hooks[phase];
|
|
257
|
+
if (!Array.isArray(entries)) continue;
|
|
258
|
+
const filtered = entries.filter(e => !isManagedCodexEntry(e));
|
|
259
|
+
if (filtered.length !== entries.length) {
|
|
260
|
+
changed = true;
|
|
261
|
+
removed += entries.length - filtered.length;
|
|
262
|
+
if (filtered.length > 0) {
|
|
263
|
+
config.hooks[phase] = filtered;
|
|
264
|
+
} else {
|
|
265
|
+
delete config.hooks[phase];
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (changed) {
|
|
270
|
+
if (Object.keys(config.hooks).length === 0) delete config.hooks;
|
|
271
|
+
writeJson(codexHooks.hooksJsonPath, config);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Runtime deps: remove aw-usage-* files
|
|
277
|
+
if (Array.isArray(manifest.runtimeDeps)) {
|
|
278
|
+
for (const dep of manifest.runtimeDeps) {
|
|
279
|
+
if (existsSync(dep)) {
|
|
280
|
+
try {
|
|
281
|
+
rmSync(dep, { force: true });
|
|
282
|
+
removed++;
|
|
283
|
+
} catch { /* best effort */ }
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return removed;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Delete the manifest file.
|
|
293
|
+
*/
|
|
294
|
+
export function removeHookManifest() {
|
|
295
|
+
if (existsSync(MANIFEST_PATH)) {
|
|
296
|
+
rmSync(MANIFEST_PATH, { force: true });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Export for testing
|
|
301
|
+
export { MANIFEST_PATH, SCHEMA_VERSION };
|
package/hooks/codex-home.mjs
CHANGED
|
@@ -2,8 +2,7 @@ import { join } from 'node:path';
|
|
|
2
2
|
|
|
3
3
|
import { getSupportedHarnessPhaseEntries } from '../hook-manifest.mjs';
|
|
4
4
|
import {
|
|
5
|
-
|
|
6
|
-
buildRegistryDelegatingPhaseScript,
|
|
5
|
+
buildRegistryDelegatingPhaseScriptWithTelemetry,
|
|
7
6
|
buildReservedPhaseScript,
|
|
8
7
|
} from './shared-phase-scripts.mjs';
|
|
9
8
|
|
|
@@ -19,7 +18,7 @@ const CODEX_HOME_PHASE_BLUEPRINTS = {
|
|
|
19
18
|
scriptName: 'aw-session-start.sh',
|
|
20
19
|
scriptMarker: '# aw-managed: codex-global-session-start',
|
|
21
20
|
buildScriptContent() {
|
|
22
|
-
return
|
|
21
|
+
return buildRegistryDelegatingPhaseScriptWithTelemetry({
|
|
23
22
|
marker: this.scriptMarker,
|
|
24
23
|
phase: 'SessionStart',
|
|
25
24
|
targetCandidates: [
|
|
@@ -27,6 +26,8 @@ const CODEX_HOME_PHASE_BLUEPRINTS = {
|
|
|
27
26
|
'$HOME/.aw/.aw_registry/platform/core/skills/using-aw-skills/hooks/session-start.sh',
|
|
28
27
|
],
|
|
29
28
|
warningMessage: 'WARNING: AW using-aw-skills hook not found in ~/.aw_registry. Run aw init or aw pull platform.',
|
|
29
|
+
telemetryHookPath: '$HOME/.aw-ecc/scripts/hooks/aw-usage-session-start.js',
|
|
30
|
+
harnessEnv: 'codex',
|
|
30
31
|
});
|
|
31
32
|
},
|
|
32
33
|
buildEntry(command) {
|
|
@@ -37,6 +37,53 @@ echo "{\\"hookSpecificOutput\\":{\\"hookEventName\\":\\"${phase}\\",\\"additiona
|
|
|
37
37
|
`;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
export function buildRegistryDelegatingPhaseScriptWithTelemetry({
|
|
41
|
+
marker,
|
|
42
|
+
phase,
|
|
43
|
+
targetCandidates,
|
|
44
|
+
warningMessage,
|
|
45
|
+
telemetryHookPath,
|
|
46
|
+
harnessEnv,
|
|
47
|
+
}) {
|
|
48
|
+
const targetsBlock = targetCandidates
|
|
49
|
+
.map(target => ` "${target}"`)
|
|
50
|
+
.join('\n');
|
|
51
|
+
const escapedWarning = escapeDoubleQuotes(warningMessage);
|
|
52
|
+
|
|
53
|
+
return `#!/usr/bin/env bash
|
|
54
|
+
${marker}
|
|
55
|
+
set -euo pipefail
|
|
56
|
+
|
|
57
|
+
# Capture stdin so we can feed it to both telemetry and the AW router delegate.
|
|
58
|
+
STDIN=$(cat)
|
|
59
|
+
|
|
60
|
+
# Fire telemetry (non-blocking, all output suppressed).
|
|
61
|
+
TELEMETRY_HOOK="${telemetryHookPath}"
|
|
62
|
+
if [[ -f "$TELEMETRY_HOOK" ]] && command -v node >/dev/null 2>&1; then
|
|
63
|
+
printf '%s' "$STDIN" | AW_HARNESS=${harnessEnv} node "$TELEMETRY_HOOK" >/dev/null 2>&1 || true
|
|
64
|
+
fi
|
|
65
|
+
|
|
66
|
+
TARGETS=(
|
|
67
|
+
${targetsBlock}
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
for target in "\${TARGETS[@]}"; do
|
|
71
|
+
if [[ -f "\$target" ]]; then
|
|
72
|
+
printf '%s' "$STDIN" | bash "\$target"
|
|
73
|
+
exit $?
|
|
74
|
+
fi
|
|
75
|
+
done
|
|
76
|
+
|
|
77
|
+
CONTEXT="# AW Session Context
|
|
78
|
+
|
|
79
|
+
${escapedWarning}"
|
|
80
|
+
|
|
81
|
+
JSON_CONTEXT=$(printf '%s' "\$CONTEXT" | python3 -c 'import json, sys; print(json.dumps(sys.stdin.read()))')
|
|
82
|
+
|
|
83
|
+
echo "{\\"hookSpecificOutput\\":{\\"hookEventName\\":\\"${phase}\\",\\"additionalContext\\":\${JSON_CONTEXT}}}"
|
|
84
|
+
`;
|
|
85
|
+
}
|
|
86
|
+
|
|
40
87
|
export function buildDelegatingPhaseScript({
|
|
41
88
|
marker,
|
|
42
89
|
targetPath,
|
package/hooks.mjs
CHANGED
|
@@ -95,6 +95,16 @@ if [ -f ".aw/.git" ] && command -v aw >/dev/null 2>&1; then
|
|
|
95
95
|
AW_TRIGGER=hook:post-commit aw link >/dev/null 2>&1 &
|
|
96
96
|
fi
|
|
97
97
|
|
|
98
|
+
# Fire commit_created telemetry if commit has AW co-author trailer
|
|
99
|
+
if git log -1 --format='%b' HEAD 2>/dev/null | grep -qF "Co-Authored-By: AW"; then
|
|
100
|
+
TELEMETRY_HOOK="$HOME/.aw-ecc/scripts/hooks/aw-usage-commit-created.js"
|
|
101
|
+
if command -v node >/dev/null 2>&1 && [ -f "$TELEMETRY_HOOK" ]; then
|
|
102
|
+
COMMIT_HASH="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"
|
|
103
|
+
BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)"
|
|
104
|
+
node "$TELEMETRY_HOOK" "$COMMIT_HASH" "$BRANCH" >/dev/null 2>&1
|
|
105
|
+
fi
|
|
106
|
+
fi
|
|
107
|
+
|
|
98
108
|
# Chain to previous hooksPath
|
|
99
109
|
PREV_PATH_FILE="$HOME/.aw/hooks/.previous-hooks-path"
|
|
100
110
|
if [ -f "$PREV_PATH_FILE" ]; then
|
|
@@ -171,11 +181,11 @@ export function installGlobalHooks() {
|
|
|
171
181
|
|
|
172
182
|
if (previousPath && previousPath !== HOOKS_DIR) {
|
|
173
183
|
writeFileSync(PREV_PATH_FILE, previousPath);
|
|
174
|
-
fmt.logStep(`
|
|
184
|
+
fmt.logStep(`Chained existing git hooks (${previousPath})`);
|
|
175
185
|
}
|
|
176
186
|
|
|
177
187
|
execSync(`git config --global core.hooksPath "${HOOKS_DIR}"`, { stdio: 'pipe' });
|
|
178
|
-
fmt.logStep('
|
|
188
|
+
fmt.logStep('Git hooks installed');
|
|
179
189
|
return true;
|
|
180
190
|
} catch (e) {
|
|
181
191
|
fmt.logWarn(`Could not install global hooks: ${e.message}`);
|
|
@@ -258,10 +268,31 @@ fi
|
|
|
258
268
|
exit 0
|
|
259
269
|
`;
|
|
260
270
|
|
|
271
|
+
// Standalone post-commit for local .git/hooks/ installation.
|
|
272
|
+
// Fires commit_created telemetry when the commit has AW co-author trailer.
|
|
273
|
+
const LOCAL_POST_COMMIT = `#!/bin/sh
|
|
274
|
+
# aw: local post-commit hook (installed by aw init)
|
|
275
|
+
|
|
276
|
+
# Skip aw temp dirs
|
|
277
|
+
case "$(pwd)" in /tmp/aw-*|/var/folders/*/aw-*|*/.aw|*/.aw/*) exit 0 ;; esac
|
|
278
|
+
|
|
279
|
+
# Fire commit_created telemetry if commit has AW co-author trailer
|
|
280
|
+
if git log -1 --format='%b' HEAD 2>/dev/null | grep -qF "Co-Authored-By: AW"; then
|
|
281
|
+
TELEMETRY_HOOK="$HOME/.aw-ecc/scripts/hooks/aw-usage-commit-created.js"
|
|
282
|
+
if command -v node >/dev/null 2>&1 && [ -f "$TELEMETRY_HOOK" ]; then
|
|
283
|
+
COMMIT_HASH="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"
|
|
284
|
+
BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)"
|
|
285
|
+
node "$TELEMETRY_HOOK" "$COMMIT_HASH" "$BRANCH" >/dev/null 2>&1
|
|
286
|
+
fi
|
|
287
|
+
fi
|
|
288
|
+
|
|
289
|
+
exit 0
|
|
290
|
+
`;
|
|
291
|
+
|
|
261
292
|
/**
|
|
262
|
-
* Install prepare-commit-msg
|
|
263
|
-
* This covers repos where another tool (e.g. Claude Code) sets
|
|
264
|
-
* core.hooksPath that overrides the global one.
|
|
293
|
+
* Install prepare-commit-msg and post-commit hooks into a project's local
|
|
294
|
+
* .git/hooks/. This covers repos where another tool (e.g. Claude Code) sets
|
|
295
|
+
* a local core.hooksPath that overrides the global one.
|
|
265
296
|
*
|
|
266
297
|
* @param {string} projectDir — root of the project (must contain .git/)
|
|
267
298
|
*/
|
|
@@ -297,6 +328,15 @@ export function installLocalCommitHook(projectDir) {
|
|
|
297
328
|
|
|
298
329
|
writeFileSync(hookPath, LOCAL_PREPARE_COMMIT_MSG);
|
|
299
330
|
chmodSync(hookPath, '755');
|
|
331
|
+
|
|
332
|
+
// Also install post-commit for commit_created telemetry
|
|
333
|
+
const postCommitPath = join(hooksDir, 'post-commit');
|
|
334
|
+
const shouldWritePostCommit = !existsSync(postCommitPath)
|
|
335
|
+
|| readFileSync(postCommitPath, 'utf8').includes('aw:');
|
|
336
|
+
if (shouldWritePostCommit) {
|
|
337
|
+
writeFileSync(postCommitPath, LOCAL_POST_COMMIT);
|
|
338
|
+
chmodSync(postCommitPath, '755');
|
|
339
|
+
}
|
|
300
340
|
} catch { /* best effort */ }
|
|
301
341
|
}
|
|
302
342
|
|
package/integrate.mjs
CHANGED
|
@@ -246,7 +246,7 @@ export function copyInstructions(cwd, tempDir, namespace) {
|
|
|
246
246
|
|
|
247
247
|
if (updated !== existing) {
|
|
248
248
|
writeFileSync(dest, updated);
|
|
249
|
-
fmt.logStep(`
|
|
249
|
+
fmt.logStep(`Migrated ${file} to global config`);
|
|
250
250
|
}
|
|
251
251
|
continue;
|
|
252
252
|
}
|
|
@@ -643,7 +643,7 @@ No active tasks. Tasks are created during workflow execution.
|
|
|
643
643
|
// _pending-sync.jsonl — sync queue (empty)
|
|
644
644
|
writeFileSync(join(awDocsDir, 'learnings', '_pending-sync.jsonl'), '');
|
|
645
645
|
|
|
646
|
-
fmt.logSuccess('
|
|
646
|
+
fmt.logSuccess('Orchestration state ready');
|
|
647
647
|
}
|
|
648
648
|
|
|
649
649
|
/**
|
package/mcp.mjs
CHANGED
|
@@ -52,7 +52,7 @@ function resolveGitHubToken(silent = false) {
|
|
|
52
52
|
timeout: 5000,
|
|
53
53
|
}).trim();
|
|
54
54
|
if (token && (token.startsWith('ghp_') || token.startsWith('gho_') || token.startsWith('github_pat_'))) {
|
|
55
|
-
if (!silent) fmt.logStep('
|
|
55
|
+
if (!silent) fmt.logStep('GitHub token found');
|
|
56
56
|
return token;
|
|
57
57
|
}
|
|
58
58
|
} catch { /* not authenticated yet */ }
|
|
@@ -312,7 +312,8 @@ export async function setupMcp(cwd, namespace, { silent = false } = {}) {
|
|
|
312
312
|
const unique = [...new Set(updatedFiles)];
|
|
313
313
|
|
|
314
314
|
if (unique.length > 0) {
|
|
315
|
-
|
|
315
|
+
const shortNames = unique.map(f => fmt.chalk.cyan(f.replace(HOME, '~')));
|
|
316
|
+
fmt.logSuccess(`MCP configured (${shortNames.join(', ')})`);
|
|
316
317
|
} else {
|
|
317
318
|
fmt.logInfo('MCP servers already configured — no changes needed');
|
|
318
319
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ghl-ai/aw",
|
|
3
|
-
"version": "0.1.42-beta.
|
|
3
|
+
"version": "0.1.42-beta.40",
|
|
4
4
|
"description": "Agentic Workspace CLI — pull, push & manage agents, skills and commands from the registry",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"slack-sim/",
|
|
27
27
|
"file-tree.mjs",
|
|
28
28
|
"apply.mjs",
|
|
29
|
+
"hook-cleanup.mjs",
|
|
29
30
|
"hook-manifest.mjs",
|
|
30
31
|
"update.mjs",
|
|
31
32
|
"hooks.mjs",
|
package/render-rules.mjs
CHANGED
|
@@ -351,6 +351,7 @@ For every non-trivial request, execute these steps in order before any substanti
|
|
|
351
351
|
- /aw-review → Read aw-review/SKILL.md
|
|
352
352
|
- /aw-deploy → Read aw-deploy/SKILL.md
|
|
353
353
|
- /aw-ship → Read aw-ship/SKILL.md
|
|
354
|
+
- /aw-feature → Read aw-feature/SKILL.md
|
|
354
355
|
|
|
355
356
|
4. **Follow the skill's behavior** — produce the artifacts the skill defines, not general-knowledge answers.
|
|
356
357
|
|
package/startup.mjs
CHANGED
|
@@ -16,25 +16,30 @@ const DISABLED_MODE = 'disabled';
|
|
|
16
16
|
const CLAUDE_DISABLE_DESCRIPTION = 'AW-managed override: disable automatic AW session routing';
|
|
17
17
|
const CLAUDE_TELEMETRY_DESCRIPTION = 'AW usage telemetry';
|
|
18
18
|
const CLAUDE_TELEMETRY_HOOKS = [
|
|
19
|
+
{
|
|
20
|
+
phase: 'SessionStart',
|
|
21
|
+
matcher: undefined,
|
|
22
|
+
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',
|
|
23
|
+
},
|
|
19
24
|
{
|
|
20
25
|
phase: 'PostToolUse',
|
|
21
26
|
matcher: 'Skill|Agent|Shell|Bash',
|
|
22
|
-
command: 'node "$HOME/.aw-ecc/scripts/hooks/aw-usage-post-tool-use.js"',
|
|
27
|
+
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',
|
|
23
28
|
},
|
|
24
29
|
{
|
|
25
30
|
phase: 'Stop',
|
|
26
31
|
matcher: undefined,
|
|
27
|
-
command: 'node "$HOME/.aw-ecc/scripts/hooks/aw-usage-stop.js"',
|
|
32
|
+
command: 'test -f "$HOME/.aw-ecc/scripts/hooks/aw-usage-stop.js" && node "$HOME/.aw-ecc/scripts/hooks/aw-usage-stop.js" || true',
|
|
28
33
|
},
|
|
29
34
|
{
|
|
30
35
|
phase: 'PostToolUseFailure',
|
|
31
36
|
matcher: undefined,
|
|
32
|
-
command: 'node "$HOME/.aw-ecc/scripts/hooks/aw-usage-post-tool-use-failure.js"',
|
|
37
|
+
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',
|
|
33
38
|
},
|
|
34
39
|
{
|
|
35
40
|
phase: 'UserPromptSubmit',
|
|
36
41
|
matcher: undefined,
|
|
37
|
-
command: 'node "$HOME/.aw-ecc/scripts/hooks/aw-usage-prompt-submit.js"',
|
|
42
|
+
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',
|
|
38
43
|
},
|
|
39
44
|
];
|
|
40
45
|
const CURSOR_SESSION_START_COMMAND = 'node .cursor/hooks/session-start.js';
|
|
@@ -168,7 +173,10 @@ function hasCommandHook(entry, command) {
|
|
|
168
173
|
}
|
|
169
174
|
|
|
170
175
|
function isManagedClaudeTelemetryEntry(entry) {
|
|
171
|
-
|
|
176
|
+
if (entry?.description === CLAUDE_TELEMETRY_DESCRIPTION) return true;
|
|
177
|
+
// Also match legacy entries (no description) by command pattern
|
|
178
|
+
const cmds = Array.isArray(entry?.hooks) ? entry.hooks.map(h => h?.command || '') : [];
|
|
179
|
+
return cmds.some(c => c.includes('aw-usage-'));
|
|
172
180
|
}
|
|
173
181
|
|
|
174
182
|
function buildClaudeTelemetryEntry(hookDef) {
|
|
@@ -187,6 +195,18 @@ function buildClaudeTelemetryEntry(hookDef) {
|
|
|
187
195
|
return entry;
|
|
188
196
|
}
|
|
189
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Resolve the filesystem path from a telemetry hook command.
|
|
200
|
+
* Commands look like: node "$HOME/.aw-ecc/scripts/hooks/aw-usage-stop.js"
|
|
201
|
+
*/
|
|
202
|
+
function resolveHookScriptPath(command, homeDir) {
|
|
203
|
+
const match = command.match(/"\$HOME\/([^"]+)"/);
|
|
204
|
+
if (match) return join(homeDir, match[1]);
|
|
205
|
+
const matchBare = command.match(/\$HOME\/(\S+)/);
|
|
206
|
+
if (matchBare) return join(homeDir, matchBare[1]);
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
190
210
|
function enableClaudeTelemetryHooks(homeDir = homedir()) {
|
|
191
211
|
const settingsPath = join(homeDir, '.claude', 'settings.json');
|
|
192
212
|
const config = readJson(settingsPath, {});
|
|
@@ -196,15 +216,41 @@ function enableClaudeTelemetryHooks(homeDir = homedir()) {
|
|
|
196
216
|
|
|
197
217
|
for (const hookDef of CLAUDE_TELEMETRY_HOOKS) {
|
|
198
218
|
const current = Array.isArray(config.hooks[hookDef.phase]) ? config.hooks[hookDef.phase] : [];
|
|
199
|
-
|
|
200
|
-
|
|
219
|
+
const managed = current.filter(isManagedClaudeTelemetryEntry);
|
|
220
|
+
|
|
221
|
+
// Only register hooks whose script files actually exist on disk.
|
|
222
|
+
// On version downgrade the aw-ecc clone may not have them — registering
|
|
223
|
+
// hooks for missing scripts causes MODULE_NOT_FOUND crashes.
|
|
224
|
+
const scriptPath = resolveHookScriptPath(hookDef.command, homeDir);
|
|
225
|
+
if (scriptPath && !existsSync(scriptPath)) {
|
|
226
|
+
// Script missing — remove any stale telemetry entries for this phase
|
|
227
|
+
if (managed.length > 0) {
|
|
228
|
+
const cleaned = current.filter(e => !isManagedClaudeTelemetryEntry(e));
|
|
229
|
+
if (cleaned.length > 0) {
|
|
230
|
+
config.hooks[hookDef.phase] = cleaned;
|
|
231
|
+
} else {
|
|
232
|
+
delete config.hooks[hookDef.phase];
|
|
233
|
+
}
|
|
234
|
+
changed = true;
|
|
235
|
+
}
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// If exactly one properly described entry exists, nothing to do
|
|
240
|
+
if (managed.length === 1 && managed[0].description === CLAUDE_TELEMETRY_DESCRIPTION) continue;
|
|
201
241
|
|
|
202
|
-
|
|
242
|
+
// Remove all legacy/duplicate telemetry entries, then add the canonical one
|
|
243
|
+
const cleaned = current.filter(e => !isManagedClaudeTelemetryEntry(e));
|
|
244
|
+
config.hooks[hookDef.phase] = [...cleaned, buildClaudeTelemetryEntry(hookDef)];
|
|
203
245
|
changed = true;
|
|
204
246
|
}
|
|
205
247
|
|
|
206
248
|
if (!changed) return [];
|
|
207
249
|
|
|
250
|
+
if (isEmptyObject(config.hooks)) {
|
|
251
|
+
delete config.hooks;
|
|
252
|
+
}
|
|
253
|
+
|
|
208
254
|
writeJson(settingsPath, config);
|
|
209
255
|
return [settingsPath];
|
|
210
256
|
}
|
package/telemetry.mjs
CHANGED
|
@@ -192,7 +192,7 @@ export async function startSpan(command, args) {
|
|
|
192
192
|
notice() {
|
|
193
193
|
if (disabled || config.noticed) return;
|
|
194
194
|
if (args?.['--silent'] || !process.stderr.isTTY) return;
|
|
195
|
-
|
|
195
|
+
// Telemetry notice suppressed — opt-out via `aw telemetry disable`
|
|
196
196
|
config.noticed = true;
|
|
197
197
|
saveConfig(config);
|
|
198
198
|
},
|