@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 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 { installAwEcc } from '../ecc.mjs';
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/.gitignore has personal/local entries ───────────────────
92
-
93
- const AW_GITIGNORE_ENTRIES = [
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 ensureAwGitignore(awHome) {
99
- // Use .git/info/exclude so the tracked .gitignore stays clean
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
- const missing = AW_GITIGNORE_ENTRIES.filter(e => !existing.includes(e));
104
- if (missing.length === 0) return;
105
- const block = (existing.endsWith('\n') || existing === '' ? '' : '\n')
106
- + '# aw: personal/local — do not commit\n'
107
- + missing.join('\n') + '\n';
108
- try { appendFileSync(excludePath, block); } catch { /* best effort */ }
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(`Added auto-pull task to ${ide}`);
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(`Added auto-pull task to ${ide}`);
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('Created ~/.aw_registry/ symlink');
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('Updated ~/.aw_registry/ symlink');
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
- syncHomeAndProjectInstructions(cwd, team);
435
- const mcpFiles = await setupMcp(HOME, team, { silent }) || [];
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
- const ideSpinner = fmt.spinner();
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
- const symlinks = linkWorkspace(HOME, awDirForLinks, { silent: true });
544
+ // Parallel batch C: symlinks + commands are independent
470
545
  if (cwd !== HOME) installLocalCommitHook(cwd);
471
- ideSpinner.message('Generating commands...');
472
- const commands = generateCommands(HOME, { silent: true });
473
- ideSpinner.stop(`IDE wired — ${chalk.bold(symlinks)} symlinks · ${chalk.bold(commands)} commands`);
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
- // Use find command for thorough search.
137
- // Append "|| true" so find's exit code is always 0 (find exits 1 on permission-denied dirs).
138
- const { stdout: registryLinks } = await exec(
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
- // Also remove legacy local .git/hooks/post-checkout installed by old aw versions
151
- // and prepare-commit-msg hooks installed by installLocalCommitHook
152
- let hooksRemoved = 0;
153
- const hookNames = ['post-checkout', 'prepare-commit-msg'];
154
- for (const hookName of hookNames) {
155
- const { stdout: hookFiles } = await exec(
156
- `find "${HOME}" -maxdepth 5 -path "*/.git/hooks/${hookName}" -type f 2>/dev/null || true`,
157
- { encoding: 'utf8', timeout: 30000 }
158
- );
159
- for (const hookPath of hookFiles.trim().split('\n').filter(Boolean)) {
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(hookPath);
156
+ unlinkSync(itemPath);
165
157
  hooksRemoved++;
166
158
  }
167
- } catch { /* best effort */ }
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. Remove new-style .aw symlinks pointing to ~/.aw
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: awLinks } = await exec(
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 linkPath of awLinks.trim().split('\n').filter(Boolean)) {
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 target = readlinkSync(linkPath);
307
- if (target === AW_HOME || target.endsWith('/.aw')) {
308
- unlinkSync(linkPath);
309
- wtRemoved++;
310
- }
311
- } catch { /* best effort */ }
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.38-beta.8";
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
- cloneOrUpdate(AW_ECC_TAG, repoDir);
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
- run("npm install --no-audit --no-fund --ignore-scripts --loglevel=error", {
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
- for (const target of fileCopyTargets) {
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 (!silent) fmt.logSuccess("aw-ecc engine installed");
394
+ if (eccSpinner) eccSpinner.stop('aw-ecc engine installed');
336
395
  } catch (err) {
337
- if (!silent) fmt.logWarn(`aw-ecc install failed: ${err.message}`);
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