@ghl-ai/aw 0.1.36-beta.8 → 0.1.36-beta.81

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/cli.mjs CHANGED
@@ -65,7 +65,8 @@ function parseArgs(argv) {
65
65
 
66
66
  function printHelp() {
67
67
  fmt.banner('aw', {
68
- subtitle: ` ${chalk.dim('v' + VERSION)} ${chalk.hex('#FF6B35')('Agentic Workspace CLI')} ${chalk.dim('— pull, push & manage agents, skills and more from the registry')}`,
68
+ icon: '',
69
+ subtitle: ` ${chalk.hex('#FF6B35')('⟁')} ${chalk.dim('v' + VERSION)} ${chalk.hex('#FF6B35')('Agentic Workspace CLI')} ${chalk.dim('— pull, push & manage agents, skills and more from the registry')}`,
69
70
  });
70
71
 
71
72
  const cmd = (c, d) => ` ${chalk.hex('#FF6B35')(c.padEnd(38))} ${chalk.dim(d)}`;
@@ -92,6 +93,7 @@ function printHelp() {
92
93
 
93
94
  sec('Manage'),
94
95
  cmd('aw status', 'Show synced paths, modified files & conflicts'),
96
+ cmd('aw link', 'Link current project as a git worktree (wires IDE symlinks)'),
95
97
  cmd('aw drop <path>', 'Stop syncing or delete local content'),
96
98
  cmd('aw nuke', 'Remove entire .aw_registry/ & start fresh'),
97
99
  cmd('aw daemon install', 'Auto-pull on a schedule (macOS launchd / Linux cron)'),
@@ -2,12 +2,15 @@
2
2
  // that silently runs `aw pull` on a schedule without any user interaction.
3
3
 
4
4
  import { existsSync, writeFileSync, readFileSync, mkdirSync, unlinkSync } from 'node:fs';
5
- import { execSync } from 'node:child_process';
5
+ import { execSync, exec as execCb } from 'node:child_process';
6
+ import { promisify } from 'node:util';
6
7
  import { join } from 'node:path';
7
8
  import { homedir, platform } from 'node:os';
8
9
  import * as fmt from '../fmt.mjs';
9
10
  import { chalk } from '../fmt.mjs';
10
11
 
12
+ const exec = promisify(execCb);
13
+
11
14
  const LABEL = 'ai.ghl.aw.pull';
12
15
  const PLIST_PATH = join(homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`);
13
16
  const DEFAULT_INTERVAL = 3600; // 1 hour in seconds
@@ -22,7 +25,7 @@ function getAwBin() {
22
25
 
23
26
  // ── macOS: launchd ──────────────────────────────────────────────────────────
24
27
 
25
- function installLaunchd(intervalSeconds) {
28
+ async function installLaunchd(intervalSeconds) {
26
29
  const awBin = getAwBin();
27
30
  const logDir = join(homedir(), '.aw_registry', 'logs');
28
31
  if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
@@ -63,22 +66,24 @@ function installLaunchd(intervalSeconds) {
63
66
 
64
67
  writeFileSync(PLIST_PATH, plist);
65
68
 
66
- // Unload first if already loaded (to apply new interval)
67
- try { execSync(`launchctl unload "${PLIST_PATH}" 2>/dev/null`); } catch { /* not loaded */ }
68
- execSync(`launchctl load "${PLIST_PATH}"`);
69
-
70
- fmt.logStep(`Daemon installed — runs every ${formatInterval(intervalSeconds)}`);
71
- fmt.logStep(`Logs: ${chalk.dim(logDir + '/pull.log')}`);
69
+ const s = fmt.spinner();
70
+ s.start('Installing daemon...');
71
+ try { await exec(`launchctl unload "${PLIST_PATH}" 2>/dev/null`); } catch { /* not loaded */ }
72
+ await exec(`launchctl load "${PLIST_PATH}"`);
73
+ s.stop(`Daemon installed — runs every ${formatInterval(intervalSeconds)}`);
74
+ fmt.logInfo(`Logs: ${chalk.dim(logDir + '/pull.log')}`);
72
75
  }
73
76
 
74
- function uninstallLaunchd() {
77
+ async function uninstallLaunchd() {
75
78
  if (!existsSync(PLIST_PATH)) {
76
79
  fmt.logStep('No daemon installed.');
77
80
  return;
78
81
  }
79
- try { execSync(`launchctl unload "${PLIST_PATH}"`); } catch { /* ignore */ }
82
+ const s = fmt.spinner();
83
+ s.start('Removing daemon...');
84
+ try { await exec(`launchctl unload "${PLIST_PATH}"`); } catch { /* ignore */ }
80
85
  unlinkSync(PLIST_PATH);
81
- fmt.logStep('Daemon removed.');
86
+ s.stop('Daemon removed.');
82
87
  }
83
88
 
84
89
  function statusLaunchd() {
@@ -102,7 +107,7 @@ function toCronExpression(intervalSeconds) {
102
107
  return `0 */${hours} * * *`;
103
108
  }
104
109
 
105
- function installCron(intervalSeconds) {
110
+ async function installCron(intervalSeconds) {
106
111
  const awBin = getAwBin();
107
112
  const logDir = join(homedir(), '.aw_registry', 'logs');
108
113
  if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
@@ -117,17 +122,21 @@ function installCron(intervalSeconds) {
117
122
  const cleaned = current.split('\n').filter(l => !l.includes('# aw-daemon')).join('\n');
118
123
  const updated = cleaned.trimEnd() + '\n' + cronLine + '\n';
119
124
 
120
- execSync(`echo ${JSON.stringify(updated)} | crontab -`);
121
- fmt.logStep(`Cron job installed — runs every ${formatInterval(intervalSeconds)}`);
122
- fmt.logStep(`Logs: ${chalk.dim(logDir + '/pull.log')}`);
125
+ const s = fmt.spinner();
126
+ s.start('Installing cron job...');
127
+ await exec(`echo ${JSON.stringify(updated)} | crontab -`);
128
+ s.stop(`Cron job installed — runs every ${formatInterval(intervalSeconds)}`);
129
+ fmt.logInfo(`Logs: ${chalk.dim(logDir + '/pull.log')}`);
123
130
  }
124
131
 
125
- function uninstallCron() {
132
+ async function uninstallCron() {
126
133
  let current = '';
127
134
  try { current = execSync('crontab -l 2>/dev/null', { encoding: 'utf8' }); } catch { return; }
128
135
  const cleaned = current.split('\n').filter(l => !l.includes('# aw-daemon')).join('\n');
129
- execSync(`echo ${JSON.stringify(cleaned)} | crontab -`);
130
- fmt.logStep('Cron job removed.');
136
+ const s = fmt.spinner();
137
+ s.start('Removing cron job...');
138
+ await exec(`echo ${JSON.stringify(cleaned)} | crontab -`);
139
+ s.stop('Cron job removed.');
131
140
  }
132
141
 
133
142
  // ── Helpers ─────────────────────────────────────────────────────────────────
@@ -148,7 +157,7 @@ function parseInterval(str) {
148
157
 
149
158
  // ── Main ─────────────────────────────────────────────────────────────────────
150
159
 
151
- export function daemonCommand(args) {
160
+ export async function daemonCommand(args) {
152
161
  const subcommand = args._positional[0] || 'install';
153
162
  const interval = parseInterval(args['--interval'] || args._positional[1]);
154
163
  const isMac = platform() === 'darwin';
@@ -158,13 +167,13 @@ export function daemonCommand(args) {
158
167
  if (subcommand === 'install') {
159
168
  fmt.logStep(`Platform: ${isMac ? 'macOS (launchd)' : 'Linux (cron)'}`);
160
169
  fmt.logStep(`Interval: every ${formatInterval(interval)}`);
161
- if (isMac) installLaunchd(interval);
162
- else installCron(interval);
170
+ if (isMac) await installLaunchd(interval);
171
+ else await installCron(interval);
163
172
  fmt.outro(`aw pull will run silently every ${formatInterval(interval)}`);
164
173
 
165
174
  } else if (subcommand === 'uninstall' || subcommand === 'stop') {
166
- if (isMac) uninstallLaunchd();
167
- else uninstallCron();
175
+ if (isMac) await uninstallLaunchd();
176
+ else await uninstallCron();
168
177
  fmt.outro('Daemon stopped.');
169
178
 
170
179
  } else if (subcommand === 'status') {
package/commands/drop.mjs CHANGED
@@ -80,7 +80,7 @@ export function dropCommand(args) {
80
80
  }
81
81
 
82
82
  // Re-link to remove dead symlinks
83
- linkWorkspace(HOME);
83
+ linkWorkspace(HOME, null, { silent: true });
84
84
 
85
- fmt.outro('Done');
85
+ fmt.outro(`⟁ Dropped ${chalk.cyan(regPath)}`);
86
86
  }
package/commands/init.mjs CHANGED
@@ -4,12 +4,11 @@
4
4
  // Uses core.hooksPath (git-lfs pattern) for system-wide hook interception.
5
5
  // Uses IDE tasks for auto-pull on workspace open.
6
6
 
7
- import { existsSync, writeFileSync, symlinkSync, lstatSync, readdirSync } from 'node:fs';
7
+ import { existsSync, writeFileSync, symlinkSync, lstatSync, readdirSync, readFileSync, rmSync, realpathSync, appendFileSync } from 'node:fs';
8
8
  import { execSync } from 'node:child_process';
9
9
  import { join, dirname, sep } from 'node:path';
10
10
  import { homedir } from 'node:os';
11
11
  import { fileURLToPath } from 'node:url';
12
- import { readFileSync } from 'node:fs';
13
12
  import * as config from '../config.mjs';
14
13
  import * as fmt from '../fmt.mjs';
15
14
  import { chalk } from '../fmt.mjs';
@@ -29,16 +28,43 @@ import {
29
28
  includeToSparsePaths,
30
29
  sparseCheckoutAsync,
31
30
  cleanup,
31
+ syncWorktreeSparseCheckout,
32
+ findNearestWorktree,
32
33
  } from '../git.mjs';
33
- import { REGISTRY_DIR, REGISTRY_REPO } from '../constants.mjs';
34
+ import { REGISTRY_DIR, REGISTRY_REPO, REGISTRY_URL } from '../constants.mjs';
34
35
 
35
36
  const __dirname = dirname(fileURLToPath(import.meta.url));
36
37
  const VERSION = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')).version;
37
38
 
38
- const HOME = homedir();
39
+ // Resolve HOME to the real path — on macOS /var is a symlink to /private/var,
40
+ // so homedir() returns /var/... while process.cwd() returns /private/var/...
41
+ // Without normalization, the `cwd !== HOME` guard would fail and HOME would be
42
+ // treated as a project directory, causing addProjectWorktree to delete ~/.aw.
43
+ const _rawHome = homedir();
44
+ const HOME = (() => { try { return realpathSync(_rawHome); } catch { return _rawHome; } })();
39
45
  const GLOBAL_AW_DIR = join(HOME, '.aw_registry');
40
46
  const AW_HOME = join(HOME, '.aw');
41
47
 
48
+ // ── Ensure ~/.aw/.gitignore has personal/local entries ───────────────────
49
+
50
+ const AW_GITIGNORE_ENTRIES = [
51
+ '.aw_registry/.sync-config.json',
52
+ 'hooks/',
53
+ ];
54
+
55
+ function ensureAwGitignore(awHome) {
56
+ // Use .git/info/exclude so the tracked .gitignore stays clean
57
+ const excludePath = join(awHome, '.git', 'info', 'exclude');
58
+ let existing = '';
59
+ try { existing = readFileSync(excludePath, 'utf8'); } catch { /* doesn't exist yet */ }
60
+ const missing = AW_GITIGNORE_ENTRIES.filter(e => !existing.includes(e));
61
+ if (missing.length === 0) return;
62
+ const block = (existing.endsWith('\n') || existing === '' ? '' : '\n')
63
+ + '# aw: personal/local — do not commit\n'
64
+ + missing.join('\n') + '\n';
65
+ try { appendFileSync(excludePath, block); } catch { /* best effort */ }
66
+ }
67
+
42
68
  // ── IDE tasks for auto-pull ─────────────────────────────────────────────
43
69
 
44
70
  function installIdeTasks() {
@@ -91,7 +117,6 @@ export async function initCommand(args) {
91
117
  let nsParts = namespace ? namespace.split('/') : [];
92
118
  let team = nsParts[0] || null;
93
119
  let subTeam = nsParts[1] || null;
94
- let teamNS = subTeam ? `${team}-${subTeam}` : team;
95
120
  let folderName = subTeam ? `${team}/${subTeam}` : team;
96
121
 
97
122
  if (team && !ALLOWED_NAMESPACES.includes(team)) {
@@ -125,7 +150,7 @@ export async function initCommand(args) {
125
150
 
126
151
  // ── Detect installation state ─────────────────────────────────────────
127
152
 
128
- const repoUrl = `https://github.com/${REGISTRY_REPO}.git`;
153
+ const repoUrl = REGISTRY_URL;
129
154
  const isGitNative = isValidClone(AW_HOME, repoUrl);
130
155
  const isLegacy = !isGitNative && existsSync(GLOBAL_AW_DIR) && !lstatSync(GLOBAL_AW_DIR).isSymbolicLink();
131
156
 
@@ -133,6 +158,8 @@ export async function initCommand(args) {
133
158
 
134
159
  let namespaceExistsInRemote = false;
135
160
  if (folderName && !silent && !isGitNative && !isLegacy) {
161
+ const probeSpinner = fmt.spinner();
162
+ probeSpinner.start(`Checking namespace ${chalk.cyan(folderName)} in registry...`);
136
163
  try {
137
164
  const probePaths = includeToSparsePaths([folderName]);
138
165
  const probeDir = await sparseCheckoutAsync(REGISTRY_REPO, probePaths);
@@ -144,7 +171,11 @@ export async function initCommand(args) {
144
171
  } finally {
145
172
  cleanup(probeDir);
146
173
  }
174
+ probeSpinner.stop(namespaceExistsInRemote
175
+ ? `Namespace ${chalk.cyan(folderName)} found in registry`
176
+ : `Namespace ${chalk.cyan(folderName)} not yet in registry`);
147
177
  } catch {
178
+ probeSpinner.stop(chalk.dim('Could not verify namespace (continuing)'));
148
179
  namespaceExistsInRemote = true;
149
180
  }
150
181
  }
@@ -164,11 +195,12 @@ export async function initCommand(args) {
164
195
  }
165
196
 
166
197
  if (choice === 'platform-only') {
167
- namespace = null; team = null; subTeam = null; teamNS = null; folderName = null;
198
+ namespace = null; team = null; subTeam = null; folderName = null;
168
199
  }
169
200
  }
170
201
 
171
- const cwd = process.cwd();
202
+ const _rawCwd = process.cwd();
203
+ const cwd = (() => { try { return realpathSync(_rawCwd); } catch { return _rawCwd; } })();
172
204
 
173
205
  // ── Re-init path: already set up with native git clone ────────────────
174
206
 
@@ -185,29 +217,52 @@ export async function initCommand(args) {
185
217
  if (!silent) fmt.logStep('Already initialized — syncing...');
186
218
  }
187
219
 
188
- const s = fmt.spinner ? fmt.spinner() : { start: () => {}, stop: () => {} };
189
- if (!silent) s.start('Fetching latest...');
220
+ const s = fmt.spinner();
221
+ if (!silent) s.start('Fetching latest from registry...');
190
222
  try {
191
- fetchAndMerge(AW_HOME);
192
- if (!silent) s.stop('Registry updated');
223
+ await fetchAndMerge(AW_HOME);
224
+ if (!silent) s.stop('Registry up to date');
193
225
  } catch (e) {
194
- if (!silent) s.stop(chalk.yellow('Fetch failed (continuing with local)'));
226
+ if (!silent) s.stop(chalk.yellow('Fetch failed continuing with local cache'));
195
227
  }
196
228
 
229
+ ensureAwGitignore(AW_HOME);
197
230
  const freshCfg = config.load(GLOBAL_AW_DIR);
198
231
 
232
+ // Ensure project worktree sparse checkout matches the global clone.
233
+ // Covers the case where a namespace was added from HOME (or another project)
234
+ // and this project's .aw/ hasn't been updated yet.
235
+ const localAw = cwd !== HOME ? findNearestWorktree(cwd, HOME) : null;
236
+ if (localAw) {
237
+ try { syncWorktreeSparseCheckout(AW_HOME, localAw); } catch { /* best effort */ }
238
+ }
239
+
199
240
  await installAwEcc(cwd, { silent });
200
241
  copyInstructions(HOME, null, freshCfg?.namespace || team) || [];
201
242
  initAwDocs(HOME);
202
243
  await setupMcp(HOME, freshCfg?.namespace || team, { silent });
203
- if (cwd !== HOME) await setupMcp(cwd, freshCfg?.namespace || team, { silent });
204
244
  installGlobalHooks();
205
245
 
246
+ // Remove old local .git/hooks/post-checkout that pre-dates core.hooksPath (creates stale .aw_registry symlink)
247
+ if (cwd !== HOME) {
248
+ const oldLocalHook = join(cwd, '.git', 'hooks', 'post-checkout');
249
+ try {
250
+ const content = readFileSync(oldLocalHook, 'utf8');
251
+ if (content.includes('aw: auto-link registry') || content.includes('ln -s "$AW_REGISTRY"')) {
252
+ rmSync(oldLocalHook);
253
+ if (!silent) fmt.logStep('Removed legacy .git/hooks/post-checkout');
254
+ }
255
+ } catch { /* not there, fine */ }
256
+ }
257
+
206
258
  const isInsideAw = cwd.endsWith(`${sep}.aw`) || cwd.includes(`${sep}.aw${sep}`);
207
- if (cwd !== HOME && !isInsideAw && !isWorktree(join(cwd, '.aw'))) {
259
+ // Only skip if already a valid symlink (new model). Old git worktrees must be migrated.
260
+ const awLink = join(cwd, '.aw');
261
+ const isAlreadySymlink = (() => { try { return lstatSync(awLink).isSymbolicLink() && existsSync(awLink); } catch { return false; } })();
262
+ if (cwd !== HOME && !isInsideAw && !isAlreadySymlink) {
208
263
  try {
209
264
  addProjectWorktree(AW_HOME, cwd);
210
- if (!silent) fmt.logStep('Linked current project as git worktree');
265
+ if (!silent) fmt.logStep('Linked current project');
211
266
  } catch { /* best effort */ }
212
267
  }
213
268
 
@@ -215,18 +270,18 @@ export async function initCommand(args) {
215
270
  // so edits to project/.aw/.aw_registry/ are instantly visible in global IDE dirs.
216
271
  const projectRegistryDir = cwd !== HOME ? join(cwd, '.aw', REGISTRY_DIR) : null;
217
272
  const awDirForLinks = (projectRegistryDir && existsSync(projectRegistryDir)) ? projectRegistryDir : null;
218
- linkWorkspace(HOME, awDirForLinks);
219
- generateCommands(HOME);
273
+ const symlinks = linkWorkspace(HOME, awDirForLinks, { silent: true });
274
+ const commands = generateCommands(HOME, { silent: true });
220
275
 
221
276
  if (silent) {
222
277
  autoUpdate(await args._updateCheck);
223
278
  } else {
224
279
  fmt.outro([
225
- isNewSubTeam ? `Sub-team ${chalk.cyan(folderName)} added` : 'Sync complete',
280
+ `⟁ ${isNewSubTeam ? `Sub-team ${chalk.cyan(folderName)} added` : 'Up to date'}`,
226
281
  '',
227
- ` ${chalk.green('✓')} Registry updated`,
228
- ` ${chalk.green('✓')} IDE integration refreshed`,
229
- cwd !== HOME && isWorktree(join(cwd, '.aw')) ? ` ${chalk.green('✓')} Current project linked` : null,
282
+ ` ${chalk.green('✓')} Registry synced`,
283
+ ` ${chalk.green('✓')} IDE refreshed — ${chalk.bold(symlinks)} symlinks · ${chalk.bold(commands)} commands`,
284
+ cwd !== HOME && isWorktree(join(cwd, '.aw')) ? ` ${chalk.green('✓')} Project linked` : null,
230
285
  ].filter(Boolean).join('\n'));
231
286
  }
232
287
  return;
@@ -271,7 +326,8 @@ export async function initCommand(args) {
271
326
  s.start(`Cloning registry...`);
272
327
 
273
328
  try {
274
- initPersistentClone(repoUrl, AW_HOME, sparsePaths);
329
+ await initPersistentClone(repoUrl, AW_HOME, sparsePaths);
330
+ ensureAwGitignore(AW_HOME);
275
331
  s.stop('Registry cloned');
276
332
  } catch (e) {
277
333
  s.stop(chalk.red('Clone failed'));
@@ -279,13 +335,25 @@ export async function initCommand(args) {
279
335
  }
280
336
 
281
337
  // Create backward-compat symlink: ~/.aw_registry/ → ~/.aw/.aw_registry/
282
- if (!existsSync(GLOBAL_AW_DIR)) {
338
+ // Use lstatSync (not existsSync) so we handle dangling symlinks correctly.
339
+ let awRegistryLstat = null;
340
+ try { awRegistryLstat = lstatSync(GLOBAL_AW_DIR); } catch { /* doesn't exist */ }
341
+ if (!awRegistryLstat) {
283
342
  try {
284
343
  symlinkSync(join(AW_HOME, REGISTRY_DIR), GLOBAL_AW_DIR);
285
344
  fmt.logStep('Created ~/.aw_registry/ symlink');
286
345
  } catch (e) {
287
346
  fmt.logWarn(`Could not create symlink ~/.aw_registry/: ${e.message}`);
288
347
  }
348
+ } else if (awRegistryLstat.isSymbolicLink()) {
349
+ // Stale or dangling — re-point to the new clone
350
+ try {
351
+ rmSync(GLOBAL_AW_DIR);
352
+ symlinkSync(join(AW_HOME, REGISTRY_DIR), GLOBAL_AW_DIR);
353
+ fmt.logStep('Updated ~/.aw_registry/ symlink');
354
+ } catch (e) {
355
+ fmt.logWarn(`Could not update symlink ~/.aw_registry/: ${e.message}`);
356
+ }
289
357
  }
290
358
 
291
359
  // Create sync config
@@ -298,33 +366,48 @@ export async function initCommand(args) {
298
366
  await installAwEcc(cwd, { silent });
299
367
  const instructionFiles = copyInstructions(HOME, null, team) || [];
300
368
  initAwDocs(HOME);
301
- const mcpFiles = await setupMcp(HOME, team) || [];
302
- if (cwd !== HOME) await setupMcp(cwd, team);
369
+ const mcpFiles = await setupMcp(HOME, team, { silent }) || [];
303
370
  const hooksInstalled = installGlobalHooks();
304
371
  installIdeTasks();
305
372
 
306
- // Step 4: Link current project as a git worktree (gives IDE git panel)
373
+ // Remove old local .git/hooks/post-checkout that pre-dates core.hooksPath
374
+ if (cwd !== HOME) {
375
+ const oldLocalHook = join(cwd, '.git', 'hooks', 'post-checkout');
376
+ try {
377
+ const content = readFileSync(oldLocalHook, 'utf8');
378
+ if (content.includes('aw: auto-link registry') || content.includes('ln -s "$AW_REGISTRY"')) {
379
+ rmSync(oldLocalHook);
380
+ }
381
+ } catch { /* not there, fine */ }
382
+ }
383
+
384
+ // Step 4: Link current project as a symlink to ~/.aw (gives IDE git panel, shared across all workspaces)
307
385
  const isInsideAw = cwd.endsWith(`${sep}.aw`) || cwd.includes(`${sep}.aw${sep}`);
308
- if (cwd !== HOME && !isInsideAw && !isWorktree(join(cwd, '.aw'))) {
386
+ const awLinkFresh = join(cwd, '.aw');
387
+ const isAlreadySymlinkFresh = (() => { try { return lstatSync(awLinkFresh).isSymbolicLink() && existsSync(awLinkFresh); } catch { return false; } })();
388
+ if (cwd !== HOME && !isInsideAw && !isAlreadySymlinkFresh) {
309
389
  try {
310
390
  addProjectWorktree(AW_HOME, cwd);
311
- fmt.logStep('Linked current project as git worktree');
391
+ fmt.logStep('Linked current project');
312
392
  } catch { /* best effort */ }
313
393
  }
314
394
 
315
395
  // Step 5: Wire ~/.claude/.cursor/.codex to the project's registry when in a project,
316
396
  // so edits to project/.aw/.aw_registry/ are instantly visible in global IDE dirs.
317
- fmt.logStep('Linking IDE symlinks...');
397
+ const ideSpinner = fmt.spinner();
398
+ ideSpinner.start('Wiring IDE symlinks...');
318
399
  const projectRegistryDir = cwd !== HOME ? join(cwd, '.aw', REGISTRY_DIR) : null;
319
400
  const awDirForLinks = (projectRegistryDir && existsSync(projectRegistryDir)) ? projectRegistryDir : null;
320
- linkWorkspace(HOME, awDirForLinks);
321
- generateCommands(HOME);
401
+ const symlinks = linkWorkspace(HOME, awDirForLinks, { silent: true });
402
+ ideSpinner.message('Generating commands...');
403
+ const commands = generateCommands(HOME, { silent: true });
404
+ ideSpinner.stop(`IDE wired — ${chalk.bold(symlinks)} symlinks · ${chalk.bold(commands)} commands`);
322
405
 
323
406
  // Offer to update if a newer version is available
324
407
  await promptUpdate(await args._updateCheck);
325
408
 
326
409
  fmt.outro([
327
- 'Install complete',
410
+ 'Install complete',
328
411
  '',
329
412
  ` ${chalk.green('✓')} Source of truth: ~/.aw/ (git clone)`,
330
413
  ` ${chalk.green('✓')} Symlink: ~/.aw_registry/ → ~/.aw/.aw_registry/`,
@@ -6,7 +6,7 @@ import { homedir } from 'node:os';
6
6
  import * as fmt from '../fmt.mjs';
7
7
  import { chalk } from '../fmt.mjs';
8
8
  import { addProjectWorktree, isWorktree, isValidClone } from '../git.mjs';
9
- import { REGISTRY_DIR, REGISTRY_REPO } from '../constants.mjs';
9
+ import { REGISTRY_DIR, REGISTRY_URL } from '../constants.mjs';
10
10
  import { linkWorkspace } from '../link.mjs';
11
11
  import { generateCommands } from '../integrate.mjs';
12
12
 
@@ -18,8 +18,7 @@ export function linkProjectCommand(args) {
18
18
 
19
19
  fmt.intro('aw link');
20
20
 
21
- const repoUrl = `https://github.com/${REGISTRY_REPO}.git`;
22
- if (!isValidClone(AW_HOME, repoUrl)) {
21
+ if (!isValidClone(AW_HOME, REGISTRY_URL)) {
23
22
  fmt.cancel('Registry not initialized. Run: aw init');
24
23
  return;
25
24
  }
@@ -39,9 +38,9 @@ export function linkProjectCommand(args) {
39
38
  // Worktree exists — refresh global IDE symlinks pointing to this project's registry
40
39
  const projectRegistryDir = join(worktreeDir, REGISTRY_DIR);
41
40
  const awDirForLinks = existsSync(projectRegistryDir) ? projectRegistryDir : null;
42
- linkWorkspace(HOME, awDirForLinks);
43
- generateCommands(HOME);
44
- fmt.logSuccess(`Already linked — refreshed IDE symlinks`);
41
+ const symlinks = linkWorkspace(HOME, awDirForLinks, { silent: true });
42
+ const commands = generateCommands(HOME, { silent: true });
43
+ fmt.logSuccess(`Already linked — refreshed ${chalk.bold(symlinks)} IDE symlinks · ${chalk.bold(commands)} commands`);
45
44
  return;
46
45
  }
47
46
 
@@ -49,10 +48,10 @@ export function linkProjectCommand(args) {
49
48
  addProjectWorktree(AW_HOME, cwd);
50
49
  const projectRegistryDir = join(worktreeDir, REGISTRY_DIR);
51
50
  const awDirForLinks = existsSync(projectRegistryDir) ? projectRegistryDir : null;
52
- linkWorkspace(HOME, awDirForLinks);
53
- generateCommands(HOME);
51
+ const symlinks = linkWorkspace(HOME, awDirForLinks, { silent: true });
52
+ const commands = generateCommands(HOME, { silent: true });
54
53
  fmt.logSuccess([
55
- `Linked project as git worktree`,
54
+ `Project linked ${chalk.bold(symlinks)} IDE symlinks · ${chalk.bold(commands)} commands`,
56
55
  '',
57
56
  ` ${chalk.green('✓')} ${chalk.dim('.aw/')} git worktree (IDE git panel enabled)`,
58
57
  ` ${chalk.green('✓')} ${chalk.dim(`.aw/${REGISTRY_DIR}/`)} registry content`,
@@ -62,5 +61,5 @@ export function linkProjectCommand(args) {
62
61
  fmt.cancel(`Failed to link project: ${e.message}`);
63
62
  }
64
63
 
65
- fmt.outro('Done');
64
+ fmt.outro('Done');
66
65
  }