@ghl-ai/aw 0.1.34-beta.13 → 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 — Clean init: 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_repo/ — git clone (repo root)
5
- // ~/.aw_registry/ — symlink ~/.aw_repo/.aw_registry/
6
- //
7
- // No temp dirs. No manifest hashing. Git handles sync natively.
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.
8
6
 
9
- import { mkdirSync, existsSync, writeFileSync, symlinkSync, cpSync, readFileSync, readdirSync, renameSync, lstatSync, rmSync } from 'node:fs';
7
+ import { mkdirSync, existsSync, writeFileSync, symlinkSync } from 'node:fs';
10
8
  import { execSync } from 'node:child_process';
11
9
  import { join, dirname } from 'node:path';
12
10
  import { homedir } from 'node:os';
13
11
  import { fileURLToPath } from 'node:url';
12
+ import { readFileSync } from 'node:fs';
14
13
  import * as config from '../config.mjs';
15
14
  import * as fmt from '../fmt.mjs';
16
15
  import { chalk } from '../fmt.mjs';
17
- import {
18
- gitInit, gitPull, gitSparseAdd, gitSparseList, gitSparseSet, gitLsRemote,
19
- isGitRepo, withLock, includeToSparsePaths, addGitExcludes,
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';
@@ -36,6 +30,7 @@ const MANIFEST_PATH = join(GLOBAL_AW_DIR, '.aw-manifest.json');
36
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
36
  tasks: [
@@ -50,15 +45,18 @@ function installIdeTasks() {
50
45
  ],
51
46
  };
52
47
 
48
+ // Install globally for VS Code and Cursor
53
49
  for (const ide of ['Code', 'Cursor']) {
54
50
  const userDir = join(HOME, 'Library', 'Application Support', ide, 'User');
55
51
  if (!existsSync(userDir)) continue;
56
52
 
57
53
  const tasksPath = join(userDir, 'tasks.json');
58
54
  if (existsSync(tasksPath)) {
55
+ // Don't override existing tasks — check if aw task already there
59
56
  try {
60
57
  const existing = JSON.parse(readFileSync(tasksPath, 'utf8'));
61
58
  if (existing.tasks?.some(t => t.label === 'aw: sync registry' || t.label === 'aw: pull registry')) continue;
59
+ // Add our task to existing
62
60
  existing.tasks = existing.tasks || [];
63
61
  existing.tasks.push(vscodeTask.tasks[0]);
64
62
  writeFileSync(tasksPath, JSON.stringify(existing, null, 2) + '\n');
@@ -75,85 +73,27 @@ function saveManifest(data) {
75
73
  writeFileSync(MANIFEST_PATH, JSON.stringify(data, null, 2) + '\n');
76
74
  }
77
75
 
78
- const ALLOWED_NAMESPACES = ['platform', 'revex', 'mobile', 'commerce', 'leadgen', 'crm', 'marketplace', 'ai'];
79
76
 
80
- /**
81
- * Bootstrap a new namespace from [template].
82
- * Copies [template]/ folderName/, replaces $TEAM_NS.
83
- */
84
- function bootstrapFromTemplate(folderName, teamNS) {
85
- // Template and target are inside GLOBAL_AW_DIR (which IS .aw_registry/)
86
- const templateDir = join(GLOBAL_AW_DIR, '[template]');
87
- const targetDir = join(GLOBAL_AW_DIR, ...folderName.split('/'));
88
-
89
- if (!existsSync(templateDir)) {
90
- throw new Error('Template directory not found. Ensure [template] is in sparse checkout.');
91
- }
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;
92
81
 
93
- cpSync(templateDir, targetDir, { recursive: true });
94
- walkAndReplace(targetDir, '$TEAM_NS', teamNS);
95
- }
82
+ const counts = { ADD: 0, UPDATE: 0, CONFLICT: 0 };
83
+ for (const a of typeActions) counts[a.action] = (counts[a.action] || 0) + 1;
96
84
 
97
- function walkAndReplace(dir, search, replace) {
98
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
99
- const fullPath = join(dir, entry.name);
100
- if (entry.isDirectory()) {
101
- walkAndReplace(fullPath, search, replace);
102
- } else if (entry.name.endsWith('.md')) {
103
- const content = readFileSync(fullPath, 'utf8');
104
- if (content.includes(search)) {
105
- writeFileSync(fullPath, content.replaceAll(search, replace));
106
- }
107
- }
108
- }
109
- }
110
-
111
- /**
112
- * Ensure platform/docs symlink points to content/ in the git repo root.
113
- */
114
- function ensureDocsSymlink() {
115
- const docsLink = join(GLOBAL_AW_DIR, 'platform', 'docs');
116
- const contentDir = join(GLOBAL_GIT_DIR, 'content');
117
-
118
- if (!existsSync(contentDir)) return;
119
-
120
- try {
121
- // Check if already a correct symlink
122
- if (existsSync(docsLink) && lstatSync(docsLink).isSymbolicLink()) return;
123
-
124
- // Remove existing real directory (sparse checkout creates it with just README.md)
125
- if (existsSync(docsLink)) {
126
- rmSync(docsLink, { recursive: true, force: true });
127
- }
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(', ')})` : '';
128
90
 
129
- mkdirSync(dirname(docsLink), { recursive: true });
130
- // Relative: .aw_repo/.aw_registry/platform/docs → ../../../content
131
- symlinkSync('../../../content', docsLink);
132
- } catch { /* best effort */ }
133
- }
134
-
135
- /**
136
- * Create the ~/.aw_registry symlink pointing to the .aw_registry/ subdir inside the git repo.
137
- */
138
- function ensureWorkspaceSymlink() {
139
- const target = join(GLOBAL_GIT_DIR, REGISTRY_DIR);
140
-
141
- if (existsSync(GLOBAL_AW_DIR)) {
142
- // Already exists — check if it's the right symlink
143
- try {
144
- if (lstatSync(GLOBAL_AW_DIR).isSymbolicLink()) return; // already a symlink, good
145
- } catch { /* */ }
146
- return; // exists as real dir, handled by caller (backup)
147
- }
148
-
149
- // Ensure parent dir for the symlink target exists
150
- if (!existsSync(target)) {
151
- mkdirSync(target, { recursive: true });
91
+ fmt.logSuccess(`${chalk.cyan(pattern)}: ${typeActions.length} ${type}${detail}`);
152
92
  }
153
-
154
- symlinkSync(target, GLOBAL_AW_DIR);
155
93
  }
156
94
 
95
+ const ALLOWED_NAMESPACES = ['platform', 'revex', 'mobile', 'commerce', 'leadgen', 'crm', 'marketplace', 'ai'];
96
+
157
97
  export async function initCommand(args) {
158
98
  const namespace = args['--namespace'] || null;
159
99
  let user = args['--user'] || '';
@@ -175,11 +115,12 @@ export async function initCommand(args) {
175
115
  ].join('\n'));
176
116
  }
177
117
 
118
+ // Parse team/sub-team
178
119
  const nsParts = namespace ? namespace.split('/') : [];
179
120
  const team = nsParts[0] || null;
180
121
  const subTeam = nsParts[1] || null;
181
- const teamNS = subTeam ? `${team}-${subTeam}` : team;
182
- 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
183
124
 
184
125
  if (team && !ALLOWED_NAMESPACES.includes(team)) {
185
126
  const list = ALLOWED_NAMESPACES.map(n => chalk.cyan(n)).join(', ');
@@ -210,59 +151,63 @@ export async function initCommand(args) {
210
151
  fmt.cancel(`Invalid sub-team '${subTeam}' — must match: ${SLUG_RE}`);
211
152
  }
212
153
 
213
- const cwd = process.cwd();
214
- const hasGitRepo = existsSync(GLOBAL_GIT_DIR) && existsSync(join(GLOBAL_GIT_DIR, '.git'));
215
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();
216
158
 
217
159
  // ── Fast path: already initialized → just pull + link ─────────────────
218
160
 
219
- if (hasGitRepo && hasConfig) {
161
+ if (isExisting) {
220
162
  const cfg = config.load(GLOBAL_AW_DIR);
163
+
164
+ // Add new sub-team if not already tracked
221
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
+ }
222
180
 
223
- await withLock(GLOBAL_AW_DIR, async () => {
224
- if (!silent) fmt.logStep('Syncing registry...');
225
- try {
226
- const { updated, conflicts } = gitPull(GLOBAL_AW_DIR);
227
- if (conflicts && !silent) {
228
- fmt.logWarn('Merge conflicts detected — resolve manually in ~/.aw_repo/');
229
- }
230
- } catch (e) {
231
- if (!silent) fmt.logWarn(`Pull failed: ${e.message}`);
232
- }
233
-
234
- if (isNewSubTeam) {
235
- if (!silent) fmt.logStep(`Adding sub-team ${chalk.cyan(folderName)}...`);
236
-
237
- const nsExists = gitLsRemote(GLOBAL_AW_DIR, folderName);
238
- if (nsExists) {
239
- gitSparseAdd(GLOBAL_AW_DIR, [`${REGISTRY_DIR}/${folderName}`]);
240
- } else {
241
- gitSparseAdd(GLOBAL_AW_DIR, [`${REGISTRY_DIR}/[template]`]);
242
- try { gitPull(GLOBAL_AW_DIR); } catch { /* already up to date */ }
243
- bootstrapFromTemplate(folderName, teamNS);
244
- // Remove [template] from sparse checkout
245
- const currentPaths = gitSparseList(GLOBAL_AW_DIR);
246
- const cleanPaths = currentPaths.filter(p => !p.includes('[template]'));
247
- if (cleanPaths.length !== currentPaths.length) {
248
- gitSparseSet(GLOBAL_AW_DIR, cleanPaths);
249
- }
250
- }
251
-
252
- config.addPattern(GLOBAL_AW_DIR, folderName);
253
- }
254
- }, { skipIfLocked: silent });
255
-
256
- ensureDocsSymlink();
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.
257
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)
258
202
  linkWorkspace(HOME);
259
203
  generateCommands(HOME);
260
- copyInstructions(HOME, null, freshCfg?.namespace || team);
204
+ copyInstructions(HOME, null, freshCfg?.namespace || team) || [];
261
205
  initAwDocs(HOME);
262
- setupMcp(HOME, freshCfg?.namespace || team);
206
+ setupMcp(HOME, freshCfg?.namespace || team) || [];
263
207
  if (cwd !== HOME) setupMcp(cwd, freshCfg?.namespace || team);
264
208
  installGlobalHooks();
265
209
 
210
+ // Link current project if needed
266
211
  if (cwd !== HOME && !existsSync(join(cwd, '.aw_registry'))) {
267
212
  try {
268
213
  symlinkSync(GLOBAL_AW_DIR, join(cwd, '.aw_registry'));
@@ -286,23 +231,6 @@ export async function initCommand(args) {
286
231
 
287
232
  // ── Full init: first time setup ───────────────────────────────────────
288
233
 
289
- // Backup old non-git ~/.aw_registry/ if it's a real directory (not a symlink)
290
- if (existsSync(GLOBAL_AW_DIR)) {
291
- try {
292
- if (!lstatSync(GLOBAL_AW_DIR).isSymbolicLink()) {
293
- const backupDir = `${GLOBAL_AW_DIR}.bak-${new Date().toISOString().slice(0, 10)}`;
294
- fmt.logWarn(`Existing ~/.aw_registry/ directory found — backing up to ${backupDir}`);
295
- renameSync(GLOBAL_AW_DIR, backupDir);
296
- }
297
- } catch { /* */ }
298
- }
299
-
300
- // Backup old ~/.aw_repo/ if it exists but isn't a git repo
301
- if (existsSync(GLOBAL_GIT_DIR) && !existsSync(join(GLOBAL_GIT_DIR, '.git'))) {
302
- const backupDir = `${GLOBAL_GIT_DIR}.bak-${new Date().toISOString().slice(0, 10)}`;
303
- renameSync(GLOBAL_GIT_DIR, backupDir);
304
- }
305
-
306
234
  // Auto-detect user
307
235
  if (!user) {
308
236
  try {
@@ -310,74 +238,46 @@ export async function initCommand(args) {
310
238
  } catch { /* git not configured */ }
311
239
  }
312
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
+
313
246
  fmt.note([
314
247
  `${chalk.dim('source:')} ~/.aw_registry/`,
315
248
  folderName ? `${chalk.dim('namespace:')} ${folderName}` : `${chalk.dim('namespace:')} ${chalk.dim('none')}`,
316
- user ? `${chalk.dim('user:')} ${user}` : null,
249
+ user ? `${chalk.dim('user:')} ${cfg.user}` : null,
317
250
  `${chalk.dim('version:')} v${VERSION}`,
318
251
  ].filter(Boolean).join('\n'), 'Config created');
319
252
 
320
- // Step 1: Clone registry to ~/.aw_repo/
253
+ // Step 2: Pull registry content (parallel)
321
254
  const s = fmt.spinner();
322
255
  const pullTargets = folderName ? `platform + ${folderName}` : 'platform';
323
- s.start(`Cloning registry (${pullTargets})...`);
256
+ s.start(`Pulling ${pullTargets}...`);
324
257
 
325
- 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
+ }
326
266
 
267
+ let pullResults;
327
268
  try {
328
- gitInit(REGISTRY_REPO, REGISTRY_BASE_BRANCH, GLOBAL_GIT_DIR, initialPaths);
329
- s.stop('Repository cloned');
269
+ pullResults = await Promise.all(pullJobs);
270
+ s.stop(`Pulled ${pullTargets}`);
330
271
  } catch (e) {
331
- s.stop(chalk.red('Clone failed'));
272
+ s.stop(chalk.red('Pull failed'));
332
273
  fmt.cancel(e.message);
333
274
  }
334
275
 
335
- // Step 2: Symlink ~/.aw_registry ~/.aw_repo/.aw_registry
336
- ensureWorkspaceSymlink();
337
-
338
- // Step 3: Add git excludes for local config files
339
- addGitExcludes(GLOBAL_GIT_DIR, [
340
- `${REGISTRY_DIR}/.sync-config.json`,
341
- `${REGISTRY_DIR}/.aw-manifest.json`,
342
- '.aw-lock/',
343
- ]);
344
-
345
- // Step 4: Write config (goes into the symlinked dir)
346
- const newCfg = config.create(GLOBAL_AW_DIR, { namespace: team, user });
347
-
348
- // Step 5: Add namespace via sparse-checkout
349
- if (folderName) {
350
- const s2 = fmt.spinner();
351
- s2.start(`Setting up namespace ${chalk.cyan(folderName)}...`);
352
-
353
- const nsExists = gitLsRemote(GLOBAL_AW_DIR, folderName);
354
- if (nsExists) {
355
- gitSparseAdd(GLOBAL_AW_DIR, [`${REGISTRY_DIR}/${folderName}`]);
356
- s2.stop(`Namespace ${chalk.cyan(folderName)} checked out`);
357
- } else {
358
- // Temporarily add [template] to sparse checkout, bootstrap, then remove it
359
- gitSparseAdd(GLOBAL_AW_DIR, [`${REGISTRY_DIR}/[template]`]);
360
- bootstrapFromTemplate(folderName, teamNS);
361
- // Remove [template] from sparse checkout — no longer needed
362
- const currentPaths = gitSparseList(GLOBAL_AW_DIR);
363
- const cleanPaths = currentPaths.filter(p => !p.includes('[template]'));
364
- if (cleanPaths.length !== currentPaths.length) {
365
- gitSparseSet(GLOBAL_AW_DIR, cleanPaths);
366
- }
367
- s2.stop(`Namespace ${chalk.cyan(folderName)} bootstrapped from template`);
368
- }
369
-
370
- config.addPattern(GLOBAL_AW_DIR, folderName);
371
- }
372
-
373
- if (!newCfg.include.includes('platform')) {
374
- config.addPattern(GLOBAL_AW_DIR, 'platform');
276
+ for (const { pattern, actions } of pullResults) {
277
+ printPullSummary(pattern, actions);
375
278
  }
376
279
 
377
- // Step 6: Docs symlink
378
- ensureDocsSymlink();
379
-
380
- // Step 7: Link IDE dirs + setup tasks
280
+ // Step 3: Link IDE dirs + setup tasks
381
281
  fmt.logStep('Linking IDE symlinks...');
382
282
  linkWorkspace(HOME);
383
283
  generateCommands(HOME);
@@ -388,7 +288,7 @@ export async function initCommand(args) {
388
288
  const hooksInstalled = installGlobalHooks();
389
289
  installIdeTasks();
390
290
 
391
- // Step 8: Symlink in current directory
291
+ // Step 4: Symlink in current directory if it's a git repo
392
292
  if (cwd !== HOME && !existsSync(join(cwd, '.aw_registry'))) {
393
293
  try {
394
294
  symlinkSync(GLOBAL_AW_DIR, join(cwd, '.aw_registry'));
@@ -396,12 +296,11 @@ export async function initCommand(args) {
396
296
  } catch { /* best effort */ }
397
297
  }
398
298
 
399
- // Step 9: Write install manifest for nuke cleanup
299
+ // Step 5: Write manifest for nuke cleanup
400
300
  const manifest = {
401
- version: 2,
301
+ version: 1,
402
302
  installedAt: new Date().toISOString(),
403
303
  globalDir: GLOBAL_AW_DIR,
404
- gitDir: GLOBAL_GIT_DIR,
405
304
  createdFiles: [
406
305
  ...instructionFiles.map(p => p.startsWith(HOME) ? p.slice(HOME.length + 1) : p),
407
306
  ...mcpFiles.map(p => p.startsWith(HOME) ? p.slice(HOME.length + 1) : p),
@@ -410,12 +309,14 @@ export async function initCommand(args) {
410
309
  };
411
310
  saveManifest(manifest);
412
311
 
312
+ // Offer to update if a newer version is available
413
313
  await promptUpdate(await args._updateCheck);
414
314
 
315
+ // Done
415
316
  fmt.outro([
416
317
  'Install complete',
417
318
  '',
418
- ` ${chalk.green('✓')} Source of truth: ~/.aw_registry/ (git)`,
319
+ ` ${chalk.green('✓')} Source of truth: ~/.aw_registry/`,
419
320
  ` ${chalk.green('✓')} IDE integration: ~/.claude/, ~/.cursor/, ~/.codex/`,
420
321
  hooksInstalled ? ` ${chalk.green('✓')} Git hooks: auto-sync on pull/clone (core.hooksPath)` : null,
421
322
  ` ${chalk.green('✓')} IDE task: auto-sync on workspace open`,
package/commands/nuke.mjs CHANGED
@@ -286,20 +286,8 @@ export function nukeCommand(args) {
286
286
  } catch { /* not installed via npm or no permissions */ }
287
287
  }
288
288
 
289
- // 9. Remove ~/.aw_repo/ (git clone) and ~/.aw_registry/ (symlink or dir)
290
- const GLOBAL_GIT_DIR = join(HOME, '.aw_repo');
291
- if (existsSync(GLOBAL_GIT_DIR)) {
292
- rmSync(GLOBAL_GIT_DIR, { recursive: true, force: true });
293
- fmt.logStep('Removed ~/.aw_repo/');
294
- }
295
- // Remove ~/.aw_registry/ — may be a symlink (new) or real dir (legacy)
296
- try {
297
- if (lstatSync(GLOBAL_AW_DIR).isSymbolicLink()) {
298
- unlinkSync(GLOBAL_AW_DIR);
299
- } else {
300
- rmSync(GLOBAL_AW_DIR, { recursive: true, force: true });
301
- }
302
- } 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 });
303
291
  fmt.logStep('Removed ~/.aw_registry/');
304
292
 
305
293
  fmt.outro([