@ghl-ai/aw 0.1.36-beta.12 → 0.1.36-beta.121

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,7 +4,7 @@
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, readFileSync, rmSync } 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';
@@ -28,16 +28,43 @@ import {
28
28
  includeToSparsePaths,
29
29
  sparseCheckoutAsync,
30
30
  cleanup,
31
+ syncWorktreeSparseCheckout,
32
+ findNearestWorktree,
31
33
  } from '../git.mjs';
32
- import { REGISTRY_DIR, REGISTRY_REPO } from '../constants.mjs';
34
+ import { REGISTRY_DIR, REGISTRY_REPO, REGISTRY_URL } from '../constants.mjs';
33
35
 
34
36
  const __dirname = dirname(fileURLToPath(import.meta.url));
35
37
  const VERSION = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')).version;
36
38
 
37
- 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; } })();
38
45
  const GLOBAL_AW_DIR = join(HOME, '.aw_registry');
39
46
  const AW_HOME = join(HOME, '.aw');
40
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
+
41
68
  // ── IDE tasks for auto-pull ─────────────────────────────────────────────
42
69
 
43
70
  function installIdeTasks() {
@@ -83,14 +110,13 @@ export async function initCommand(args) {
83
110
  let user = args['--user'] || '';
84
111
  const silent = args['--silent'] === true;
85
112
 
86
- fmt.intro(`aw init ${chalk.dim('v' + VERSION)}`);
113
+ if (!silent) fmt.intro(`aw init ${chalk.dim('v' + VERSION)}`);
87
114
 
88
115
  // ── Validate ──────────────────────────────────────────────────────────
89
116
 
90
117
  let nsParts = namespace ? namespace.split('/') : [];
91
118
  let team = nsParts[0] || null;
92
119
  let subTeam = nsParts[1] || null;
93
- let teamNS = subTeam ? `${team}-${subTeam}` : team;
94
120
  let folderName = subTeam ? `${team}/${subTeam}` : team;
95
121
 
96
122
  if (team && !ALLOWED_NAMESPACES.includes(team)) {
@@ -124,7 +150,7 @@ export async function initCommand(args) {
124
150
 
125
151
  // ── Detect installation state ─────────────────────────────────────────
126
152
 
127
- const repoUrl = `https://github.com/${REGISTRY_REPO}.git`;
153
+ const repoUrl = REGISTRY_URL;
128
154
  const isGitNative = isValidClone(AW_HOME, repoUrl);
129
155
  const isLegacy = !isGitNative && existsSync(GLOBAL_AW_DIR) && !lstatSync(GLOBAL_AW_DIR).isSymbolicLink();
130
156
 
@@ -132,6 +158,8 @@ export async function initCommand(args) {
132
158
 
133
159
  let namespaceExistsInRemote = false;
134
160
  if (folderName && !silent && !isGitNative && !isLegacy) {
161
+ const probeSpinner = fmt.spinner();
162
+ probeSpinner.start(`Checking namespace ${chalk.cyan(folderName)} in registry...`);
135
163
  try {
136
164
  const probePaths = includeToSparsePaths([folderName]);
137
165
  const probeDir = await sparseCheckoutAsync(REGISTRY_REPO, probePaths);
@@ -143,7 +171,11 @@ export async function initCommand(args) {
143
171
  } finally {
144
172
  cleanup(probeDir);
145
173
  }
174
+ probeSpinner.stop(namespaceExistsInRemote
175
+ ? `Namespace ${chalk.cyan(folderName)} found in registry`
176
+ : `Namespace ${chalk.cyan(folderName)} not yet in registry`);
146
177
  } catch {
178
+ probeSpinner.stop(chalk.dim('Could not verify namespace (continuing)'));
147
179
  namespaceExistsInRemote = true;
148
180
  }
149
181
  }
@@ -163,11 +195,12 @@ export async function initCommand(args) {
163
195
  }
164
196
 
165
197
  if (choice === 'platform-only') {
166
- namespace = null; team = null; subTeam = null; teamNS = null; folderName = null;
198
+ namespace = null; team = null; subTeam = null; folderName = null;
167
199
  }
168
200
  }
169
201
 
170
- const cwd = process.cwd();
202
+ const _rawCwd = process.cwd();
203
+ const cwd = (() => { try { return realpathSync(_rawCwd); } catch { return _rawCwd; } })();
171
204
 
172
205
  // ── Re-init path: already set up with native git clone ────────────────
173
206
 
@@ -184,22 +217,37 @@ export async function initCommand(args) {
184
217
  if (!silent) fmt.logStep('Already initialized — syncing...');
185
218
  }
186
219
 
187
- const s = fmt.spinner ? fmt.spinner() : { start: () => {}, stop: () => {} };
188
- if (!silent) s.start('Fetching latest...');
220
+ const s = fmt.spinner();
221
+ if (!silent) s.start('Fetching latest from registry...');
189
222
  try {
190
- fetchAndMerge(AW_HOME);
191
- if (!silent) s.stop('Registry updated');
223
+ const { conflicts } = await fetchAndMerge(AW_HOME, { silent });
224
+ if (!silent) {
225
+ if (conflicts.length > 0) {
226
+ s.stop(chalk.yellow(`Conflicts in ${conflicts.length} file${conflicts.length > 1 ? 's' : ''} — resolve then run aw init again`));
227
+ fmt.logWarn(conflicts.map(f => ` • ${f}`).join('\n'));
228
+ } else {
229
+ s.stop('Registry up to date');
230
+ }
231
+ }
192
232
  } catch (e) {
193
- if (!silent) s.stop(chalk.yellow('Fetch failed (continuing with local)'));
233
+ if (!silent) s.stop(chalk.yellow('Fetch failed continuing with local cache'));
194
234
  }
195
235
 
236
+ ensureAwGitignore(AW_HOME);
196
237
  const freshCfg = config.load(GLOBAL_AW_DIR);
197
238
 
239
+ // Ensure project worktree sparse checkout matches the global clone.
240
+ // Covers the case where a namespace was added from HOME (or another project)
241
+ // and this project's .aw/ hasn't been updated yet.
242
+ const localAw = cwd !== HOME ? findNearestWorktree(cwd, HOME) : null;
243
+ if (localAw) {
244
+ try { syncWorktreeSparseCheckout(AW_HOME, localAw); } catch { /* best effort */ }
245
+ }
246
+
198
247
  await installAwEcc(cwd, { silent });
199
248
  copyInstructions(HOME, null, freshCfg?.namespace || team) || [];
200
249
  initAwDocs(HOME);
201
250
  await setupMcp(HOME, freshCfg?.namespace || team, { silent });
202
- if (cwd !== HOME) await setupMcp(cwd, freshCfg?.namespace || team, { silent });
203
251
  installGlobalHooks();
204
252
 
205
253
  // Remove old local .git/hooks/post-checkout that pre-dates core.hooksPath (creates stale .aw_registry symlink)
@@ -215,10 +263,13 @@ export async function initCommand(args) {
215
263
  }
216
264
 
217
265
  const isInsideAw = cwd.endsWith(`${sep}.aw`) || cwd.includes(`${sep}.aw${sep}`);
218
- if (cwd !== HOME && !isInsideAw && !isWorktree(join(cwd, '.aw'))) {
266
+ // Only skip if already a valid symlink (new model). Old git worktrees must be migrated.
267
+ const awLink = join(cwd, '.aw');
268
+ const isAlreadySymlink = (() => { try { return lstatSync(awLink).isSymbolicLink() && existsSync(awLink); } catch { return false; } })();
269
+ if (cwd !== HOME && !isInsideAw && !isAlreadySymlink) {
219
270
  try {
220
271
  addProjectWorktree(AW_HOME, cwd);
221
- if (!silent) fmt.logStep('Linked current project as git worktree');
272
+ if (!silent) fmt.logStep('Linked current project');
222
273
  } catch { /* best effort */ }
223
274
  }
224
275
 
@@ -226,18 +277,18 @@ export async function initCommand(args) {
226
277
  // so edits to project/.aw/.aw_registry/ are instantly visible in global IDE dirs.
227
278
  const projectRegistryDir = cwd !== HOME ? join(cwd, '.aw', REGISTRY_DIR) : null;
228
279
  const awDirForLinks = (projectRegistryDir && existsSync(projectRegistryDir)) ? projectRegistryDir : null;
229
- linkWorkspace(HOME, awDirForLinks);
230
- generateCommands(HOME);
280
+ const symlinks = linkWorkspace(HOME, awDirForLinks, { silent: true });
281
+ const commands = generateCommands(HOME, { silent: true });
231
282
 
232
283
  if (silent) {
233
284
  autoUpdate(await args._updateCheck);
234
285
  } else {
235
286
  fmt.outro([
236
- isNewSubTeam ? `Sub-team ${chalk.cyan(folderName)} added` : 'Sync complete',
287
+ `⟁ ${isNewSubTeam ? `Sub-team ${chalk.cyan(folderName)} added` : 'Up to date'}`,
237
288
  '',
238
- ` ${chalk.green('✓')} Registry updated`,
239
- ` ${chalk.green('✓')} IDE integration refreshed`,
240
- cwd !== HOME && isWorktree(join(cwd, '.aw')) ? ` ${chalk.green('✓')} Current project linked` : null,
289
+ ` ${chalk.green('✓')} Registry synced`,
290
+ ` ${chalk.green('✓')} IDE refreshed — ${chalk.bold(symlinks)} symlinks · ${chalk.bold(commands)} commands`,
291
+ cwd !== HOME && isWorktree(join(cwd, '.aw')) ? ` ${chalk.green('✓')} Project linked` : null,
241
292
  ].filter(Boolean).join('\n'));
242
293
  }
243
294
  return;
@@ -282,7 +333,8 @@ export async function initCommand(args) {
282
333
  s.start(`Cloning registry...`);
283
334
 
284
335
  try {
285
- initPersistentClone(repoUrl, AW_HOME, sparsePaths);
336
+ await initPersistentClone(repoUrl, AW_HOME, sparsePaths);
337
+ ensureAwGitignore(AW_HOME);
286
338
  s.stop('Registry cloned');
287
339
  } catch (e) {
288
340
  s.stop(chalk.red('Clone failed'));
@@ -290,13 +342,25 @@ export async function initCommand(args) {
290
342
  }
291
343
 
292
344
  // Create backward-compat symlink: ~/.aw_registry/ → ~/.aw/.aw_registry/
293
- if (!existsSync(GLOBAL_AW_DIR)) {
345
+ // Use lstatSync (not existsSync) so we handle dangling symlinks correctly.
346
+ let awRegistryLstat = null;
347
+ try { awRegistryLstat = lstatSync(GLOBAL_AW_DIR); } catch { /* doesn't exist */ }
348
+ if (!awRegistryLstat) {
294
349
  try {
295
350
  symlinkSync(join(AW_HOME, REGISTRY_DIR), GLOBAL_AW_DIR);
296
351
  fmt.logStep('Created ~/.aw_registry/ symlink');
297
352
  } catch (e) {
298
353
  fmt.logWarn(`Could not create symlink ~/.aw_registry/: ${e.message}`);
299
354
  }
355
+ } else if (awRegistryLstat.isSymbolicLink()) {
356
+ // Stale or dangling — re-point to the new clone
357
+ try {
358
+ rmSync(GLOBAL_AW_DIR);
359
+ symlinkSync(join(AW_HOME, REGISTRY_DIR), GLOBAL_AW_DIR);
360
+ fmt.logStep('Updated ~/.aw_registry/ symlink');
361
+ } catch (e) {
362
+ fmt.logWarn(`Could not update symlink ~/.aw_registry/: ${e.message}`);
363
+ }
300
364
  }
301
365
 
302
366
  // Create sync config
@@ -309,8 +373,7 @@ export async function initCommand(args) {
309
373
  await installAwEcc(cwd, { silent });
310
374
  const instructionFiles = copyInstructions(HOME, null, team) || [];
311
375
  initAwDocs(HOME);
312
- const mcpFiles = await setupMcp(HOME, team) || [];
313
- if (cwd !== HOME) await setupMcp(cwd, team);
376
+ const mcpFiles = await setupMcp(HOME, team, { silent }) || [];
314
377
  const hooksInstalled = installGlobalHooks();
315
378
  installIdeTasks();
316
379
 
@@ -325,28 +388,33 @@ export async function initCommand(args) {
325
388
  } catch { /* not there, fine */ }
326
389
  }
327
390
 
328
- // Step 4: Link current project as a git worktree (gives IDE git panel)
391
+ // Step 4: Link current project as a symlink to ~/.aw (gives IDE git panel, shared across all workspaces)
329
392
  const isInsideAw = cwd.endsWith(`${sep}.aw`) || cwd.includes(`${sep}.aw${sep}`);
330
- if (cwd !== HOME && !isInsideAw && !isWorktree(join(cwd, '.aw'))) {
393
+ const awLinkFresh = join(cwd, '.aw');
394
+ const isAlreadySymlinkFresh = (() => { try { return lstatSync(awLinkFresh).isSymbolicLink() && existsSync(awLinkFresh); } catch { return false; } })();
395
+ if (cwd !== HOME && !isInsideAw && !isAlreadySymlinkFresh) {
331
396
  try {
332
397
  addProjectWorktree(AW_HOME, cwd);
333
- fmt.logStep('Linked current project as git worktree');
398
+ fmt.logStep('Linked current project');
334
399
  } catch { /* best effort */ }
335
400
  }
336
401
 
337
402
  // Step 5: Wire ~/.claude/.cursor/.codex to the project's registry when in a project,
338
403
  // so edits to project/.aw/.aw_registry/ are instantly visible in global IDE dirs.
339
- fmt.logStep('Linking IDE symlinks...');
404
+ const ideSpinner = fmt.spinner();
405
+ ideSpinner.start('Wiring IDE symlinks...');
340
406
  const projectRegistryDir = cwd !== HOME ? join(cwd, '.aw', REGISTRY_DIR) : null;
341
407
  const awDirForLinks = (projectRegistryDir && existsSync(projectRegistryDir)) ? projectRegistryDir : null;
342
- linkWorkspace(HOME, awDirForLinks);
343
- generateCommands(HOME);
408
+ const symlinks = linkWorkspace(HOME, awDirForLinks, { silent: true });
409
+ ideSpinner.message('Generating commands...');
410
+ const commands = generateCommands(HOME, { silent: true });
411
+ ideSpinner.stop(`IDE wired — ${chalk.bold(symlinks)} symlinks · ${chalk.bold(commands)} commands`);
344
412
 
345
413
  // Offer to update if a newer version is available
346
414
  await promptUpdate(await args._updateCheck);
347
415
 
348
416
  fmt.outro([
349
- 'Install complete',
417
+ 'Install complete',
350
418
  '',
351
419
  ` ${chalk.green('✓')} Source of truth: ~/.aw/ (git clone)`,
352
420
  ` ${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
  }