@ghl-ai/aw 0.1.58 → 0.1.60-beta.0

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/push.mjs CHANGED
@@ -662,6 +662,10 @@ async function publishProjectAwDocs(cwd, home, dryRun, scope = null) {
662
662
  // Auto-generate a branch name from the files being pushed.
663
663
  function generateBranchName(files, extraPaths = []) {
664
664
  const shortId = Date.now().toString(36).slice(-5);
665
+ const branchSafe = (value) => String(value || '')
666
+ .replace(/\//g, '-')
667
+ .replace(/[^A-Za-z0-9._-]+/g, '-')
668
+ .replace(/^-+|-+$/g, '') || 'item';
665
669
 
666
670
  if (files.length === 0 && extraPaths.length > 0) {
667
671
  return `${managedBranchPrefix(extraPaths)}-${shortId}`;
@@ -675,8 +679,8 @@ function generateBranchName(files, extraPaths = []) {
675
679
 
676
680
  if (files.length === 1) {
677
681
  const f = files[0];
678
- const nsSlug = f.namespace.replace(/\//g, '-');
679
- return `${prefix}/${nsSlug}-${f.type}-${f.slug}-${shortId}`;
682
+ const nsSlug = branchSafe(f.namespace);
683
+ return `${prefix}/${nsSlug}-${f.type}-${branchSafe(f.slug)}-${shortId}`;
680
684
  }
681
685
 
682
686
  if (namespaces.length === 1) {
@@ -698,7 +702,7 @@ function singular(type, count) {
698
702
  function parseCommitFiles(commits) {
699
703
  const result = [];
700
704
  for (const c of commits) {
701
- const m = c.message.match(/registry:\s+(add|remove|sync)\s+(agents|skills|commands|evals)\/([\w-]+)\s+(?:to|from)\s+([\w/]+)/);
705
+ const m = c.message.match(/registry:\s+(add|remove|sync)\s+(agents|skills|commands|evals)\/([\w/-]+)\s+(?:to|from)\s+([\w/]+)/);
702
706
  if (m) result.push({ verb: m[1], type: m[2], slug: m[3], namespace: m[4] });
703
707
  }
704
708
  return result;
@@ -860,16 +864,14 @@ function collectBatchFiles(folderAbsPath, registrySubDir) {
860
864
  let walkDir = folderAbsPath;
861
865
  let walkBaseName = relPath;
862
866
 
863
- for (let i = 0; i < segments.length; i++) {
864
- if (PUSHABLE_TYPES.includes(segments[i])) {
865
- const namespaceParts = segments.slice(0, i);
866
- walkBaseName = namespaceParts.join('/');
867
- walkDir = join(registrySubDir, ...namespaceParts);
868
- typeFilter = segments[i];
869
- if (i + 1 < segments.length) {
870
- subPathFilter = segments.slice(i + 1).join('/');
871
- }
872
- break;
867
+ const typeIdx = findPushableTypeIndex(segments);
868
+ if (typeIdx !== -1) {
869
+ const namespaceParts = segments.slice(0, typeIdx);
870
+ walkBaseName = namespaceParts.join('/');
871
+ walkDir = join(registrySubDir, ...namespaceParts);
872
+ typeFilter = segments[typeIdx];
873
+ if (typeIdx + 1 < segments.length) {
874
+ subPathFilter = segments.slice(typeIdx + 1).join('/');
873
875
  }
874
876
  }
875
877
 
@@ -877,7 +879,10 @@ function collectBatchFiles(folderAbsPath, registrySubDir) {
877
879
  return entries
878
880
  .filter(entry => {
879
881
  if (typeFilter && entry.type !== typeFilter) return false;
880
- if (subPathFilter && !entry.slug.startsWith(subPathFilter)) return false;
882
+ if (subPathFilter) {
883
+ const prefix = `${subPathFilter}/`;
884
+ if (entry.slug !== subPathFilter && !entry.slug.startsWith(prefix)) return false;
885
+ }
881
886
  return true;
882
887
  })
883
888
  .map(entry => {
@@ -900,18 +905,53 @@ function collectBatchFiles(folderAbsPath, registrySubDir) {
900
905
 
901
906
  // ── Parse type/namespace/slug from a registry-relative path ──────────
902
907
 
903
- function parseRegistryPath(relPath) {
904
- const parts = relPath.split('/');
908
+ function findSkillSlugParts(namespaceParts, skillParts, registrySubDir) {
909
+ if (skillParts.length === 0) return [];
910
+
911
+ if (registrySubDir) {
912
+ for (let i = skillParts.length; i > 0; i--) {
913
+ const candidate = join(registrySubDir, ...namespaceParts, 'skills', ...skillParts.slice(0, i));
914
+ if (existsSync(join(candidate, 'SKILL.md'))) return skillParts.slice(0, i);
915
+ }
916
+ }
917
+
918
+ const skillMdIdx = skillParts.findIndex(part => part === 'SKILL' || part === 'SKILL.md');
919
+ if (skillMdIdx > 0) return skillParts.slice(0, skillMdIdx);
920
+
921
+ return [skillParts[0]];
922
+ }
923
+
924
+ function findPushableTypeIndex(parts) {
905
925
  for (let i = 0; i < parts.length; i++) {
906
- if (PUSHABLE_TYPES.includes(parts[i]) && i + 1 < parts.length) {
907
- return {
908
- type: parts[i],
909
- namespace: parts.slice(0, i).join('/'),
910
- slug: parts[i + 1].replace(/\.md$/, ''),
911
- };
926
+ if ((parts[i] === 'agents' || parts[i] === 'commands') && parts[i + 1] === 'evals') {
927
+ return i + 1;
912
928
  }
929
+ if (PUSHABLE_TYPES.includes(parts[i])) return i;
913
930
  }
914
- return null;
931
+ return -1;
932
+ }
933
+
934
+ function parseRegistryPath(relPath, registrySubDir = null) {
935
+ const parts = relPath.split('/');
936
+ const typeIdx = findPushableTypeIndex(parts);
937
+ if (typeIdx === -1 || typeIdx + 1 >= parts.length) return null;
938
+
939
+ const namespaceParts = parts.slice(0, typeIdx);
940
+ if (parts[typeIdx] === 'skills') {
941
+ const skillParts = parts.slice(typeIdx + 1).map(part => part.replace(/\.md$/, ''));
942
+ const slugParts = findSkillSlugParts(namespaceParts, skillParts, registrySubDir);
943
+ return {
944
+ type: parts[typeIdx],
945
+ namespace: namespaceParts.join('/'),
946
+ slug: slugParts.join('/'),
947
+ };
948
+ }
949
+
950
+ return {
951
+ type: parts[typeIdx],
952
+ namespace: namespaceParts.join('/'),
953
+ slug: parts[typeIdx + 1].replace(/\.md$/, ''),
954
+ };
915
955
  }
916
956
 
917
957
  // ── Colocated eval resolution ─────────────────────────────────────────
@@ -1198,7 +1238,7 @@ export async function pushCommand(args) {
1198
1238
 
1199
1239
  if (staged.length > 0 || extraStaged.length > 0) {
1200
1240
  const files = staged.map(f => {
1201
- const meta = parseRegistryPath(f.registryPath);
1241
+ const meta = parseRegistryPath(f.registryPath, registrySubDir);
1202
1242
  const parts = f.registryPath.split('/');
1203
1243
  return {
1204
1244
  absPath: join(awHome, f.path),
@@ -1264,7 +1304,7 @@ export async function pushCommand(args) {
1264
1304
 
1265
1305
  const files = allEntries
1266
1306
  .map(f => {
1267
- const meta = parseRegistryPath(f.registryPath);
1307
+ const meta = parseRegistryPath(f.registryPath, registrySubDir);
1268
1308
  const parts = f.registryPath.split('/');
1269
1309
  return {
1270
1310
  absPath: join(awHome, f.path),
@@ -1369,7 +1409,7 @@ export async function pushCommand(args) {
1369
1409
  const deletedInFolder = folderChanges.deleted
1370
1410
  .filter(e => !folderPrefix || e.registryPath.startsWith(folderPrefix))
1371
1411
  .map(e => {
1372
- const meta = parseRegistryPath(e.registryPath);
1412
+ const meta = parseRegistryPath(e.registryPath, registrySubDir);
1373
1413
  const parts = e.registryPath.split('/');
1374
1414
  return {
1375
1415
  absPath: join(awHome, e.path),
@@ -1396,13 +1436,8 @@ export async function pushCommand(args) {
1396
1436
  }
1397
1437
 
1398
1438
  // Single file input
1399
- const regParts = resolved.registryPath.split('/');
1400
- let typeIdx = -1;
1401
- for (let i = regParts.length - 1; i >= 0; i--) {
1402
- if (PUSHABLE_TYPES.includes(regParts[i])) { typeIdx = i; break; }
1403
- }
1404
-
1405
- if (typeIdx === -1 || typeIdx + 1 >= regParts.length) {
1439
+ const meta = parseRegistryPath(resolved.registryPath, registrySubDir);
1440
+ if (!meta?.type || !meta.slug) {
1406
1441
  fmt.cancel([
1407
1442
  `Invalid push path: ${chalk.red(resolved.registryPath)}`,
1408
1443
  '',
@@ -1416,14 +1451,18 @@ export async function pushCommand(args) {
1416
1451
  return;
1417
1452
  }
1418
1453
 
1419
- const namespaceParts = regParts.slice(0, typeIdx);
1420
- const parentDir = regParts[typeIdx];
1421
- const slug = regParts[typeIdx + 1];
1422
- const namespacePath = namespaceParts.join('/');
1454
+ const parentDir = meta.type;
1455
+ const slug = meta.slug;
1456
+ const namespacePath = meta.namespace;
1423
1457
  const isDir = !isDeletedFile && statSync(absPath).isDirectory();
1458
+ const registryPathWithExt = resolved.registryPath.endsWith('.md')
1459
+ || (!isDeletedFile && absPath && !absPath.endsWith('.md'))
1460
+ || (isDeletedFile && /\.[^/]+$/.test(resolved.registryPath))
1461
+ ? resolved.registryPath
1462
+ : `${resolved.registryPath}.md`;
1424
1463
  const registryTarget = isDir
1425
1464
  ? `${REGISTRY_DIR}/${namespacePath}/${parentDir}/${slug}`
1426
- : `${REGISTRY_DIR}/${namespacePath}/${parentDir}/${slug}.md`;
1465
+ : `${REGISTRY_DIR}/${registryPathWithExt}`;
1427
1466
 
1428
1467
  // Check if the file actually has changes before trying to commit
1429
1468
  const singleFileChanges = detectChanges(awHome, REGISTRY_DIR);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghl-ai/aw",
3
- "version": "0.1.58",
3
+ "version": "0.1.60-beta.0",
4
4
  "description": "Agentic Workspace CLI — pull, push & manage agents, skills and commands from the registry",
5
5
  "type": "module",
6
6
  "bin": {
package/registry.mjs CHANGED
@@ -28,10 +28,33 @@ export function getAllFiles(dirPath) {
28
28
  return results;
29
29
  }
30
30
 
31
+ function findSkillRootDirs(skillsDir) {
32
+ const results = [];
33
+
34
+ function walk(dir, segments) {
35
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
36
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
37
+
38
+ const childDir = join(dir, entry.name);
39
+ const childSegments = [...segments, entry.name];
40
+ if (existsSync(join(childDir, 'SKILL.md'))) {
41
+ results.push({ skillPath: childDir, slug: childSegments.join('/') });
42
+ continue;
43
+ }
44
+
45
+ walk(childDir, childSegments);
46
+ }
47
+ }
48
+
49
+ walk(skillsDir, []);
50
+ return results;
51
+ }
52
+
31
53
  /**
32
54
  * Walk a registry directory tree and collect file-level entries.
33
55
  * Every file gets its own entry with its full registry path.
34
- * Skills are NOT atomic — each file inside a skill is registered individually.
56
+ * Skills are NOT atomic — each file inside a skill root is registered individually.
57
+ * A skill root is the nearest directory below skills/ that contains SKILL.md.
35
58
  */
36
59
  export function walkRegistryTree(baseDir, baseName) {
37
60
  const entries = [];
@@ -47,13 +70,9 @@ export function walkRegistryTree(baseDir, baseName) {
47
70
  const namespace = pathSegments.join('/');
48
71
 
49
72
  if (typeDir === 'skills') {
50
- // Skills are directories register each file inside individually
51
- for (const skillEntry of readdirSync(fullPath, { withFileTypes: true })) {
52
- if (skillEntry.name.startsWith('.')) continue;
53
- const skillPath = join(fullPath, skillEntry.name);
54
- if (!statSync(skillPath).isDirectory()) continue;
55
- const slug = skillEntry.name;
56
-
73
+ // Skills can be grouped in nested folders. The actual skill root is
74
+ // the first nested directory that contains SKILL.md.
75
+ for (const { skillPath, slug } of findSkillRootDirs(fullPath)) {
57
76
  for (const file of getAllFiles(skillPath)) {
58
77
  const relPath = relative(skillPath, file);
59
78
  const relNoExt = relPath.replace(/\.md$/, '');