@ghl-ai/aw 0.1.34-beta.14 → 0.1.34-beta.15

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/apply.mjs ADDED
@@ -0,0 +1,60 @@
1
+ // apply.mjs — Apply sync plan (file operations). Zero dependencies.
2
+
3
+ import { readFileSync, writeFileSync, mkdirSync, cpSync, existsSync } from 'node:fs';
4
+ import { dirname } from 'node:path';
5
+
6
+ function writeConflictMarkers(filePath, localContent, registryContent) {
7
+ writeFileSync(filePath, [
8
+ '<<<<<<< LOCAL',
9
+ localContent.trimEnd(),
10
+ '=======',
11
+ registryContent.trimEnd(),
12
+ '>>>>>>> REGISTRY',
13
+ '',
14
+ ].join('\n'));
15
+ }
16
+
17
+ /**
18
+ * Apply actions to the workspace.
19
+ * All actions are file-level (no directory-level operations).
20
+ *
21
+ * @param {Array} actions
22
+ * @param {{ teamNS?: string }} opts
23
+ * teamNS — when set, replaces all occurrences of `$TEAM_NS` in file content
24
+ * with this value. Used when pulling [template] as a renamed team namespace.
25
+ * @returns {number} count of files with conflicts
26
+ */
27
+ export function applyActions(actions, { teamNS } = {}) {
28
+ let conflicts = 0;
29
+
30
+ for (const act of actions) {
31
+ switch (act.action) {
32
+ case 'ADD':
33
+ case 'UPDATE':
34
+ mkdirSync(dirname(act.targetPath), { recursive: true });
35
+ if (teamNS && act.sourcePath.endsWith('.md')) {
36
+ const content = readFileSync(act.sourcePath, 'utf8').replaceAll('$TEAM_NS', teamNS);
37
+ writeFileSync(act.targetPath, content);
38
+ } else {
39
+ cpSync(act.sourcePath, act.targetPath);
40
+ }
41
+ break;
42
+
43
+ case 'CONFLICT': {
44
+ const local = existsSync(act.targetPath) ? readFileSync(act.targetPath, 'utf8') : '';
45
+ let registry = readFileSync(act.sourcePath, 'utf8');
46
+ if (teamNS) registry = registry.replaceAll('$TEAM_NS', teamNS);
47
+ mkdirSync(dirname(act.targetPath), { recursive: true });
48
+ writeConflictMarkers(act.targetPath, local, registry);
49
+ conflicts++;
50
+ break;
51
+ }
52
+
53
+ case 'ORPHAN':
54
+ case 'UNCHANGED':
55
+ break;
56
+ }
57
+ }
58
+
59
+ return conflicts;
60
+ }
package/commands/drop.mjs CHANGED
@@ -1,21 +1,18 @@
1
1
  // commands/drop.mjs — Remove file or synced path from workspace
2
2
 
3
- import { join } from 'node:path';
3
+ import { join, resolve } from 'node:path';
4
4
  import { rmSync, existsSync } from 'node:fs';
5
- import { homedir } from 'node:os';
6
5
  import * as config from '../config.mjs';
7
6
  import * as fmt from '../fmt.mjs';
8
7
  import { chalk } from '../fmt.mjs';
8
+ import { matchesAny } from '../glob.mjs';
9
9
  import { resolveInput } from '../paths.mjs';
10
- import { gitSparseList, gitSparseSet, isGitRepo } from '../git.mjs';
11
- import { REGISTRY_DIR } from '../constants.mjs';
10
+ import { load as loadManifest, save as saveManifest } from '../manifest.mjs';
12
11
 
