@ghl-ai/aw 0.1.34-beta.2 → 0.1.34-beta.20

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  // apply.mjs — Apply sync plan (file operations). Zero dependencies.
2
2
 
3
- import { readFileSync, writeFileSync, mkdirSync, cpSync, existsSync } from 'node:fs';
4
- import { dirname } from 'node:path';
3
+ import { readFileSync, writeFileSync, mkdirSync, cpSync, existsSync, unlinkSync, rmdirSync, readdirSync } from 'node:fs';
4
+ import { dirname, basename } from 'node:path';
5
5
 
6
6
  function writeConflictMarkers(filePath, localContent, registryContent) {
7
7
  writeFileSync(filePath, [
@@ -51,6 +51,25 @@ export function applyActions(actions, { teamNS } = {}) {
51
51
  }
52
52
 
53
53
  case 'ORPHAN':
54
+ // File was removed from registry — delete local copy
55
+ if (existsSync(act.targetPath)) {
56
+ try { unlinkSync(act.targetPath); } catch { /* best effort */ }
57
+ // Clean up empty parent dirs, stop at .aw_registry/ boundary
58
+ let dir = dirname(act.targetPath);
59
+ while (basename(dir) !== '.aw_registry') {
60
+ try {
61
+ const entries = readdirSync(dir);
62
+ if (entries.length === 0) {
63
+ rmdirSync(dir);
64
+ dir = dirname(dir);
65
+ } else {
66
+ break;
67
+ }
68
+ } catch { break; }
69
+ }
70
+ }
71
+ break;
72
+
54
73
  case 'UNCHANGED':
55
74
  break;
56
75
  }
package/commands/pull.mjs CHANGED
@@ -34,7 +34,7 @@ export async function pullCommand(args) {
34
34
  const dryRun = args['--dry-run'] === true;
35
35
  const verbose = args['-v'] === true || args['--verbose'] === true;
36
36
  const silent = args['--silent'] === true || args._silent === true;
37
- const renameNamespace = args._renameNamespace || null;
37
+ let renameNamespace = args._renameNamespace || null;
38
38
  const teamNSOverride = args._teamNS || null;
39
39
 
40
40
  // Silent mode: wrap fmt to suppress all output and exit cleanly on errors
@@ -101,7 +101,13 @@ export async function pullCommand(args) {
101
101
  const s = log.spinner();
102
102
  s.start('Fetching from registry...');
103
103
 
104
- const sparsePaths = includeToSparsePaths([pattern]);
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);
109
+ }
110
+ const sparsePaths = includeToSparsePaths(pathsToFetch);
105
111
  let tempDir;
106
112
  try {
107
113
  tempDir = sparseCheckout(cfg.repo, sparsePaths);
@@ -111,9 +117,22 @@ export async function pullCommand(args) {
111
117
  }
112
118
 
113
119
  try {
114
- const registryDirs = [];
115
120
  const regBase = join(tempDir, REGISTRY_DIR);
116
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 = [];
117
136
  if (existsSync(regBase)) {
118
137
  for (const name of listDirs(regBase)) {
119
138
  registryDirs.push({ name, path: join(regBase, name) });
@@ -165,7 +184,7 @@ export async function pullCommand(args) {
165
184
 
166
185
  // Compute plan
167
186
  const { actions: rawActions } = computePlan(registryDirs, workspaceDir, [pattern], {
168
- skipOrphans: true,
187
+ skipOrphans: false,
169
188
  });
170
189
  const actions = filterActions(rawActions, pattern);
171
190
 
@@ -226,7 +245,7 @@ export async function pullCommand(args) {
226
245
  export async function pullAsync(args) {
227
246
  const input = args._positional?.[0] || '';
228
247
  const workspaceDir = args._workspaceDir;
229
- const renameNamespace = args._renameNamespace || null;
248
+ let renameNamespace = args._renameNamespace || null;
230
249
  const teamNSOverride = args._teamNS || null;
231
250
 
232
251
  const resolved = resolveInput(input, workspaceDir);
@@ -238,13 +257,35 @@ export async function pullAsync(args) {
238
257
  const cfg = config.load(workspaceDir);
239
258
  if (!cfg) throw new Error('No .sync-config.json found');
240
259
 
241
- const sparsePaths = includeToSparsePaths([pattern]);
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);
242
267
  const tempDir = await sparseCheckoutAsync(cfg.repo, sparsePaths);
243
268
 
244
269
  try {
245
- const registryDirs = [];
246
270
  const regBase = join(tempDir, REGISTRY_DIR);
247
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 = [];
248
289
  if (existsSync(regBase)) {
249
290
  for (const name of listDirs(regBase)) {
250
291
  registryDirs.push({ name, path: join(regBase, name) });
@@ -279,7 +320,7 @@ export async function pullAsync(args) {
279
320
  config.addPattern(workspaceDir, pattern);
280
321
  }
281
322
 
282
- const { actions: rawActions } = computePlan(registryDirs, workspaceDir, [pattern], { skipOrphans: true });
323
+ const { actions: rawActions } = computePlan(registryDirs, workspaceDir, [pattern], { skipOrphans: false });
283
324
  const actions = filterActions(rawActions, pattern);
284
325
  const conflictCount = applyActions(actions, { teamNS: teamNSOverride || renameNamespace || undefined });
285
326
  updateManifest(workspaceDir, actions, cfg.namespace, { fromTemplate: !!renameNamespace });
@@ -377,6 +418,7 @@ function printSummary(actions, verbose, conflictCount) {
377
418
  if (counts.ADD > 0) parts.push(chalk.green(`${counts.ADD} new`));
378
419
  if (counts.UPDATE > 0) parts.push(chalk.cyan(`${counts.UPDATE} updated`));
379
420
  if (counts.CONFLICT > 0) parts.push(chalk.red(`${counts.CONFLICT} conflict`));
421
+ if (counts.ORPHAN > 0) parts.push(chalk.yellow(`${counts.ORPHAN} removed`));
380
422
  const detail = parts.length > 0 ? ` (${parts.join(', ')})` : '';
381
423
 
382
424
  fmt.logSuccess(`${typeActions.length} ${type} pulled${detail}`);
package/commands/push.mjs CHANGED
@@ -20,26 +20,56 @@ const PUSHABLE_TYPES = ['agents', 'skills', 'commands', 'evals'];
20
20
  * Returns array of { absPath, registryTarget, type, namespace, slug, isDir }.
21
21
  */
22
22
  function collectBatchFiles(folderAbsPath, workspaceDir) {
23
- // Determine the namespace name from path relative to workspaceDir
24
23
  const relPath = relative(workspaceDir, folderAbsPath);
25
- const nameSegments = relPath.split('/');
26
- const baseName = nameSegments[0];
27
-
28
- const entries = walkRegistryTree(folderAbsPath, baseName);
29
- return entries.map(entry => {
30
- // Build the registry target path for git
31
- const registryTarget = entry.type === 'skills'
32
- ? `${REGISTRY_DIR}/${entry.namespacePath}/${entry.type}/${entry.slug}/${entry.skillRelPath || entry.filename}`
33
- : `${REGISTRY_DIR}/${entry.namespacePath}/${entry.type}/${entry.filename}`;
34
- return {
35
- absPath: entry.sourcePath,
36
- registryTarget,
37
- type: entry.type,
38
- namespace: entry.namespacePath,
39
- slug: entry.slug,
40
- isDir: false,
41
- };
42
- });
24
+ const segments = relPath.split('/');
25
+
26
+ // Detect if user pointed at or inside a type dir (agents/, skills/, etc.).
27
+ // e.g. "revex/courses/core/evals" → typeFilter=evals, subPathFilter=null
28
+ // e.g. "revex/courses/core/evals/agents" → typeFilter=evals, subPathFilter="agents"
29
+ // e.g. "revex/courses/core/evals/agents/architect" typeFilter=evals, subPathFilter="agents/architect"
30
+ let typeFilter = null;
31
+ let subPathFilter = null;
32
+ let walkDir = folderAbsPath;
33
+ let walkBaseName = relPath;
34
+
35
+ for (let i = 0; i < segments.length; i++) {
36
+ if (PUSHABLE_TYPES.includes(segments[i])) {
37
+ // Everything before this segment is the namespace
38
+ const namespaceParts = segments.slice(0, i);
39
+ walkBaseName = namespaceParts.join('/');
40
+ // Walk from the namespace dir (parent of the type dir)
41
+ walkDir = join(workspaceDir, ...namespaceParts);
42
+ typeFilter = segments[i];
43
+ // Anything after the type dir is a sub-path filter (e.g., "agents/architect")
44
+ if (i + 1 < segments.length) {
45
+ subPathFilter = segments.slice(i + 1).join('/');
46
+ }
47
+ break;
48
+ }
49
+ }
50
+
51
+ const entries = walkRegistryTree(walkDir, walkBaseName);
52
+ return entries
53
+ .filter(entry => {
54
+ if (typeFilter && entry.type !== typeFilter) return false;
55
+ // For sub-path filtering (e.g., only evals under "agents" or "agents/architect")
56
+ if (subPathFilter && !entry.slug.startsWith(subPathFilter)) return false;
57
+ return true;
58
+ })
59
+ .map(entry => {
60
+ // Skills and evals both have nested slug subdirs (e.g. skills/slug/file, evals/agents/developer/file)
61
+ const registryTarget = (entry.type === 'skills' || entry.type === 'evals')
62
+ ? `${REGISTRY_DIR}/${entry.namespacePath}/${entry.type}/${entry.slug}/${entry.skillRelPath || entry.filename}`
63
+ : `${REGISTRY_DIR}/${entry.namespacePath}/${entry.type}/${entry.filename}`;
64
+ return {
65
+ absPath: entry.sourcePath,
66
+ registryTarget,
67
+ type: entry.type,
68
+ namespace: entry.namespacePath,
69
+ slug: entry.slug,
70
+ isDir: false,
71
+ };
72
+ });
43
73
  }
44
74
 
45
75
  /**
@@ -234,23 +264,29 @@ function pushFiles(files, { repo, dryRun, workspaceDir }) {
234
264
  const topNamespaces = [...new Set(files.map(f => f.namespace.split('/')[0]))];
235
265
  const codeownersPath = join(tempDir, 'CODEOWNERS');
236
266
  const newNamespaces = [];
267
+ const ghUser = getGitHubUser();
237
268
  for (const ns of topNamespaces) {
238
- if (isNewNamespaceInCodeowners(codeownersPath, ns)) {
269
+ if (ghUser && isNewNamespaceInCodeowners(codeownersPath, ns)) {
239
270
  newNamespaces.push(ns);
240
- const ghUser = getGitHubUser();
241
- if (ghUser && existsSync(codeownersPath)) {
242
- const line = `/${REGISTRY_DIR}/${ns}/ @${ghUser}\n`;
243
- appendFileSync(codeownersPath, line);
244
- }
271
+ // Create CODEOWNERS if it doesn't exist yet
272
+ const line = `/${REGISTRY_DIR}/${ns}/ @${ghUser}\n`;
273
+ appendFileSync(codeownersPath, line);
245
274
  }
246
275
  }
247
- if (newNamespaces.length > 0) {
276
+ if (newNamespaces.length > 0 && existsSync(codeownersPath)) {
248
277
  execSync('git add CODEOWNERS', { cwd: tempDir, stdio: 'pipe' });
249
278
  }
250
279
 
251
280
  // Stage all registry changes
252
281
  execSync(`git add "${REGISTRY_DIR}"`, { cwd: tempDir, stdio: 'pipe' });
253
282
 
283
+ // Check if there are actual changes to commit (files may already exist in remote)
284
+ const diffStatus = execSync('git diff --cached --name-only', { cwd: tempDir, encoding: 'utf8' }).trim();
285
+ if (!diffStatus) {
286
+ s2.stop('No changes');
287
+ fmt.cancel('Nothing to push — all files already exist in registry with identical content.');
288
+ }
289
+
254
290
  const prTitle = generatePrTitle(files);
255
291
  const commitMsg = files.length === 1
256
292
  ? `registry: add ${files[0].type}/${files[0].slug} to ${files[0].namespace}`
package/fmt.mjs CHANGED
@@ -81,7 +81,7 @@ export function actionLabel(action) {
81
81
  case 'UPDATE': return chalk.bgCyan.black(' UPD ');
82
82
  case 'SKIP': return chalk.bgYellow.black(' SKP ');
83
83
  case 'CONFLICT': return chalk.bgRed.white(' CON ');
84
- case 'ORPHAN': return chalk.bgYellow.black(' ORP ');
84
+ case 'ORPHAN': return chalk.bgRed.white(' DEL ');
85
85
  case 'UNCHANGED': return chalk.dim(' --- ');
86
86
  default: return chalk.dim(` ${action} `);
87
87
  }
@@ -94,6 +94,6 @@ export function countSummary(counts) {
94
94
  if (counts.UNCHANGED > 0) parts.push(chalk.dim(`${counts.UNCHANGED} unchanged`));
95
95
  if (counts.SKIP > 0) parts.push(chalk.yellow(`${counts.SKIP} skipped`));
96
96
  if (counts.CONFLICT > 0) parts.push(chalk.red(`${counts.CONFLICT} conflict`));
97
- if (counts.ORPHAN > 0) parts.push(chalk.yellow(`${counts.ORPHAN} orphan`));
97
+ if (counts.ORPHAN > 0) parts.push(chalk.red(`${counts.ORPHAN} removed`));
98
98
  return parts.join(chalk.dim(' · '));
99
99
  }
package/link.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // link.mjs — Create symlinks from IDE dirs → .aw_registry/
2
2
 
3
- import { existsSync, lstatSync, mkdirSync, readdirSync, unlinkSync, symlinkSync } from 'node:fs';
3
+ import { existsSync, lstatSync, mkdirSync, readdirSync, unlinkSync, symlinkSync, rmdirSync } from 'node:fs';
4
4
  import { join, relative } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
6
  import * as fmt from './fmt.mjs';
@@ -80,11 +80,14 @@ function cleanIdeSymlinks(cwd) {
80
80
  }
81
81
 
82
82
  /**
83
- * Remove all symlinks in a directory (non-recursive into subdirs,
84
- * but does walk immediate child directories one level deep).
83
+ * Remove all symlinks in a directory, then prune empty subdirectories.
84
+ * Walks depth-first so children are cleaned before parents.
85
85
  */
86
86
  function cleanSymlinksRecursive(dir) {
87
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
87
+ let entries;
88
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
89
+
90
+ for (const entry of entries) {
88
91
  const p = join(dir, entry.name);
89
92
  try {
90
93
  if (lstatSync(p).isSymbolicLink()) {
@@ -94,6 +97,13 @@ function cleanSymlinksRecursive(dir) {
94
97
  }
95
98
  } catch { /* best effort */ }
96
99
  }
100
+
101
+ // Remove directory if now empty (but never remove top-level IDE dirs like .claude/)
102
+ try {
103
+ if (readdirSync(dir).length === 0 && !IDE_DIRS.some(d => dir.endsWith(d))) {
104
+ rmdirSync(dir);
105
+ }
106
+ } catch { /* best effort */ }
97
107
  }
98
108
 
99
109
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghl-ai/aw",
3
- "version": "0.1.34-beta.2",
3
+ "version": "0.1.34-beta.20",
4
4
  "description": "Agentic Workspace CLI — pull, push & manage agents, skills and commands from the registry",
5
5
  "type": "module",
6
6
  "bin": {
package/plan.mjs CHANGED
@@ -98,10 +98,24 @@ export function computePlan(registryDirs, workspaceDir, includePatterns = [], {
98
98
  });
99
99
  }
100
100
 
101
- // Orphans: in manifest but not in plan
101
+ // Orphans: in manifest but not in plan (scoped to current includePatterns)
102
102
  if (manifest.files && !skipOrphans) {
103
+ // Build a set of all targetFilenames in the plan for exact matching
104
+ const planTargets = new Set(actions.map(a => a.targetFilename));
105
+
103
106
  for (const [manifestKey, manifestEntry] of Object.entries(manifest.files)) {
104
- // manifestKey is now namespace/type/slug.md or namespace/type/slug/relPath
107
+ // Already handled by the plan — not an orphan
108
+ if (planTargets.has(manifestKey)) continue;
109
+
110
+ // Only consider entries that match the current include patterns.
111
+ // This prevents one pull (e.g. platform) from orphaning entries
112
+ // belonging to another namespace (e.g. revex/courses).
113
+ const manifestRegistryPath = manifestKey.replace(/\.md$/, '');
114
+ if (includePatterns.length > 0 && !matchesAny(manifestRegistryPath, includePatterns)) {
115
+ continue;
116
+ }
117
+
118
+ // manifestKey is namespace/type/slug.md or namespace/type/slug/relPath
105
119
  const parts = manifestKey.split('/');
106
120
  // Find the type segment
107
121
  let typeIdx = -1;
@@ -115,22 +129,20 @@ export function computePlan(registryDirs, workspaceDir, includePatterns = [], {
115
129
  const namespace = parts.slice(0, typeIdx).join('/');
116
130
  const type = parts[typeIdx];
117
131
  const slug = parts[typeIdx + 1]?.replace(/\.md$/, '');
118
- const key = `${namespace}/${type}/${slug}`;
119
- if (!plan.has(key)) {
120
- actions.push({
121
- slug,
122
- source: manifestEntry.source || 'unknown',
123
- type,
124
- action: 'ORPHAN',
125
- sourcePath: '',
126
- targetPath: join(workspaceDir, manifestKey),
127
- targetFilename: manifestKey,
128
- isDirectory: false,
129
- registryHash: '',
130
- namespacePath: parts.slice(0, typeIdx).join('/'),
131
- registryPath: '',
132
- });
133
- }
132
+
133
+ actions.push({
134
+ slug,
135
+ source: manifestEntry.source || 'unknown',
136
+ type,
137
+ action: 'ORPHAN',
138
+ sourcePath: '',
139
+ targetPath: join(workspaceDir, manifestKey),
140
+ targetFilename: manifestKey,
141
+ isDirectory: false,
142
+ registryHash: '',
143
+ namespacePath: namespace,
144
+ registryPath: '',
145
+ });
134
146
  }
135
147
  }
136
148