@ghl-ai/aw 0.1.42-beta.9 → 0.1.42
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 +119 -33
- package/commands/nuke.mjs +49 -60
- package/ecc.mjs +83 -23
- package/fmt.mjs +27 -17
- package/hook-cleanup.mjs +306 -0
- package/hooks/codex-home.mjs +60 -18
- 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 +160 -3
- package/telemetry.mjs +14 -4
package/commands/init.mjs
CHANGED
|
@@ -13,15 +13,15 @@ import {
|
|
|
13
13
|
readFileSync,
|
|
14
14
|
rmSync,
|
|
15
15
|
realpathSync,
|
|
16
|
-
appendFileSync,
|
|
17
16
|
} from 'node:fs';
|
|
18
17
|
import { execSync } from 'node:child_process';
|
|
19
18
|
import { join, dirname, sep } from 'node:path';
|
|
20
19
|
import { homedir } from 'node:os';
|
|
21
20
|
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import * as p from '@clack/prompts';
|
|
22
22
|
import * as config from '../config.mjs';
|
|
23
23
|
import * as fmt from '../fmt.mjs';
|
|
24
|
-
import { chalk } from '../fmt.mjs';
|
|
24
|
+
import { chalk, setSilent } from '../fmt.mjs';
|
|
25
25
|
import { linkWorkspace } from '../link.mjs';
|
|
26
26
|
import { generateCommands, copyInstructions, initAwDocs, syncHomeHarnessInstructions } from '../integrate.mjs';
|
|
27
27
|
import { setupMcp } from '../mcp.mjs';
|
|
@@ -29,8 +29,10 @@ import { applyStoredStartupPreferences, ensureAwRuntimeHook } from '../startup.m
|
|
|
29
29
|
import { installLocalCommitHook } from '../hooks.mjs';
|
|
30
30
|
import { autoUpdate, promptUpdate } from '../update.mjs';
|
|
31
31
|
import { installGlobalHooks } from '../hooks.mjs';
|
|
32
|
-
import {
|
|
32
|
+
import { loadConfig as ensureTelemetryConfig } from '../telemetry.mjs';
|
|
33
|
+
import { installAwEcc, AW_ECC_TAG } from '../ecc.mjs';
|
|
33
34
|
import { removeWorkspaceHookDefaults } from '../codex.mjs';
|
|
35
|
+
import { readHookManifest, pruneStaleHooks, writeHookManifest } from '../hook-cleanup.mjs';
|
|
34
36
|
import {
|
|
35
37
|
initPersistentClone,
|
|
36
38
|
isValidClone,
|
|
@@ -88,24 +90,65 @@ function syncHomeAndProjectInstructions(cwd, namespace) {
|
|
|
88
90
|
}
|
|
89
91
|
}
|
|
90
92
|
|
|
91
|
-
// ── Ensure ~/.aw/.
|
|
92
|
-
|
|
93
|
-
|
|
93
|
+
// ── Ensure ~/.aw/.git/info/exclude has the whitelist block ─────────────
|
|
94
|
+
//
|
|
95
|
+
// Strategy: only .aw_registry/, .aw_rules/, content/ are tracked — everything
|
|
96
|
+
// else at the top level of ~/.aw/ is local-only (telemetry/, hooks/, logs,
|
|
97
|
+
// .DS_Store, etc.). We write to .git/info/exclude (not tracked .gitignore)
|
|
98
|
+
// so upstream pulls never conflict.
|
|
99
|
+
|
|
100
|
+
const AW_MANAGED_BEGIN = '# BEGIN aw-managed (do not edit; managed by `aw init`)';
|
|
101
|
+
const AW_MANAGED_END = '# END aw-managed';
|
|
102
|
+
|
|
103
|
+
const AW_MANAGED_BLOCK = [
|
|
104
|
+
AW_MANAGED_BEGIN,
|
|
105
|
+
'# Whitelist: only these top-level entries are tracked; everything else is local-only.',
|
|
106
|
+
'/*',
|
|
107
|
+
'!/.aw_registry',
|
|
108
|
+
'!/.aw_rules',
|
|
109
|
+
'!/content',
|
|
110
|
+
'',
|
|
111
|
+
'# Nested local state within whitelisted dirs',
|
|
112
|
+
'/.aw_registry/.sync-config.json',
|
|
113
|
+
AW_MANAGED_END,
|
|
114
|
+
'',
|
|
115
|
+
].join('\n');
|
|
116
|
+
|
|
117
|
+
// Legacy flat lines appended by earlier versions of ensureAwGitignore — strip on upgrade
|
|
118
|
+
// so we don't leave stale rules lingering outside the managed block.
|
|
119
|
+
const LEGACY_GITIGNORE_LINES = new Set([
|
|
94
120
|
'.aw_registry/.sync-config.json',
|
|
95
121
|
'hooks/',
|
|
96
|
-
|
|
122
|
+
'# aw: personal/local — do not commit',
|
|
123
|
+
]);
|
|
97
124
|
|
|
98
|
-
function
|
|
99
|
-
|
|
125
|
+
function escapeRegex(s) {
|
|
126
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function ensureAwGitignore(awHome) {
|
|
100
130
|
const excludePath = join(awHome, '.git', 'info', 'exclude');
|
|
101
131
|
let existing = '';
|
|
102
|
-
try { existing = readFileSync(excludePath, 'utf8'); } catch { /* doesn't exist yet */ }
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
132
|
+
try { existing = readFileSync(excludePath, 'utf8'); } catch (err) { void err; /* doesn't exist yet */ }
|
|
133
|
+
|
|
134
|
+
// Strip any prior aw-managed block so re-rendering is idempotent.
|
|
135
|
+
const blockRegex = new RegExp(
|
|
136
|
+
`${escapeRegex(AW_MANAGED_BEGIN)}[\\s\\S]*?${escapeRegex(AW_MANAGED_END)}\\n?`,
|
|
137
|
+
'g'
|
|
138
|
+
);
|
|
139
|
+
const withoutManaged = existing.replace(blockRegex, '');
|
|
140
|
+
|
|
141
|
+
// Strip legacy flat lines (pre-whitelist implementation).
|
|
142
|
+
const cleaned = withoutManaged
|
|
143
|
+
.split('\n')
|
|
144
|
+
.filter(line => !LEGACY_GITIGNORE_LINES.has(line.trim()))
|
|
145
|
+
.join('\n');
|
|
146
|
+
|
|
147
|
+
const prefix = cleaned === '' || cleaned.endsWith('\n') ? cleaned : cleaned + '\n';
|
|
148
|
+
const next = prefix + AW_MANAGED_BLOCK;
|
|
149
|
+
|
|
150
|
+
if (next === existing) return; // already up to date
|
|
151
|
+
try { writeFileSync(excludePath, next); } catch (err) { void err; /* best effort */ }
|
|
109
152
|
}
|
|
110
153
|
|
|
111
154
|
// ── IDE tasks for auto-pull ─────────────────────────────────────────────
|
|
@@ -137,11 +180,11 @@ function installIdeTasks() {
|
|
|
137
180
|
existing.tasks = existing.tasks || [];
|
|
138
181
|
existing.tasks.push(vscodeTask.tasks[0]);
|
|
139
182
|
writeFileSync(tasksPath, JSON.stringify(existing, null, 2) + '\n');
|
|
140
|
-
fmt.logStep(`
|
|
183
|
+
fmt.logStep(`Auto-sync task added to ${ide}`);
|
|
141
184
|
} catch { /* corrupted tasks.json, skip */ }
|
|
142
185
|
} else {
|
|
143
186
|
writeFileSync(tasksPath, JSON.stringify(vscodeTask, null, 2) + '\n');
|
|
144
|
-
fmt.logStep(`
|
|
187
|
+
fmt.logStep(`Auto-sync task added to ${ide}`);
|
|
145
188
|
}
|
|
146
189
|
}
|
|
147
190
|
}
|
|
@@ -153,6 +196,16 @@ export async function initCommand(args) {
|
|
|
153
196
|
let user = args['--user'] || '';
|
|
154
197
|
const silent = args['--silent'] === true;
|
|
155
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
|
+
|
|
156
209
|
fmt.intro(`aw init ${chalk.dim('v' + VERSION)}`);
|
|
157
210
|
|
|
158
211
|
// ── Validate ──────────────────────────────────────────────────────────
|
|
@@ -283,6 +336,7 @@ export async function initCommand(args) {
|
|
|
283
336
|
syncRulesTargets(cwd);
|
|
284
337
|
}
|
|
285
338
|
removeLegacyRegistryRules();
|
|
339
|
+
if (!silent) fmt.logStep('Rules synced');
|
|
286
340
|
|
|
287
341
|
// Ensure project worktree sparse checkout matches the global clone.
|
|
288
342
|
// Covers the case where a namespace was added from HOME (or another project)
|
|
@@ -292,13 +346,19 @@ export async function initCommand(args) {
|
|
|
292
346
|
try { syncWorktreeSparseCheckout(AW_HOME, localAw); } catch { /* best effort */ }
|
|
293
347
|
}
|
|
294
348
|
|
|
349
|
+
// Prune stale hooks from prior version before installing new ones
|
|
350
|
+
const oldManifest = readHookManifest();
|
|
351
|
+
if (oldManifest) pruneStaleHooks(oldManifest);
|
|
352
|
+
|
|
295
353
|
await installAwEcc(cwd, { silent });
|
|
354
|
+
|
|
296
355
|
ensureAwRuntimeHook(HOME);
|
|
297
356
|
syncHomeAndProjectInstructions(cwd, freshCfg?.namespace || team);
|
|
298
357
|
await setupMcp(HOME, freshCfg?.namespace || team, { silent });
|
|
299
358
|
applyStoredStartupPreferences(HOME);
|
|
300
359
|
const removedLegacyStartupFiles = cwd !== HOME ? removeWorkspaceHookDefaults(cwd) : [];
|
|
301
360
|
installGlobalHooks();
|
|
361
|
+
if (!silent) fmt.logStep('Hooks and IDE integration configured');
|
|
302
362
|
|
|
303
363
|
// Remove old local .git/hooks/post-checkout that pre-dates core.hooksPath (creates stale .aw_registry symlink)
|
|
304
364
|
if (cwd !== HOME) {
|
|
@@ -325,13 +385,19 @@ export async function initCommand(args) {
|
|
|
325
385
|
|
|
326
386
|
// Wire ~/.claude/.cursor/.codex to the project's registry when in a project,
|
|
327
387
|
// so edits to project/.aw/.aw_registry/ are instantly visible in global IDE dirs.
|
|
388
|
+
if (!silent) fmt.logStep('Wiring IDE symlinks...');
|
|
328
389
|
const projectRegistryDir = cwd !== HOME ? join(cwd, '.aw', REGISTRY_DIR) : null;
|
|
329
390
|
const awDirForLinks = (projectRegistryDir && existsSync(projectRegistryDir)) ? projectRegistryDir : null;
|
|
330
391
|
const symlinks = linkWorkspace(HOME, awDirForLinks, { silent: true });
|
|
331
392
|
const commands = generateCommands(HOME, { silent: true });
|
|
332
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 */ }
|
|
333
398
|
|
|
334
399
|
if (silent) {
|
|
400
|
+
if (silentSpinner) { silentSpinner.stop('Done'); setSilent(false); }
|
|
335
401
|
autoUpdate(await args._updateCheck);
|
|
336
402
|
} else {
|
|
337
403
|
fmt.outro([
|
|
@@ -377,7 +443,6 @@ export async function initCommand(args) {
|
|
|
377
443
|
}
|
|
378
444
|
|
|
379
445
|
fmt.note([
|
|
380
|
-
`${chalk.dim('source:')} ~/.aw/ → ~/.aw_registry/ (symlink)`,
|
|
381
446
|
folderName ? `${chalk.dim('namespace:')} ${folderName}` : `${chalk.dim('namespace:')} ${chalk.dim('none')}`,
|
|
382
447
|
user ? `${chalk.dim('user:')} ${user}` : null,
|
|
383
448
|
`${chalk.dim('version:')} v${VERSION}`,
|
|
@@ -402,7 +467,7 @@ export async function initCommand(args) {
|
|
|
402
467
|
if (!awRegistryLstat) {
|
|
403
468
|
try {
|
|
404
469
|
symlinkSync(join(AW_HOME, REGISTRY_DIR), GLOBAL_AW_DIR);
|
|
405
|
-
fmt.logStep('
|
|
470
|
+
fmt.logStep('Registry linked');
|
|
406
471
|
} catch (e) {
|
|
407
472
|
fmt.logWarn(`Could not create symlink ~/.aw_registry/: ${e.message}`);
|
|
408
473
|
}
|
|
@@ -411,7 +476,7 @@ export async function initCommand(args) {
|
|
|
411
476
|
try {
|
|
412
477
|
rmSync(GLOBAL_AW_DIR);
|
|
413
478
|
symlinkSync(join(AW_HOME, REGISTRY_DIR), GLOBAL_AW_DIR);
|
|
414
|
-
fmt.logStep('
|
|
479
|
+
fmt.logStep('Registry linked');
|
|
415
480
|
} catch (e) {
|
|
416
481
|
fmt.logWarn(`Could not update symlink ~/.aw_registry/: ${e.message}`);
|
|
417
482
|
}
|
|
@@ -422,21 +487,32 @@ export async function initCommand(args) {
|
|
|
422
487
|
if (folderName) {
|
|
423
488
|
config.addPattern(GLOBAL_AW_DIR, folderName);
|
|
424
489
|
}
|
|
490
|
+
// Parallel batch A: rules sync (HOME + cwd are independent targets)
|
|
425
491
|
syncRulesTargets(HOME);
|
|
426
|
-
if (cwd !== HOME)
|
|
427
|
-
syncRulesTargets(cwd);
|
|
428
|
-
}
|
|
492
|
+
if (cwd !== HOME) syncRulesTargets(cwd);
|
|
429
493
|
removeLegacyRegistryRules();
|
|
494
|
+
if (!silent) fmt.logStep('Rules synced');
|
|
430
495
|
|
|
431
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
|
+
|
|
432
501
|
await installAwEcc(cwd, { silent });
|
|
502
|
+
|
|
433
503
|
ensureAwRuntimeHook(HOME);
|
|
434
|
-
|
|
435
|
-
|
|
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
|
|
436
511
|
applyStoredStartupPreferences(HOME);
|
|
437
512
|
const removedLegacyStartupFiles = cwd !== HOME ? removeWorkspaceHookDefaults(cwd) : [];
|
|
438
513
|
const hooksInstalled = installGlobalHooks();
|
|
439
514
|
installIdeTasks();
|
|
515
|
+
if (!silent) fmt.logStep('Hooks and IDE integration configured');
|
|
440
516
|
|
|
441
517
|
// Remove old local .git/hooks/post-checkout that pre-dates core.hooksPath
|
|
442
518
|
if (cwd !== HOME) {
|
|
@@ -462,18 +538,28 @@ export async function initCommand(args) {
|
|
|
462
538
|
|
|
463
539
|
// Step 5: Wire ~/.claude/.cursor/.codex to the project's registry when in a project,
|
|
464
540
|
// so edits to project/.aw/.aw_registry/ are instantly visible in global IDE dirs.
|
|
465
|
-
|
|
466
|
-
ideSpinner.start('Wiring IDE symlinks...');
|
|
541
|
+
if (!silent) fmt.logStep('Wiring IDE symlinks...');
|
|
467
542
|
const projectRegistryDir = cwd !== HOME ? join(cwd, '.aw', REGISTRY_DIR) : null;
|
|
468
543
|
const awDirForLinks = (projectRegistryDir && existsSync(projectRegistryDir)) ? projectRegistryDir : null;
|
|
469
|
-
|
|
544
|
+
// Parallel batch C: symlinks + commands are independent
|
|
470
545
|
if (cwd !== HOME) installLocalCommitHook(cwd);
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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();
|
|
474
557
|
|
|
475
558
|
// Offer to update if a newer version is available
|
|
476
|
-
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); }
|
|
477
563
|
|
|
478
564
|
fmt.outro([
|
|
479
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";
|
|
14
16
|
|
|
15
17
|
const MARKETPLACE_NAME = "aw-marketplace";
|
|
16
18
|
const PLUGIN_KEY = `aw@${MARKETPLACE_NAME}`;
|
|
@@ -63,6 +65,70 @@ 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
|
+
// Restore working tree — pruneStaleHooks may have deleted tracked files
|
|
104
|
+
await runA(`git -C ${dest} checkout -- .`);
|
|
105
|
+
return;
|
|
106
|
+
} catch { /* fall through to fresh clone */ }
|
|
107
|
+
}
|
|
108
|
+
if (existsSync(dest)) rmSync(dest, { recursive: true, force: true });
|
|
109
|
+
if (overrideUrl) {
|
|
110
|
+
if (overrideRef) {
|
|
111
|
+
await cloneWithRefAsync(overrideUrl, overrideRef, dest);
|
|
112
|
+
} else {
|
|
113
|
+
await runA(`git clone --quiet --depth 1 "${overrideUrl}" "${dest}"`);
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
if (overrideRef) {
|
|
117
|
+
try {
|
|
118
|
+
await cloneWithRefAsync(AW_ECC_REPO_SSH, overrideRef, dest);
|
|
119
|
+
} catch {
|
|
120
|
+
await cloneWithRefAsync(AW_ECC_REPO_HTTPS, overrideRef, dest);
|
|
121
|
+
}
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
await runA(`git clone --quiet --depth 1 --branch ${tag} ${AW_ECC_REPO_SSH} ${dest}`);
|
|
126
|
+
} catch {
|
|
127
|
+
await runA(`git clone --quiet --depth 1 --branch ${tag} ${AW_ECC_REPO_HTTPS} ${dest}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
66
132
|
function readIfExists(path) {
|
|
67
133
|
try { return existsSync(path) ? readFileSync(path, "utf8") : null; } catch { return null; }
|
|
68
134
|
}
|
|
@@ -133,6 +199,8 @@ function cloneOrUpdate(tag, dest) {
|
|
|
133
199
|
}
|
|
134
200
|
fetchOverrideRef(dest, overrideRef || tag);
|
|
135
201
|
}
|
|
202
|
+
// Restore working tree — pruneStaleHooks may have deleted tracked files
|
|
203
|
+
run(`git -C ${dest} checkout -- .`);
|
|
136
204
|
return;
|
|
137
205
|
} catch { /* fall through to fresh clone */ }
|
|
138
206
|
}
|
|
@@ -259,13 +327,16 @@ export async function installAwEcc(
|
|
|
259
327
|
{ targets = ["cursor", "claude", "codex"], silent = false } = {},
|
|
260
328
|
) {
|
|
261
329
|
if (process.env.AW_NO_ECC === '1') return;
|
|
262
|
-
if (!silent) fmt.logStep("Installing aw-ecc engine...");
|
|
263
330
|
|
|
264
331
|
const repoDir = eccDir();
|
|
265
332
|
const home = homedir();
|
|
266
333
|
|
|
334
|
+
const eccSpinner = silent ? null : fmt.spinner();
|
|
335
|
+
|
|
267
336
|
try {
|
|
268
|
-
|
|
337
|
+
if (eccSpinner) eccSpinner.start('Cloning aw-ecc engine...');
|
|
338
|
+
await cloneOrUpdateAsync(AW_ECC_TAG, repoDir);
|
|
339
|
+
if (eccSpinner) eccSpinner.message('Installing aw-ecc dependencies...');
|
|
269
340
|
|
|
270
341
|
// Claude Code: plugin install via marketplace CLI (proper agent dispatch)
|
|
271
342
|
if (targets.includes("claude")) {
|
|
@@ -279,7 +350,7 @@ export async function installAwEcc(
|
|
|
279
350
|
// Claude + Cursor + Codex: file-copy via install-apply.js
|
|
280
351
|
const fileCopyTargets = targets.filter((t) => FILE_COPY_TARGETS.includes(t));
|
|
281
352
|
if (fileCopyTargets.length > 0) {
|
|
282
|
-
|
|
353
|
+
await runA("npm install --no-audit --no-fund --ignore-scripts --loglevel=error", {
|
|
283
354
|
cwd: repoDir,
|
|
284
355
|
});
|
|
285
356
|
// generate-aw-hooks.js produces hooks.json and hook script sources that
|
|
@@ -291,15 +362,14 @@ export async function installAwEcc(
|
|
|
291
362
|
run(`node "${generateHooksScript}"`, { cwd: repoDir });
|
|
292
363
|
} catch { /* best effort — older engine versions may not have this script */ }
|
|
293
364
|
}
|
|
294
|
-
|
|
365
|
+
if (eccSpinner) eccSpinner.message('Applying aw-ecc to IDE targets...');
|
|
366
|
+
// Each target writes to disjoint paths (~/.claude/, ~/.cursor/, ~/.codex/) — safe to parallelize.
|
|
367
|
+
await Promise.all(fileCopyTargets.map(async (target) => {
|
|
295
368
|
try {
|
|
296
369
|
const snapshot = snapshotProtectedConfigs(home, target);
|
|
297
370
|
|
|
298
371
|
// Always use HOME as cwd so files land in ~/.<target>/ globally.
|
|
299
372
|
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
373
|
const installArgs = target === "claude"
|
|
304
374
|
? `--target ${target} --modules ${CLAUDE_FILE_COPY_MODULES.join(",")}`
|
|
305
375
|
: `--target ${target} --profile full`;
|
|
@@ -307,34 +377,24 @@ export async function installAwEcc(
|
|
|
307
377
|
`node ${join(repoDir, "scripts/install-apply.js")} ${installArgs}`,
|
|
308
378
|
{ cwd: runCwd },
|
|
309
379
|
);
|
|
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
380
|
if (target === "cursor") {
|
|
313
381
|
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
382
|
transformCursorAwRefs(home);
|
|
318
383
|
}
|
|
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
384
|
if (target === "codex") {
|
|
323
385
|
syncEccToCodex(repoDir);
|
|
324
386
|
}
|
|
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
387
|
restoreProtectedConfigs(snapshot);
|
|
329
388
|
} catch { /* target not supported — skip */ }
|
|
330
|
-
}
|
|
389
|
+
}));
|
|
331
390
|
}
|
|
332
391
|
|
|
333
392
|
applyStoredStartupPreferences();
|
|
334
393
|
|
|
335
|
-
if (
|
|
394
|
+
if (eccSpinner) eccSpinner.stop('aw-ecc engine installed');
|
|
336
395
|
} catch (err) {
|
|
337
|
-
if (
|
|
396
|
+
if (eccSpinner) eccSpinner.stop(chalk.yellow(`aw-ecc install failed: ${err.message}`));
|
|
397
|
+
else if (!silent) fmt.logWarn(`aw-ecc install failed: ${err.message}`);
|
|
338
398
|
}
|
|
339
399
|
}
|
|
340
400
|
|