@ghl-ai/aw 0.1.36-beta.12 → 0.1.36-beta.13

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.
Files changed (3) hide show
  1. package/commands/push.mjs +105 -74
  2. package/git.mjs +28 -10
  3. package/package.json +1 -1
package/commands/push.mjs CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  isValidClone,
18
18
  isWorktree,
19
19
  getLocalRegistryDir,
20
+ commitsAheadOfMain,
20
21
  } from '../git.mjs';
21
22
 
22
23
  const PUSHABLE_TYPES = ['agents', 'skills', 'commands', 'evals'];
@@ -26,6 +27,9 @@ const PUSHABLE_TYPES = ['agents', 'skills', 'commands', 'evals'];
26
27
  // Auto-generate a branch name from the files being pushed.
27
28
  function generateBranchName(files) {
28
29
  const shortId = Date.now().toString(36).slice(-5);
30
+
31
+ if (files.length === 0) return `sync/state-${shortId}`;
32
+
29
33
  const namespaces = [...new Set(files.map(f => f.namespace))];
30
34
  const hasDeletes = files.some(f => f.deleted);
31
35
  const allDeletes = files.every(f => f.deleted);
@@ -44,92 +48,96 @@ function generateBranchName(files) {
44
48
  return `${prefix}/batch-${shortId}`;
45
49
  }
46
50
 
51
+ const TYPE_SINGULAR = { agents: 'agent', skills: 'skill', commands: 'command', evals: 'eval' };
52
+
53
+ function singular(type, count) {
54
+ const s = TYPE_SINGULAR[type] ?? type;
55
+ return count === 1 ? s : `${s}s`;
56
+ }
57
+
47
58
  function generatePrTitle(files) {
59
+ if (files.length === 0) return 'feat(registry): sync current state';
60
+
61
+ const namespaces = [...new Set(files.map(f => f.namespace))];
62
+
48
63
  if (files.length === 1) {
49
64
  const f = files[0];
50
- const verb = f.deleted ? 'Remove' : 'Add';
51
- const prep = f.deleted ? 'from' : 'to';
52
- return `${verb} ${f.slug} (${f.type}) ${prep} ${f.namespace}`;
65
+ const verb = f.deleted ? 'remove' : 'update';
66
+ const scope = f.namespace;
67
+ return `feat(${scope}): ${verb} ${f.slug} ${TYPE_SINGULAR[f.type] ?? f.type}`;
53
68
  }
54
69
 
55
- const addCounts = {};
56
- const deleteCounts = {};
57
- for (const f of files) {
58
- const target = f.deleted ? deleteCounts : addCounts;
59
- target[f.type] = (target[f.type] || 0) + 1;
60
- }
61
- const parts = [];
62
- for (const [type, count] of Object.entries(addCounts)) {
63
- parts.push(`+${count} ${type}`);
64
- }
65
- for (const [type, count] of Object.entries(deleteCounts)) {
66
- parts.push(`-${count} ${type}`);
67
- }
68
- const namespaces = [...new Set(files.map(f => f.namespace))];
70
+ const added = files.filter(f => !f.deleted);
71
+ const deleted = files.filter(f => f.deleted);
72
+ const countsByType = groupBy(added.length ? added : deleted, 'type');
73
+ const summary = Object.entries(countsByType)
74
+ .map(([t, items]) => `${items.length} ${singular(t, items.length)}`)
75
+ .join(', ');
76
+ const verb = added.length === 0 ? 'remove' : deleted.length === 0 ? 'sync' : 'sync';
69
77
 
70
78
  if (namespaces.length === 1) {
71
- return `sync: ${parts.join(', ')} in ${namespaces[0]}`;
79
+ return `feat(${namespaces[0]}): ${verb} ${summary}`;
72
80
  }
73
- return `sync: ${parts.join(', ')} across ${namespaces.join(', ')}`;
81
+ return `feat(registry): ${verb} ${files.length} files across ${namespaces.length} namespaces`;
74
82
  }
75
83
 
76
- function generatePrBody(files, newNamespaces) {
77
- if (files.length === 1) {
78
- const f = files[0];
79
- const action = f.deleted ? 'Registry Removal' : 'Registry Upload';
80
- const bodyParts = [
81
- `## ${action}`,
82
- '',
83
- `- **Type:** ${f.type}`,
84
- `- **Slug:** ${f.slug}`,
85
- `- **Namespace:** ${f.namespace}`,
86
- `- **Path:** \`${f.registryTarget}\``,
87
- ];
88
- if (f.deleted) {
89
- bodyParts.push('', '> File was deleted locally and removed from registry.');
90
- }
91
- if (newNamespaces.length > 0) {
92
- bodyParts.push('', '> **New namespace** — CODEOWNERS entry auto-added.');
93
- }
94
- bodyParts.push('', `${f.deleted ? 'Removed' : 'Uploaded'} via \`aw push\``);
95
- return bodyParts.join('\n');
96
- }
84
+ const AW_BRANDING = `---\nGenerated by [AW CLI](https://github.com/GoHighLevel/ghl-agentic-workspace)`;
97
85
 
86
+ function generatePrBody(files, newNamespaces) {
98
87
  const added = files.filter(f => !f.deleted);
99
88
  const deleted = files.filter(f => f.deleted);
89
+ const bodyParts = [];
100
90
 
101
- const bodyParts = ['## Registry Sync', ''];
91
+ if (files.length === 0) {
92
+ bodyParts.push(
93
+ '## Registry Sync',
94
+ '',
95
+ 'No new file changes — branching current state to capture all unpublished commits.',
96
+ '',
97
+ AW_BRANDING,
98
+ );
99
+ return bodyParts.join('\n');
100
+ }
102
101
 
103
- if (added.length > 0) {
104
- const grouped = groupBy(added, 'type');
105
- bodyParts.push('### Added / Updated');
106
- for (const [type, items] of Object.entries(grouped)) {
107
- bodyParts.push(`**${type}**`);
108
- for (const item of items.sort((a, b) => a.slug.localeCompare(b.slug))) {
109
- bodyParts.push(`- \`${item.slug}\` (${item.namespace})`);
102
+ if (files.length === 1) {
103
+ const f = files[0];
104
+ const verb = f.deleted ? 'Removed' : 'Updated';
105
+ bodyParts.push(
106
+ `## ${verb} \`${f.slug}\` ${TYPE_SINGULAR[f.type] ?? f.type}`,
107
+ '',
108
+ `| Field | Value |`,
109
+ `|-------|-------|`,
110
+ `| Namespace | \`${f.namespace}\` |`,
111
+ `| Type | ${f.type} |`,
112
+ `| Path | \`${f.registryTarget}\` |`,
113
+ );
114
+ } else {
115
+ bodyParts.push('## Registry Sync', '');
116
+
117
+ if (added.length > 0) {
118
+ bodyParts.push('### Added / Updated', '');
119
+ bodyParts.push('| Type | Slug | Namespace |', '|------|------|-----------|');
120
+ for (const f of added.sort((a, b) => a.slug.localeCompare(b.slug))) {
121
+ bodyParts.push(`| ${f.type} | \`${f.slug}\` | \`${f.namespace}\` |`);
110
122
  }
123
+ bodyParts.push('');
111
124
  }
112
- bodyParts.push('');
113
- }
114
125
 
115
- if (deleted.length > 0) {
116
- const grouped = groupBy(deleted, 'type');
117
- bodyParts.push('### Removed');
118
- for (const [type, items] of Object.entries(grouped)) {
119
- bodyParts.push(`**${type}**`);
120
- for (const item of items.sort((a, b) => a.slug.localeCompare(b.slug))) {
121
- bodyParts.push(`- ~~\`${item.slug}\`~~ (${item.namespace})`);
126
+ if (deleted.length > 0) {
127
+ bodyParts.push('### Removed', '');
128
+ bodyParts.push('| Type | Slug | Namespace |', '|------|------|-----------|');
129
+ for (const f of deleted.sort((a, b) => a.slug.localeCompare(b.slug))) {
130
+ bodyParts.push(`| ${f.type} | ~~\`${f.slug}\`~~ | \`${f.namespace}\` |`);
122
131
  }
132
+ bodyParts.push('');
123
133
  }
124
- bodyParts.push('');
125
134
  }
126
135
 
127
136
  if (newNamespaces.length > 0) {
128
- bodyParts.push(`> **New namespace${newNamespaces.length > 1 ? 's' : ''}:** ${newNamespaces.join(', ')} — CODEOWNERS entr${newNamespaces.length > 1 ? 'ies' : 'y'} auto-added.`);
129
- bodyParts.push('');
137
+ bodyParts.push('', `> **New namespace${newNamespaces.length > 1 ? 's' : ''}:** \`${newNamespaces.join('`, `')}\` — CODEOWNERS entr${newNamespaces.length > 1 ? 'ies' : 'y'} auto-added.`);
130
138
  }
131
139
 
132
- bodyParts.push(`---`, `Synced via \`aw push\` (${files.length} files: ${added.length} added/updated, ${deleted.length} removed)`);
140
+ bodyParts.push('', AW_BRANDING);
133
141
  return bodyParts.join('\n');
134
142
  }
135
143
 
@@ -277,7 +285,11 @@ function doPush(files, awHome, dryRun, worktreeFlow = false, preStaged = false)
277
285
  const deletedParts = Object.entries(groupBy(deleted, 'type')).map(([t, items]) => `${items.length} ${t} removed`);
278
286
  const countParts = [...addedParts, ...deletedParts];
279
287
 
280
- fmt.logInfo(`${chalk.bold(files.length)} file${files.length > 1 ? 's' : ''} to push (${countParts.join(', ')})`);
288
+ if (files.length === 0) {
289
+ fmt.logInfo('Branching current state (no new changes)');
290
+ } else {
291
+ fmt.logInfo(`${chalk.bold(files.length)} file${files.length > 1 ? 's' : ''} to push (${countParts.join(', ')})`);
292
+ }
281
293
 
282
294
  if (dryRun) {
283
295
  for (const f of files) {
@@ -416,23 +428,42 @@ export function pushCommand(args) {
416
428
  ...changes.deleted.map(e => ({ ...e, deleted: true })),
417
429
  ];
418
430
 
431
+ if (allEntries.length === 0 && worktreeFlow && commitsAheadOfMain(awHome) > 0) {
432
+ fmt.logInfo(`${chalk.dim('mode:')} auto (no new changes — branching current state)`);
433
+ doPush([], awHome, dryRun, worktreeFlow, false);
434
+ return;
435
+ }
436
+
419
437
  if (allEntries.length === 0) {
420
438
  fmt.cancel('Nothing to push — no staged or modified files.\n\n Stage files in your IDE or use `aw status` to see changes.');
421
439
  return;
422
440
  }
423
441
 
424
- const files = allEntries.map(f => {
425
- const meta = parseRegistryPath(f.registryPath);
426
- const parts = f.registryPath.split('/');
427
- return {
428
- absPath: join(awHome, f.path),
429
- registryTarget: f.path,
430
- type: meta?.type ?? 'file',
431
- namespace: meta?.namespace ?? parts.slice(0, -1).join('/'),
432
- slug: meta?.slug ?? parts[parts.length - 1].replace(/\.md$/, ''),
433
- deleted: f.deleted,
434
- };
435
- });
442
+ const files = allEntries
443
+ .filter(f => parseRegistryPath(f.registryPath) !== null)
444
+ .map(f => {
445
+ const meta = parseRegistryPath(f.registryPath);
446
+ const parts = f.registryPath.split('/');
447
+ return {
448
+ absPath: join(awHome, f.path),
449
+ registryTarget: f.path,
450
+ type: meta?.type ?? 'file',
451
+ namespace: meta?.namespace ?? parts.slice(0, -1).join('/'),
452
+ slug: meta?.slug ?? parts[parts.length - 1].replace(/\.md$/, ''),
453
+ deleted: f.deleted,
454
+ };
455
+ });
456
+
457
+ if (files.length === 0) {
458
+ // In worktree flow, still push if there are commits ahead of main not yet in a PR
459
+ if (worktreeFlow && commitsAheadOfMain(awHome) > 0) {
460
+ fmt.logInfo(`${chalk.dim('mode:')} auto (no new changes — branching current state)`);
461
+ doPush([], awHome, dryRun, worktreeFlow, false);
462
+ return;
463
+ }
464
+ fmt.cancel('Nothing to push — no staged or modified files.\n\n Stage files in your IDE or use `aw status` to see changes.');
465
+ return;
466
+ }
436
467
 
437
468
  fmt.logInfo(`${chalk.dim('mode:')} auto (${files.length} file${files.length > 1 ? 's' : ''} — stage specific files to push a subset)`);
438
469
  doPush(files, awHome, dryRun, worktreeFlow, false);
package/git.mjs CHANGED
@@ -345,6 +345,7 @@ export function updatePushBranch(awHome, pushBranchName) {
345
345
  *
346
346
  * preStaged=true → files are already in the index; skip `git add`
347
347
  * preStaged=false → explicitly stage `files` before committing
348
+ * files=[] → skip staging+commit entirely (branch current HEAD and push)
348
349
  *
349
350
  * Branch stays on disk for iteration. Returns the branch name.
350
351
  */
@@ -355,21 +356,23 @@ export function createPushBranch(awHome, branchName, files, commitMsg, preStaged
355
356
  throw new Error(`Failed to create branch ${branchName}: ${e.message}`);
356
357
  }
357
358
 
358
- if (!preStaged) {
359
+ if (files.length > 0 || preStaged) {
360
+ if (!preStaged) {
361
+ try {
362
+ const quotedFiles = files.map(f => `"${f}"`).join(' ');
363
+ execSync(`git -C "${awHome}" add ${quotedFiles}`, { stdio: 'pipe' });
364
+ } catch (e) {
365
+ throw new Error(`Failed to stage files: ${e.message}`);
366
+ }
367
+ }
368
+
359
369
  try {
360
- const quotedFiles = files.map(f => `"${f}"`).join(' ');
361
- execSync(`git -C "${awHome}" add ${quotedFiles}`, { stdio: 'pipe' });
370
+ execSync(`git -C "${awHome}" commit -m "${commitMsg.replace(/"/g, '\\"')}"`, { stdio: 'pipe' });
362
371
  } catch (e) {
363
- throw new Error(`Failed to stage files: ${e.message}`);
372
+ throw new Error(`Failed to commit: ${e.message}`);
364
373
  }
365
374
  }
366
375
 
367
- try {
368
- execSync(`git -C "${awHome}" commit -m "${commitMsg.replace(/"/g, '\\"')}"`, { stdio: 'pipe' });
369
- } catch (e) {
370
- throw new Error(`Failed to commit: ${e.message}`);
371
- }
372
-
373
376
  try {
374
377
  execSync(`git -C "${awHome}" push -u origin "${branchName}"`, { stdio: 'pipe' });
375
378
  } catch (e) {
@@ -379,6 +382,21 @@ export function createPushBranch(awHome, branchName, files, commitMsg, preStaged
379
382
  return branchName;
380
383
  }
381
384
 
385
+ /**
386
+ * Count commits on the current branch that are not yet in main.
387
+ */
388
+ export function commitsAheadOfMain(awHome) {
389
+ try {
390
+ const out = execSync(
391
+ `git -C "${awHome}" rev-list --count ${REGISTRY_BASE_BRANCH}..HEAD`,
392
+ { stdio: 'pipe', encoding: 'utf8' },
393
+ );
394
+ return parseInt(out.trim(), 10) || 0;
395
+ } catch {
396
+ return 0;
397
+ }
398
+ }
399
+
382
400
  /**
383
401
  * Get the current branch name in awHome.
384
402
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghl-ai/aw",
3
- "version": "0.1.36-beta.12",
3
+ "version": "0.1.36-beta.13",
4
4
  "description": "Agentic Workspace CLI — pull, push & manage agents, skills and commands from the registry",
5
5
  "type": "module",
6
6
  "bin": {