@ghl-ai/aw 0.1.35 → 0.1.36-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/commands/pull.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // commands/pull.mjs — Pull content from registry
1
+ // commands/pull.mjs — Pull content from registry using persistent git clone
2
2
 
3
3
  import { mkdirSync, existsSync, readdirSync, copyFileSync, unlinkSync, rmdirSync } from 'node:fs';
4
4
  import { join, relative } from 'node:path';
@@ -7,37 +7,20 @@ import { execSync } from 'node:child_process';
7
7
  import * as config from '../config.mjs';
8
8
  import * as fmt from '../fmt.mjs';
9
9
  import { chalk } from '../fmt.mjs';
10
- import { sparseCheckout, sparseCheckoutAsync, cleanup, includeToSparsePaths } from '../git.mjs';
11
- import { REGISTRY_DIR, DOCS_SOURCE_DIR } from '../constants.mjs';
12
- import { walkRegistryTree } from '../registry.mjs';
13
- import { matchesAny } from '../glob.mjs';
14
- import { computePlan } from '../plan.mjs';
15
- import { applyActions } from '../apply.mjs';
16
- import { update as updateManifest } from '../manifest.mjs';
17
- import { resolveInput } from '../paths.mjs';
10
+ import { fetchAndMerge, addToSparseCheckout, isValidClone } from '../git.mjs';
11
+ import { REGISTRY_DIR, REGISTRY_REPO, DOCS_SOURCE_DIR } from '../constants.mjs';
18
12
  import { linkWorkspace } from '../link.mjs';
19
13
  import { generateCommands, copyInstructions } from '../integrate.mjs';
20
14
 
21
- // Filter out top-level platform CLI meta-commands (drop, pull, push, etc.)
22
- // but keep domain-specific commands (platform/design/commands/, platform/infra/commands/).
23
- function filterActions(actions, pattern) {
24
- if (pattern !== 'platform') return actions;
25
- return actions.filter(a => !(a.type === 'commands' && a.namespacePath === 'platform'));
26
- }
15
+ const HOME = homedir();
16
+ const AW_HOME = join(HOME, '.aw');
17
+ const GLOBAL_AW_DIR = join(HOME, '.aw_registry');
27
18
 
