@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 +21 -2
- package/commands/pull.mjs +50 -8
- package/commands/push.mjs +62 -26
- package/fmt.mjs +2 -2
- package/link.mjs +14 -4
- package/package.json +1 -1
- package/plan.mjs +30 -18
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
namespace
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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.
|
|
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.
|
|
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
|
|
84
|
-
*
|
|
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
|
-
|
|
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
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
|
-
//
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|