13
12
  export function dropCommand(args) {
14
13
  const input = args._positional?.[0];
15
14
  const cwd = process.cwd();
16
- const GLOBAL_AW_DIR = join(homedir(), '.aw_registry');
17
- const localDir = join(cwd, '.aw_registry');
18
- const workspaceDir = existsSync(join(localDir, '.sync-config.json')) ? localDir : GLOBAL_AW_DIR;
15
+ const workspaceDir = join(cwd, '.aw_registry');
19
16
 
20
17
  fmt.intro('aw drop');
21
18
 
@@ -26,10 +23,7 @@ export function dropCommand(args) {
26
23
  const cfg = config.load(workspaceDir);
27
24
  if (!cfg) fmt.cancel('No .sync-config.json found. Run: aw init');
28
25
 
29
- if (!isGitRepo(workspaceDir)) {
30
- fmt.cancel('Registry not initialized as git repo. Run: aw init');
31
- }
32
-
26
+ // Resolve to registry path (accepts both local and registry paths)
33
27
  const resolved = resolveInput(input, workspaceDir);
34
28
  const regPath = resolved.registryPath;
35
29
 
@@ -37,37 +31,58 @@ export function dropCommand(args) {
37
31
  fmt.cancel(`Could not resolve "${input}" to a registry path`);
38
32
  }
39
33
 
40
- // Check if this is a top-level synced path → remove from sparse-checkout
34
+ // Check if this path (or a parent) is in config → remove from config + delete files
41
35
  const isConfigPath = cfg.include.some(p => p === regPath || p.startsWith(regPath + '/'));
42
36
 
43
37
  if (isConfigPath) {
44
38
  config.removePattern(workspaceDir, regPath);
45
39
  fmt.logSuccess(`Removed ${chalk.cyan(regPath)} from sync config`);
40
+ }
46
41
 
47
- // Remove from sparse checkout
48
- const sparsePath = `${REGISTRY_DIR}/${regPath}`;
49
- const currentPaths = gitSparseList(workspaceDir);
50
- const newPaths = currentPaths.filter(p => p !== sparsePath && !p.startsWith(sparsePath + '/'));
42
+ // Delete matching local files
43
+ const removed = deleteMatchingFiles(workspaceDir, regPath);
51
44
 
52
- if (newPaths.length !== currentPaths.length) {
53
- gitSparseSet(workspaceDir, newPaths);
54
- fmt.logSuccess(`Removed ${chalk.cyan(regPath)} from sparse checkout`);
55
- }
56
- } else {
57
- // Sub-path: delete local files (restored on next pull since still in sparse checkout)
58
- const targetPath = join(workspaceDir, regPath);
59
- if (existsSync(targetPath)) {
60
- rmSync(targetPath, { recursive: true, force: true });
61
- fmt.logInfo(`Removed ${chalk.cyan(regPath)} from workspace`);
62
- fmt.logWarn(`Path still in sync config — next ${chalk.dim('aw pull')} will restore it`);
63
- } else if (existsSync(targetPath + '.md')) {
64
- rmSync(targetPath + '.md', { force: true });
65
- fmt.logInfo(`Removed ${chalk.cyan(regPath)}.md from workspace`);
66
- fmt.logWarn(`Path still in sync config — next ${chalk.dim('aw pull')} will restore it`);
67
- } else {
68
- fmt.cancel(`Nothing found for ${chalk.cyan(regPath)}.\n\n Use ${chalk.dim('aw status')} to see synced paths.`);
69
- }
45
+ if (removed > 0) {
46
+ fmt.logInfo(`${chalk.bold(removed)} file${removed > 1 ? 's' : ''} removed from workspace`);
47
+ } else if (!isConfigPath) {
48
+ fmt.cancel(`Nothing found for ${chalk.cyan(regPath)}.\n\n Use ${chalk.dim('aw status')} to see synced paths.`);
49
+ }
50
+
51
+ if (!isConfigPath && removed > 0) {
52
+ fmt.logWarn(`Path still in sync config — next ${chalk.dim('aw pull')} will restore it`);
70
53
  }
71
54
 
72
55
  fmt.outro('Done');
73
56
  }
57
+
58
+ /**
59
+ * Find and delete local files whose registry path matches the given path.
60
+ */
61
+ function deleteMatchingFiles(workspaceDir, path) {
62
+ const manifest = loadManifest(workspaceDir);
63
+ let removed = 0;
64
+
65
+ for (const [manifestKey] of Object.entries(manifest.files)) {
66
+ const registryPath = manifestKeyToRegistryPath(manifestKey);
67
+
68
+ if (matchesAny(registryPath, [path])) {
69
+ const filePath = join(workspaceDir, manifestKey);
70
+ if (existsSync(filePath)) {
71
+ rmSync(filePath, { recursive: true, force: true });
72
+ removed++;
73
+ }
74
+ delete manifest.files[manifestKey];
75
+ }
76
+ }
77
+
78
+ saveManifest(workspaceDir, manifest);
79
+ return removed;
80
+ }
81
+
82
+ /**
83
+ * Convert manifest key to registry path.
84
+ * Manifest key now mirrors registry: "platform/agents/architecture-reviewer.md" → "platform/agents/architecture-reviewer"
85
+ */
86
+ function manifestKeyToRegistryPath(manifestKey) {
87
+ return manifestKey.replace(/\.md$/, '');
88
+ }
package/commands/init.mjs CHANGED
@@ -1,25 +1,19 @@
1
- // commands/init.mjs — Persistent sparse git checkout, link IDEs, global git hooks.
1
+ // commands/init.mjs — Clean init: clone registry, link IDEs, global git hooks.
2
2
  //
3
- // Layout:
4
- // ~/.aw_registry/.git-source/ — hidden git clone
5
- // ~/.aw_registry/platform — symlink into clone
6
- // ~/.aw_registry/platform/docs — symlink to content/ (full docs)
3
+ // No shell profile modifications. No daemons. No background processes.
4
+ // Uses core.hooksPath (git-lfs pattern) for system-wide hook interception.
5
+ // Uses IDE tasks for auto-pull on workspace open.
7
6
 
8
- import { mkdirSync, existsSync, writeFileSync, symlinkSync, cpSync, readFileSync, readdirSync, renameSync, lstatSync, rmSync } from 'node:fs';
7
+ import { mkdirSync, existsSync, writeFileSync, symlinkSync } from 'node:fs';
9
8
  import { execSync } from 'node:child_process';
10
9
  import { join, dirname } from 'node:path';
11
10
  import { homedir } from 'node:os';
12
11
  import { fileURLToPath } from 'node:url';
12
+ import { readFileSync } from 'node:fs';
13
13
  import * as config from '../config.mjs';
14
14
  import * as fmt from '../fmt.mjs';
15
15
  import { chalk } from '../fmt.mjs';
16
- import {
17
- gitInit, gitPull, gitSparseAdd, gitSparseList, gitSparseSet, gitLsRemote,
18
- isGitRepo, withLock, includeToSparsePaths, addGitExcludes,
19
- ensureNamespaceLinks, ensureDocsSymlink,
20
- GLOBAL_GIT_DIR,
21
- } from '../git.mjs';
22
- import { REGISTRY_BASE_BRANCH, REGISTRY_DIR, REGISTRY_REPO } from '../constants.mjs';
16
+ import { pullCommand, pullAsync } from './pull.mjs';
23
17
  import { linkWorkspace } from '../link.mjs';
24
18
  import { generateCommands, copyInstructions, initAwDocs } from '../integrate.mjs';
25
19
  import { setupMcp } from '../mcp.mjs';
@@ -33,29 +27,36 @@ const HOME = homedir();
33
27
  const GLOBAL_AW_DIR = join(HOME, '.aw_registry');
34
28
  const MANIFEST_PATH = join(GLOBAL_AW_DIR, '.aw-manifest.json');
35
29
 
36
- // ── IDE tasks ────────────────────────────────────────────────────────
30
+ // ── IDE tasks for auto-pull ─────────────────────────────────────────────
37
31
 
38
32
  function installIdeTasks() {
33
+ // VS Code / Cursor task — runs aw pull on folder open
39
34
  const vscodeTask = {
40
35
  version: '2.0.0',
41
- tasks: [{
42
- label: 'aw: sync registry',
43
- type: 'shell',
44
- command: 'aw init --silent',
45
- presentation: { reveal: 'silent', panel: 'shared', close: true },
46
- runOptions: { runOn: 'folderOpen' },
47
- problemMatcher: [],
48
- }],
36
+ tasks: [
37
+ {
38
+ label: 'aw: sync registry',
39
+ type: 'shell',
40
+ command: 'aw init --silent',
41
+ presentation: { reveal: 'silent', panel: 'shared', close: true },
42
+ runOptions: { runOn: 'folderOpen' },
43
+ problemMatcher: [],
44
+ },
45
+ ],
49
46
  };
50
47
 
48
+ // Install globally for VS Code and Cursor
51
49
  for (const ide of ['Code', 'Cursor']) {
52
50
  const userDir = join(HOME, 'Library', 'Application Support', ide, 'User');
53
51
  if (!existsSync(userDir)) continue;
52
+
54
53
  const tasksPath = join(userDir, 'tasks.json');
55
54
  if (existsSync(tasksPath)) {
55
+ // Don't override existing tasks — check if aw task already there
56
56
  try {
57
57
  const existing = JSON.parse(readFileSync(tasksPath, 'utf8'));
58
58
  if (existing.tasks?.some(t => t.label === 'aw: sync registry' || t.label === 'aw: pull registry')) continue;
59
+ // Add our task to existing
59
60
  existing.tasks = existing.tasks || [];
60
61
  existing.tasks.push(vscodeTask.tasks[0]);
61
62
  writeFileSync(tasksPath, JSON.stringify(existing, null, 2) + '\n');
@@ -72,48 +73,26 @@ function saveManifest(data) {
72
73
  writeFileSync(MANIFEST_PATH, JSON.stringify(data, null, 2) + '\n');
73
74
  }
74
75
 
75
- const ALLOWED_NAMESPACES = ['platform', 'revex', 'mobile', 'commerce', 'leadgen', 'crm', 'marketplace', 'ai'];
76
76
 
77
- /**
78
- * Bootstrap a new namespace from [template].
79
- */
80
- function bootstrapFromTemplate(folderName, teamNS) {
81
- const gitDir = GLOBAL_GIT_DIR;
82
- const templateDir = join(gitDir, REGISTRY_DIR, '[template]');
83
- const targetDir = join(gitDir, REGISTRY_DIR, ...folderName.split('/'));
77
+ function printPullSummary(pattern, actions) {
78
+ for (const type of ['agents', 'skills', 'commands', 'evals']) {
79
+ const typeActions = actions.filter(a => a.type === type);
80
+ if (typeActions.length === 0) continue;
84
81
 
85
- if (!existsSync(templateDir)) {
86
- throw new Error('Template directory not found. Ensure [template] is in sparse checkout.');
87
- }
82
+ const counts = { ADD: 0, UPDATE: 0, CONFLICT: 0 };
83
+ for (const a of typeActions) counts[a.action] = (counts[a.action] || 0) + 1;
88
84
 
89
- cpSync(templateDir, targetDir, { recursive: true });
90
- walkAndReplace(targetDir, '$TEAM_NS', teamNS);
91
- }
85
+ const parts = [];
86
+ if (counts.ADD > 0) parts.push(chalk.green(`${counts.ADD} new`));
87
+ if (counts.UPDATE > 0) parts.push(chalk.cyan(`${counts.UPDATE} updated`));
88
+ if (counts.CONFLICT > 0) parts.push(chalk.red(`${counts.CONFLICT} conflict`));
89
+ const detail = parts.length > 0 ? ` (${parts.join(', ')})` : '';
92
90
 
93
- function walkAndReplace(dir, search, replace) {
94
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
95
- const fullPath = join(dir, entry.name);
96
- if (entry.isDirectory()) {
97
- walkAndReplace(fullPath, search, replace);
98
- } else if (entry.name.endsWith('.md')) {
99
- const content = readFileSync(fullPath, 'utf8');
100
- if (content.includes(search)) {
101
- writeFileSync(fullPath, content.replaceAll(search, replace));
102
- }
103
- }
91
+ fmt.logSuccess(`${chalk.cyan(pattern)}: ${typeActions.length} ${type}${detail}`);
104
92
  }
105
93
  }
106
94
 
107
- /**
108
- * Remove [template] from sparse checkout after bootstrapping.
109
- */
110
- function removeTemplateFromSparse() {
111
- const currentPaths = gitSparseList(GLOBAL_AW_DIR);
112
- const cleanPaths = currentPaths.filter(p => !p.includes('[template]'));
113
- if (cleanPaths.length !== currentPaths.length) {
114
- gitSparseSet(GLOBAL_AW_DIR, cleanPaths);
115
- }
116
- }
95
+ const ALLOWED_NAMESPACES = ['platform', 'revex', 'mobile', 'commerce', 'leadgen', 'crm', 'marketplace', 'ai'];
117
96
 
118
97
  export async function initCommand(args) {
119
98
  const namespace = args['--namespace'] || null;
@@ -122,7 +101,7 @@ export async function initCommand(args) {
122
101
 
123
102
  fmt.intro(`aw init ${chalk.dim('v' + VERSION)}`);
124
103
 
125
- // ── Validate ──────────────────────────────────────────────────────
104
+ // ── Validate ──────────────────────────────────────────────────────────
126
105
 
127
106
  if (!namespace && !silent) {
128
107
  const list = ALLOWED_NAMESPACES.map(n => chalk.cyan(n)).join(', ');
@@ -136,11 +115,12 @@ export async function initCommand(args) {
136
115
  ].join('\n'));
137
116
  }
138
117
 
118
+ // Parse team/sub-team
139
119
  const nsParts = namespace ? namespace.split('/') : [];
140
120
  const team = nsParts[0] || null;
141
121
  const subTeam = nsParts[1] || null;
142
- const teamNS = subTeam ? `${team}-${subTeam}` : team;
143
- const folderName = subTeam ? `${team}/${subTeam}` : team;
122
+ const teamNS = subTeam ? `${team}-${subTeam}` : team; // for $TEAM_NS replacement
123
+ const folderName = subTeam ? `${team}/${subTeam}` : team; // for .aw_registry/ path
144
124
 
145
125
  if (team && !ALLOWED_NAMESPACES.includes(team)) {
146
126
  const list = ALLOWED_NAMESPACES.map(n => chalk.cyan(n)).join(', ');
@@ -171,55 +151,63 @@ export async function initCommand(args) {
171
151
  fmt.cancel(`Invalid sub-team '${subTeam}' — must match: ${SLUG_RE}`);
172
152
  }
173
153
 
174
- const cwd = process.cwd();
175
- const hasGitRepo = isGitRepo(GLOBAL_AW_DIR);
176
154
  const hasConfig = config.exists(GLOBAL_AW_DIR);
155
+ const hasPlatform = existsSync(join(GLOBAL_AW_DIR, 'platform'));
156
+ const isExisting = hasConfig && hasPlatform;
157
+ const cwd = process.cwd();
177
158
 
178
- // ── Fast path: already initialized ─────────────────────────────────
159
+ // ── Fast path: already initialized → just pull + link ─────────────────
179
160
 
180
- if (hasGitRepo && hasConfig) {
161
+ if (isExisting) {
181
162
  const cfg = config.load(GLOBAL_AW_DIR);
182
- const isNewSubTeam = folderName && cfg && !cfg.include.includes(folderName);
183
163
 
184
- await withLock(GLOBAL_AW_DIR, async () => {
185
- if (!silent) fmt.logStep('Syncing registry...');
186
- try {
187
- const { conflicts } = gitPull(GLOBAL_AW_DIR);
188
- if (conflicts && !silent) {
189
- fmt.logWarn('Merge conflicts — resolve in ~/.aw_registry/.git-source/');
190
- }
191
- } catch (e) {
192
- if (!silent) fmt.logWarn(`Pull failed: ${e.message}`);
193
- }
194
-
195
- if (isNewSubTeam) {
196
- if (!silent) fmt.logStep(`Adding sub-team ${chalk.cyan(folderName)}...`);
197
- const nsExists = gitLsRemote(GLOBAL_AW_DIR, folderName);
198
- if (nsExists) {
199
- gitSparseAdd(GLOBAL_AW_DIR, [`${REGISTRY_DIR}/${folderName}`]);
200
- } else {
201
- gitSparseAdd(GLOBAL_AW_DIR, [`${REGISTRY_DIR}/[template]`]);
202
- try { gitPull(GLOBAL_AW_DIR); } catch { /* */ }
203
- bootstrapFromTemplate(folderName, teamNS);
204
- removeTemplateFromSparse();
205
- }
206
- config.addPattern(GLOBAL_AW_DIR, folderName);
207
- }
208
- }, { skipIfLocked: silent });
209
-
210
- // Refresh symlinks + docs
211
- ensureDocsSymlink(GLOBAL_AW_DIR);
212
- ensureNamespaceLinks(GLOBAL_AW_DIR);
164
+ // Add new sub-team if not already tracked
165
+ const isNewSubTeam = folderName && cfg && !cfg.include.includes(folderName);
166
+ if (isNewSubTeam) {
167
+ if (!silent) fmt.logStep(`Adding sub-team ${chalk.cyan(folderName)}...`);
168
+ await pullAsync({
169
+ ...args,
170
+ _positional: ['[template]'],
171
+ _renameNamespace: folderName,
172
+ _teamNS: teamNS,
173
+ _workspaceDir: GLOBAL_AW_DIR,
174
+ _skipIntegrate: true,
175
+ });
176
+ config.addPattern(GLOBAL_AW_DIR, folderName);
177
+ } else {
178
+ if (!silent) fmt.logStep('Already initialized — syncing...');
179
+ }
213
180
 
181
+ // Pull latest (parallel)
182
+ // cfg.include has the renamed namespace (e.g. 'revex/courses'), but the repo
183
+ // only has '.aw_registry/[template]/' — remap non-platform entries back.
214
184
  const freshCfg = config.load(GLOBAL_AW_DIR);
185
+ if (freshCfg && freshCfg.include.length > 0) {
186
+ const pullJobs = freshCfg.include.map(p => {
187
+ const isTeamNs = p !== 'platform';
188
+ const derivedTeamNS = isTeamNs ? p.replace(/\//g, '-') : undefined;
189
+ return pullAsync({
190
+ ...args,
191
+ _positional: [isTeamNs ? '[template]' : p],
192
+ _workspaceDir: GLOBAL_AW_DIR,
193
+ _skipIntegrate: true,
194
+ _renameNamespace: isTeamNs ? p : undefined,
195
+ _teamNS: derivedTeamNS,
196
+ });
197
+ });
198
+ await Promise.all(pullJobs);
199
+ }
200
+
201
+ // Re-link IDE dirs + hooks (idempotent)
215
202
  linkWorkspace(HOME);
216
203
  generateCommands(HOME);
217
- copyInstructions(HOME, null, freshCfg?.namespace || team);
204
+ copyInstructions(HOME, null, freshCfg?.namespace || team) || [];
218
205
  initAwDocs(HOME);
219
- setupMcp(HOME, freshCfg?.namespace || team);
206
+ setupMcp(HOME, freshCfg?.namespace || team) || [];
220
207
  if (cwd !== HOME) setupMcp(cwd, freshCfg?.namespace || team);
221
208
  installGlobalHooks();
222
209
 
210
+ // Link current project if needed
223
211
  if (cwd !== HOME && !existsSync(join(cwd, '.aw_registry'))) {
224
212
  try {
225
213
  symlinkSync(GLOBAL_AW_DIR, join(cwd, '.aw_registry'));
@@ -241,86 +229,55 @@ export async function initCommand(args) {
241
229
  return;
242
230
  }
243
231
 
244
- // ── Full init ──────────────────────────────────────────────────────
245
-
246
- // Backup old ~/.aw_registry/ if it's a real dir without our git clone
247
- if (existsSync(GLOBAL_AW_DIR) && !isGitRepo(GLOBAL_AW_DIR)) {
248
- const backupDir = `${GLOBAL_AW_DIR}.bak-${new Date().toISOString().slice(0, 10)}`;
249
- fmt.logWarn(`Existing ~/.aw_registry/ found — backing up to ${backupDir}`);
250
- renameSync(GLOBAL_AW_DIR, backupDir);
251
- }
252
-
253
- // Ensure ~/.aw_registry/ exists as a real directory
254
- mkdirSync(GLOBAL_AW_DIR, { recursive: true });
232
+ // ── Full init: first time setup ───────────────────────────────────────
255
233
 
234
+ // Auto-detect user
256
235
  if (!user) {
257
236
  try {
258
237
  user = execSync('git config user.name', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
259
- } catch { /* */ }
238
+ } catch { /* git not configured */ }
260
239
  }
261
240
 
241
+ // Step 1: Create global source of truth
242
+ mkdirSync(GLOBAL_AW_DIR, { recursive: true });
243
+
244
+ const cfg = config.create(GLOBAL_AW_DIR, { namespace: team, user });
245
+
262
246
  fmt.note([
263
247
  `${chalk.dim('source:')} ~/.aw_registry/`,
264
248
  folderName ? `${chalk.dim('namespace:')} ${folderName}` : `${chalk.dim('namespace:')} ${chalk.dim('none')}`,
265
- user ? `${chalk.dim('user:')} ${user}` : null,
249
+ user ? `${chalk.dim('user:')} ${cfg.user}` : null,
266
250
  `${chalk.dim('version:')} v${VERSION}`,
267
251
  ].filter(Boolean).join('\n'), 'Config created');
268
252
 
269
- // Step 1: Clone to hidden .git-source/ inside .aw_registry
253
+ // Step 2: Pull registry content (parallel)
270
254
  const s = fmt.spinner();
271
255
  const pullTargets = folderName ? `platform + ${folderName}` : 'platform';
272
- s.start(`Cloning registry (${pullTargets})...`);
256
+ s.start(`Pulling ${pullTargets}...`);
273
257
 
274
- const initialPaths = includeToSparsePaths(['platform']);
258
+ const pullJobs = [
259
+ pullAsync({ ...args, _positional: ['platform'], _workspaceDir: GLOBAL_AW_DIR, _skipIntegrate: true }),
260
+ ];
261
+ if (folderName) {
262
+ pullJobs.push(
263
+ pullAsync({ ...args, _positional: ['[template]'], _renameNamespace: folderName, _teamNS: teamNS, _workspaceDir: GLOBAL_AW_DIR, _skipIntegrate: true }),
264
+ );
265
+ }
275
266
 
267
+ let pullResults;
276
268
  try {
277
- gitInit(REGISTRY_REPO, REGISTRY_BASE_BRANCH, GLOBAL_GIT_DIR, initialPaths);
278
- s.stop('Repository cloned');
269
+ pullResults = await Promise.all(pullJobs);
270
+ s.stop(`Pulled ${pullTargets}`);
279
271
  } catch (e) {
280
- s.stop(chalk.red('Clone failed'));
272
+ s.stop(chalk.red('Pull failed'));
281
273
  fmt.cancel(e.message);
282
274
  }
283
275
 
284
- // Step 2: Git excludes for local config files
285
- addGitExcludes(GLOBAL_GIT_DIR, [
286
- `${REGISTRY_DIR}/.sync-config.json`,
287
- `${REGISTRY_DIR}/.aw-manifest.json`,
288
- `${REGISTRY_DIR}/platform/docs`,
289
- '.aw-lock/',
290
- ]);
291
-
292
- // Step 3: Write config
293
- const newCfg = config.create(GLOBAL_AW_DIR, { namespace: team, user });
294
-
295
- // Step 4: Add namespace
296
- if (folderName) {
297
- const s2 = fmt.spinner();
298
- s2.start(`Setting up namespace ${chalk.cyan(folderName)}...`);
299
-
300
- const nsExists = gitLsRemote(GLOBAL_AW_DIR, folderName);
301
- if (nsExists) {
302
- gitSparseAdd(GLOBAL_AW_DIR, [`${REGISTRY_DIR}/${folderName}`]);
303
- s2.stop(`Namespace ${chalk.cyan(folderName)} checked out`);
304
- } else {
305
- gitSparseAdd(GLOBAL_AW_DIR, [`${REGISTRY_DIR}/[template]`]);
306
- bootstrapFromTemplate(folderName, teamNS);
307
- removeTemplateFromSparse();
308
- s2.stop(`Namespace ${chalk.cyan(folderName)} bootstrapped from template`);
309
- }
310
- config.addPattern(GLOBAL_AW_DIR, folderName);
276
+ for (const { pattern, actions } of pullResults) {
277
+ printPullSummary(pattern, actions);
311
278
  }
312
279
 
313
- if (!newCfg.include.includes('platform')) {
314
- config.addPattern(GLOBAL_AW_DIR, 'platform');
315
- }
316
-
317
- // Step 5: Docs symlink (content/ → platform/docs)
318
- ensureDocsSymlink(GLOBAL_AW_DIR);
319
-
320
- // Step 6: Namespace symlinks (platform → .git-source/.aw_registry/platform)
321
- ensureNamespaceLinks(GLOBAL_AW_DIR);
322
-
323
- // Step 7: Link IDE dirs
280
+ // Step 3: Link IDE dirs + setup tasks
324
281
  fmt.logStep('Linking IDE symlinks...');
325
282
  linkWorkspace(HOME);
326
283
  generateCommands(HOME);
@@ -331,7 +288,7 @@ export async function initCommand(args) {
331
288
  const hooksInstalled = installGlobalHooks();
332
289
  installIdeTasks();
333
290
 
334
- // Step 8: Project symlink
291
+ // Step 4: Symlink in current directory if it's a git repo
335
292
  if (cwd !== HOME && !existsSync(join(cwd, '.aw_registry'))) {
336
293
  try {
337
294
  symlinkSync(GLOBAL_AW_DIR, join(cwd, '.aw_registry'));
@@ -339,9 +296,9 @@ export async function initCommand(args) {
339
296
  } catch { /* best effort */ }
340
297
  }
341
298
 
342
- // Step 9: Install manifest for nuke
343
- saveManifest({
344
- version: 2,
299
+ // Step 5: Write manifest for nuke cleanup
300
+ const manifest = {
301
+ version: 1,
345
302
  installedAt: new Date().toISOString(),
346
303
  globalDir: GLOBAL_AW_DIR,
347
304
  createdFiles: [
@@ -349,14 +306,17 @@ export async function initCommand(args) {
349
306
  ...mcpFiles.map(p => p.startsWith(HOME) ? p.slice(HOME.length + 1) : p),
350
307
  ],
351
308
  globalHooksDir: hooksInstalled ? join(HOME, '.aw', 'hooks') : null,
352
- });
309
+ };
310
+ saveManifest(manifest);
353
311
 
312
+ // Offer to update if a newer version is available
354
313
  await promptUpdate(await args._updateCheck);
355
314
 
315
+ // Done
356
316
  fmt.outro([
357
317
  'Install complete',
358
318
  '',
359
- ` ${chalk.green('✓')} Source of truth: ~/.aw_registry/ (git)`,
319
+ ` ${chalk.green('✓')} Source of truth: ~/.aw_registry/`,
360
320
  ` ${chalk.green('✓')} IDE integration: ~/.claude/, ~/.cursor/, ~/.codex/`,
361
321
  hooksInstalled ? ` ${chalk.green('✓')} Git hooks: auto-sync on pull/clone (core.hooksPath)` : null,
362
322
  ` ${chalk.green('✓')} IDE task: auto-sync on workspace open`,
package/commands/nuke.mjs CHANGED
@@ -286,24 +286,10 @@ export function nukeCommand(args) {
286
286
  } catch { /* not installed via npm or no permissions */ }
287
287
  }
288
288
 
289
- // 9. Remove ~/.aw_registry/ (contains .git-source/ clone + namespace symlinks)
290
- // May be a real dir (current) or symlink (legacy layouts)
291
- try {
292
- if (lstatSync(GLOBAL_AW_DIR).isSymbolicLink()) {
293
- unlinkSync(GLOBAL_AW_DIR);
294
- } else {
295
- rmSync(GLOBAL_AW_DIR, { recursive: true, force: true });
296
- }
297
- } catch { rmSync(GLOBAL_AW_DIR, { recursive: true, force: true }); }
289
+ // 9. Remove ~/.aw_registry/ itself (source of truth last!)
290
+ rmSync(GLOBAL_AW_DIR, { recursive: true, force: true });
298
291
  fmt.logStep('Removed ~/.aw_registry/');
299
292
 
300
- // Legacy cleanup: remove ~/.aw_repo/ if it exists from older versions
301
- const legacyRepo = join(HOME, '.aw_repo');
302
- if (existsSync(legacyRepo)) {
303
- rmSync(legacyRepo, { recursive: true, force: true });
304
- fmt.logStep('Removed legacy ~/.aw_repo/');
305
- }
306
-
307
293
  fmt.outro([
308
294
  'Fully removed',
309
295
  '',