28
19
  export async function pullCommand(args) {
29
20
  const input = args._positional?.[0] || '';
30
21
  const cwd = process.cwd();
31
- const GLOBAL_AW_DIR = join(homedir(), '.aw_registry');
32
- const localDir = join(cwd, '.aw_registry');
33
- const workspaceDir = args._workspaceDir || (existsSync(join(localDir, '.sync-config.json')) ? localDir : GLOBAL_AW_DIR);
34
- const dryRun = args['--dry-run'] === true;
35
- const verbose = args['-v'] === true || args['--verbose'] === true;
36
22
  const silent = args['--silent'] === true || args._silent === true;
37
- let renameNamespace = args._renameNamespace || null;
38
- const teamNSOverride = args._teamNS || null;
39
23
 
40
- // Silent mode: wrap fmt to suppress all output and exit cleanly on errors
41
24
  const log = {
42
25
  cancel: silent ? () => { process.exit(0); } : fmt.cancel,
43
26
  logInfo: silent ? () => {} : fmt.logInfo,
@@ -50,304 +33,82 @@ export async function pullCommand(args) {
50
33
  spinner: silent ? () => ({ start: () => {}, stop: () => {} }) : fmt.spinner,
51
34
  };
52
35
 
53
- // No args = re-pull everything in sync config (parallel)
54
- if (!input) {
55
- const cfg = config.load(workspaceDir);
56
- if (!cfg) log.cancel('No .sync-config.json found. Run: aw init');
57
- if (cfg.include.length === 0) {
58
- log.cancel('Nothing to pull. Add paths first:\n\n aw pull <path>');
59
- }
60
- log.logInfo(`Pulling ${chalk.cyan(cfg.include.length)} synced path${cfg.include.length > 1 ? 's' : ''}...`);
61
- const pullJobs = cfg.include.map(p => {
62
- const isTeamNs = p !== 'platform';
63
- const derivedTeamNS = isTeamNs ? p.replace(/\//g, '-') : undefined;
64
- return pullAsync({
65
- ...args,
66
- _positional: [isTeamNs ? '[template]' : p],
67
- _workspaceDir: workspaceDir,
68
- _skipIntegrate: true,
69
- _renameNamespace: isTeamNs ? p : undefined,
70
- _teamNS: derivedTeamNS,
71
- });
72
- });
73
- await Promise.all(pullJobs);
74
- // Post-pull IDE integration (once after all paths pulled)
75
- linkWorkspace(cwd);
76
- generateCommands(cwd);
77
- copyInstructions(cwd, null, cfg.namespace);
78
- return;
79
- }
80
-
81
- // Resolve input — accepts both local and registry paths
82
- const resolved = resolveInput(input, workspaceDir);
83
- let pattern = resolved.registryPath;
84
-
85
- if (!pattern) {
86
- log.cancel(`Could not resolve "${input}" to a registry path`);
87
- }
36
+ const repoUrl = `https://github.com/${REGISTRY_REPO}.git`;
37
+ const hasClone = isValidClone(AW_HOME, repoUrl);
88
38
 
89
- // Ensure workspace exists
90
- if (!existsSync(workspaceDir)) {
91
- mkdirSync(workspaceDir, { recursive: true });
39
+ if (!hasClone) {
40
+ log.cancel('Registry not initialized. Run: aw init');
41
+ return;
92
42
  }
93
43
 
94
- // Load config
95
- const cfg = config.load(workspaceDir);
44
+ const cfg = config.load(GLOBAL_AW_DIR);
96
45
  if (!cfg) {
97
46
  log.cancel('No .sync-config.json found. Run: aw init');
47
+ return;
98
48
  }
99
49
 
100
- // Fetch from registry
101
- const s = log.spinner();
102
- s.start('Fetching from registry...');
103
-
104
- // When pulling from template with rename, also fetch the actual namespace —
105
- // if it exists in the registry, use it directly instead of the template.
106
- const pathsToFetch = [pattern];
107
- if (renameNamespace && pattern === '[template]') {
108
- pathsToFetch.push(renameNamespace);
50
+ // If input is a new namespace to add
51
+ if (input && input !== 'platform') {
52
+ const sparsePath = `.aw_registry/${input}`;
53
+ if (!cfg.include.includes(input)) {
54
+ log.logStep(`Adding namespace ${chalk.cyan(input)} to sparse checkout...`);
55
+ addToSparseCheckout(AW_HOME, [sparsePath, 'content']);
56
+ config.addPattern(GLOBAL_AW_DIR, input);
57
+ }
109
58
  }
110
- const sparsePaths = includeToSparsePaths(pathsToFetch);
111
- let tempDir;
59
+
60
+ // Fetch + merge latest
61
+ const s = log.spinner();
62
+ s.start('Fetching latest from registry...');
63
+ let fetchResult = { updated: false, conflicts: [] };
112
64
  try {
113
- tempDir = sparseCheckout(cfg.repo, sparsePaths);
65
+ fetchResult = fetchAndMerge(AW_HOME);
66
+ s.stop(fetchResult.updated ? 'Registry updated' : 'Already up to date');
114
67
  } catch (e) {
115
- s.stop(chalk.red('Fetch failed'));
116
- log.cancel(e.message);
68
+ s.stop(chalk.yellow('Fetch failed'));
69
+ if (!silent) log.logWarn(`Fetch error: ${e.message}`);
117
70
  }
118
71
 
119
- try {
120
- const regBase = join(tempDir, REGISTRY_DIR);
121
-
122
- // Check if actual namespace exists in the registry
123
- if (renameNamespace && pattern === '[template]') {
124
- const actualNsTopDir = renameNamespace.split('/')[0];
125
- const actualNsPath = join(regBase, actualNsTopDir);
126
- if (existsSync(actualNsPath)) {
127
- const fullNsPath = join(regBase, ...renameNamespace.split('/'));
128
- if (existsSync(fullNsPath) && listDirs(fullNsPath).length > 0) {
129
- pattern = renameNamespace;
130
- renameNamespace = null; // No rename needed — using actual content
131
- }
132
- }
133
- }
134
-
135
- const registryDirs = [];
136
- if (existsSync(regBase)) {
137
- for (const name of listDirs(regBase)) {
138
- registryDirs.push({ name, path: join(regBase, name) });
139
- }
140
- }
141
-
142
- if (registryDirs.length === 0) {
143
- s.stop(chalk.red('Not found'));
144
- if (silent) return;
145
- log.cancel(`Nothing found in registry for ${chalk.cyan(pattern)}`);
146
- }
147
-
148
- // Rename namespace if requested (e.g., [template] → dev)
149
- if (renameNamespace) {
150
- for (const rd of registryDirs) {
151
- if (rd.name === pattern) {
152
- rd.name = renameNamespace;
153
- }
154
- }
155
- // Also remap pattern for include filter matching
156
- pattern = renameNamespace;
157
- }
158
-
159
- // Validate pattern matches actual content
160
- let hasMatch = false;
161
- for (const { name, path } of registryDirs) {
162
- const entries = walkRegistryTree(path, name);
163
- if (entries.some(e => matchesAny(e.registryPath, [pattern]))) {
164
- hasMatch = true;
165
- break;
166
- }
167
- }
168
-
169
- if (!hasMatch) {
170
- s.stop(chalk.red('Not found'));
171
- cleanup(tempDir);
172
- if (silent) return;
173
- log.cancel(`Nothing found in registry for ${chalk.cyan(pattern)}\n\n Check the pattern exists in the registry repo.`);
174
- }
175
-
176
- const fetched = registryDirs.map(d => chalk.cyan(d.name)).join(', ');
177
- s.stop(`Fetched ${fetched}`);
178
-
179
- // Add to config if not already there
180
- if (!cfg.include.includes(pattern)) {
181
- config.addPattern(workspaceDir, pattern);
182
- log.logSuccess(`Added ${chalk.cyan(pattern)} to config`);
183
- }
184
-
185
- // Compute plan
186
- const { actions: rawActions } = computePlan(registryDirs, workspaceDir, [pattern], {
187
- skipOrphans: false,
188
- });
189
- const actions = filterActions(rawActions, pattern);
190
-
191
- if (dryRun) {
192
- printDryRun(actions, verbose);
193
- return;
194
- }
195
-
196
- // Apply
197
- const s2 = log.spinner();
198
- s2.start('Applying changes...');
199
- const conflictCount = applyActions(actions, { teamNS: teamNSOverride || renameNamespace || undefined });
200
- updateManifest(workspaceDir, actions, cfg.namespace, { fromTemplate: !!renameNamespace });
201
- s2.stop('Changes applied');
202
-
203
- // Copy root-level registry files (e.g. AW-PROTOCOL.md) that are fetched via
204
- // sparse checkout but never processed by walkRegistryTree (dirs-only pipeline).
205
- const ROOT_REGISTRY_FILES = ['AW-PROTOCOL.md'];
206
- for (const fname of ROOT_REGISTRY_FILES) {
207
- const src = join(regBase, fname);
208
- if (existsSync(src)) {
209
- copyFileSync(src, join(workspaceDir, fname));
210
- }
211
- }
212
-
213
- // Sync docs from repo content/ into platform/docs/ (markdown only, skip images)
214
- const contentSrc = join(tempDir, DOCS_SOURCE_DIR);
215
- if (existsSync(contentSrc)) {
216
- const docsDest = join(workspaceDir, 'platform', 'docs');
217
- syncMarkdownTree(contentSrc, docsDest);
218
- }
219
-
220
- // MCP registration (second-class — skip if not available)
221
- if (cfg.namespace) {
222
- registerMcp(cfg.namespace);
223
- }
72
+ if (fetchResult.conflicts.length > 0) {
73
+ log.logWarn(`Conflicts in: ${fetchResult.conflicts.join(', ')}`);
74
+ }
224
75
 
225
- // Summary
226
- printSummary(actions, verbose, conflictCount);
76
+ // Sync content/ → platform/docs/
77
+ syncDocs(AW_HOME, GLOBAL_AW_DIR);
227
78
 
228
- // Post-pull IDE integration (skip if called from batch re-pull)
229
- if (!args._skipIntegrate) {
79
+ // Re-link IDE dirs
80
+ if (!args._skipIntegrate) {
81
+ linkWorkspace(HOME);
82
+ generateCommands(HOME);
83
+ copyInstructions(HOME, null, cfg.namespace);
84
+ if (cwd !== HOME) {
230
85
  linkWorkspace(cwd);
231
86
  generateCommands(cwd);
232
- copyInstructions(cwd, null, cfg.namespace);
233
87
  }
88
+ }
234
89
 
235
- } finally {
236
- cleanup(tempDir);
90
+ if (!silent) {
91
+ registerMcp(cfg.namespace);
92
+ log.outro('Pull complete');
237
93
  }
238
94
  }
239
95
 
240
96
  /**
241
- * Async pull for parallel use by init. Runs git fetch asynchronously,
242
- * then applies changes synchronously. Returns a summary object instead
243
- * of printing directly — the caller controls output.
97
+ * Sync ~/.aw/content/ markdown files ~/.aw_registry/platform/docs/
244
98
  */
245
- export async function pullAsync(args) {
246
- const input = args._positional?.[0] || '';
247
- const workspaceDir = args._workspaceDir;
248
- let renameNamespace = args._renameNamespace || null;
249
- const teamNSOverride = args._teamNS || null;
250
-
251
- const resolved = resolveInput(input, workspaceDir);
252
- let pattern = resolved.registryPath;
253
- if (!pattern) throw new Error(`Could not resolve "${input}" to a registry path`);
254
-
255
- if (!existsSync(workspaceDir)) mkdirSync(workspaceDir, { recursive: true });
256
-
257
- const cfg = config.load(workspaceDir);
258
- if (!cfg) throw new Error('No .sync-config.json found');
259
-
260
- // When pulling from template with rename, also fetch the actual namespace —
261
- // if it exists in the registry, use it directly instead of the template.
262
- const pathsToFetch = [pattern];
263
- if (renameNamespace && pattern === '[template]') {
264
- pathsToFetch.push(renameNamespace);
265
- }
266
- const sparsePaths = includeToSparsePaths(pathsToFetch);
267
- const tempDir = await sparseCheckoutAsync(cfg.repo, sparsePaths);
268
-
269
- try {
270
- const regBase = join(tempDir, REGISTRY_DIR);
271
-
272
- // Check if actual namespace exists in the registry
273
- let useActualNamespace = false;
274
- if (renameNamespace && pattern === '[template]') {
275
- const actualNsTopDir = renameNamespace.split('/')[0];
276
- const actualNsPath = join(regBase, actualNsTopDir);
277
- if (existsSync(actualNsPath)) {
278
- // Verify the full namespace path has content (not just the top-level team dir)
279
- const fullNsPath = join(regBase, ...renameNamespace.split('/'));
280
- if (existsSync(fullNsPath) && listDirs(fullNsPath).length > 0) {
281
- useActualNamespace = true;
282
- pattern = renameNamespace;
283
- renameNamespace = null; // No rename needed — using actual content
284
- }
285
- }
286
- }
287
-
288
- const registryDirs = [];
289
- if (existsSync(regBase)) {
290
- for (const name of listDirs(regBase)) {
291
- registryDirs.push({ name, path: join(regBase, name) });
292
- }
293
- }
294
-
295
- if (registryDirs.length === 0) {
296
- throw new Error(`Nothing found in registry for ${pattern}`);
297
- }
298
-
299
- if (renameNamespace) {
300
- for (const rd of registryDirs) {
301
- if (rd.name === pattern) rd.name = renameNamespace;
302
- }
303
- pattern = renameNamespace;
304
- }
305
-
306
- let hasMatch = false;
307
- for (const { name, path } of registryDirs) {
308
- const entries = walkRegistryTree(path, name);
309
- if (entries.some(e => matchesAny(e.registryPath, [pattern]))) {
310
- hasMatch = true;
311
- break;
312
- }
313
- }
314
-
315
- if (!hasMatch) {
316
- throw new Error(`Nothing found in registry for ${pattern}`);
317
- }
318
-
319
- if (!cfg.include.includes(pattern)) {
320
- config.addPattern(workspaceDir, pattern);
321
- }
322
-
323
- const { actions: rawActions } = computePlan(registryDirs, workspaceDir, [pattern], { skipOrphans: false });
324
- const actions = filterActions(rawActions, pattern);
325
- const conflictCount = applyActions(actions, { teamNS: teamNSOverride || renameNamespace || undefined });
326
- updateManifest(workspaceDir, actions, cfg.namespace, { fromTemplate: !!renameNamespace });
327
-
328
- const ROOT_REGISTRY_FILES = ['AW-PROTOCOL.md'];
329
- for (const fname of ROOT_REGISTRY_FILES) {
330
- const src = join(regBase, fname);
331
- if (existsSync(src)) copyFileSync(src, join(workspaceDir, fname));
332
- }
333
-
334
- // Sync docs from repo content/ into platform/docs/ (markdown only, skip images)
335
- const contentSrc = join(tempDir, DOCS_SOURCE_DIR);
336
- if (existsSync(contentSrc)) {
337
- const docsDest = join(workspaceDir, 'platform', 'docs');
338
- syncMarkdownTree(contentSrc, docsDest);
339
- }
340
-
341
- return { pattern, actions, conflictCount };
342
- } finally {
343
- cleanup(tempDir);
344
- }
99
+ function syncDocs(awHome, globalAwDir) {
100
+ const contentSrc = join(awHome, DOCS_SOURCE_DIR);
101
+ if (!existsSync(contentSrc)) return;
102
+ const docsDest = join(globalAwDir, 'platform', 'docs');
103
+ syncMarkdownTree(contentSrc, docsDest);
345
104
  }
346
105
 
347
- function listDirs(dir) {
348
- return readdirSync(dir, { withFileTypes: true })
349
- .filter(d => d.isDirectory() && !d.name.startsWith('.'))
350
- .map(d => d.name);
106
+ /**
107
+ * pullAsync kept for backward compat; now delegates to pullCommand.
108
+ */
109
+ export async function pullAsync(args) {
110
+ await pullCommand({ ...args, _skipIntegrate: true });
111
+ return { pattern: args._positional?.[0] || '', actions: [], conflictCount: 0 };
351
112
  }
352
113
 
353
114
  /**
@@ -377,7 +138,6 @@ function syncMarkdownTree(src, dest) {
377
138
  const remotePaths = collectMarkdownPaths(src, src);
378
139
  const localPaths = collectMarkdownPaths(dest, dest);
379
140
 
380
- // Copy new and updated files
381
141
  for (const rel of remotePaths) {
382
142
  const srcPath = join(src, rel);
383
143
  const destPath = join(dest, rel);
@@ -385,7 +145,6 @@ function syncMarkdownTree(src, dest) {
385
145
  copyFileSync(srcPath, destPath);
386
146
  }
387
147
 
388
- // Delete local files not on remote
389
148
  for (const rel of localPaths) {
390
149
  if (!remotePaths.has(rel)) {
391
150
  const destPath = join(dest, rel);
@@ -393,7 +152,6 @@ function syncMarkdownTree(src, dest) {
393
152
  }
394
153
  }
395
154
 
396
- // Prune empty directories (bottom-up)
397
155
  function pruneEmpty(dir) {
398
156
  if (!existsSync(dir)) return;
399
157
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
@@ -416,74 +174,3 @@ function registerMcp(namespace) {
416
174
  fmt.logWarn('MCP registration failed (pull still successful)');
417
175
  }
418
176
  }
419
-
420
- function printDryRun(actions, verbose) {
421
- const counts = { ADD: 0, UPDATE: 0, CONFLICT: 0, ORPHAN: 0, UNCHANGED: 0 };
422
- const lines = [];
423
-
424
- for (const type of ['agents', 'skills', 'commands', 'evals']) {
425
- const items = actions.filter(a => a.type === type);
426
- if (items.length === 0) continue;
427
-
428
- lines.push(chalk.bold(`${type}/`));
429
- for (const act of items.sort((a, b) => a.targetFilename.localeCompare(b.targetFilename))) {
430
- counts[act.action] = (counts[act.action] || 0) + 1;
431
- if (!verbose && act.action === 'UNCHANGED') continue;
432
- const ns = act.namespacePath ? chalk.dim(` [${act.namespacePath}]`) : '';
433
- lines.push(` ${fmt.actionLabel(act.action)} ${act.targetFilename}${ns}`);
434
- }
435
- }
436
-
437
- if (lines.length > 0) {
438
- fmt.note(lines.join('\n'), 'Dry Run');
439
- }
440
-
441
- fmt.logInfo(`Summary: ${fmt.countSummary(counts)}`);
442
- fmt.logWarn('No files modified (--dry-run)');
443
- }
444
-
445
- function printSummary(actions, verbose, conflictCount) {
446
- const conflicts = actions.filter(a => a.action === 'CONFLICT');
447
-
448
- for (const type of ['agents', 'skills', 'commands', 'evals']) {
449
- const typeActions = actions.filter(a => a.type === type);
450
- if (typeActions.length === 0) continue;
451
-
452
- const counts = { ADD: 0, UPDATE: 0, CONFLICT: 0, ORPHAN: 0, UNCHANGED: 0 };
453
- for (const a of typeActions) counts[a.action]++;
454
-
455
- const parts = [];
456
- if (counts.ADD > 0) parts.push(chalk.green(`${counts.ADD} new`));
457
- if (counts.UPDATE > 0) parts.push(chalk.cyan(`${counts.UPDATE} updated`));
458
- if (counts.CONFLICT > 0) parts.push(chalk.red(`${counts.CONFLICT} conflict`));
459
- if (counts.ORPHAN > 0) parts.push(chalk.yellow(`${counts.ORPHAN} removed`));
460
- const detail = parts.length > 0 ? ` (${parts.join(', ')})` : '';
461
-
462
- fmt.logSuccess(`${typeActions.length} ${type} pulled${detail}`);
463
-
464
- if (verbose) {
465
- for (const a of typeActions.filter(a => a.action !== 'UNCHANGED')) {
466
- const ns = a.namespacePath ? chalk.dim(` [${a.namespacePath}]`) : '';
467
- fmt.logMessage(` ${fmt.actionLabel(a.action)} ${a.targetFilename}${ns}`);
468
- }
469
- }
470
- }
471
-
472
- if (conflicts.length > 0) {
473
- const conflictLines = conflicts.map(c => {
474
- return `${chalk.red('both modified:')} ${c.type}/${c.targetFilename}`;
475
- }).join('\n');
476
-
477
- fmt.note(
478
- conflictLines + '\n\n' +
479
- chalk.dim('Fix conflicts, then re-run pull to verify.\n') +
480
- chalk.dim('grep -r "<<<<<<< " .aw_registry/'),
481
- chalk.red('Merge Conflicts')
482
- );
483
-
484
- fmt.outro(chalk.red('Pull completed with conflicts — resolve and re-run'));
485
- process.exit(1);
486
- }
487
-
488
- fmt.outro('Pull complete');
489
- }