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

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 +120 -78
  2. package/git.mjs +47 -10
  3. package/package.json +1 -1
package/commands/push.mjs CHANGED
@@ -17,6 +17,8 @@ import {
17
17
  isValidClone,
18
18
  isWorktree,
19
19
  getLocalRegistryDir,
20
+ commitsAheadOfMain,
21
+ logAheadOfMain,
20
22
  } from '../git.mjs';
21
23
 
22
24
  const PUSHABLE_TYPES = ['agents', 'skills', 'commands', 'evals'];
@@ -26,6 +28,9 @@ const PUSHABLE_TYPES = ['agents', 'skills', 'commands', 'evals'];
26
28
  // Auto-generate a branch name from the files being pushed.
27
29
  function generateBranchName(files) {
28
30
  const shortId = Date.now().toString(36).slice(-5);
31
+
32
+ if (files.length === 0) return `sync/state-${shortId}`;
33
+
29
34
  const namespaces = [...new Set(files.map(f => f.namespace))];
30
35
  const hasDeletes = files.some(f => f.deleted);
31
36
  const allDeletes = files.every(f => f.deleted);
@@ -44,92 +49,106 @@ function generateBranchName(files) {
44
49
  return `${prefix}/batch-${shortId}`;
45
50
  }
46
51
 
47
- function generatePrTitle(files) {
48
- if (files.length === 1) {
49
- 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}`;
53
- }
52
+ const TYPE_SINGULAR = { agents: 'agent', skills: 'skill', commands: 'command', evals: 'eval' };
54
53
 
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))];
54
+ function singular(type, count) {
55
+ const s = TYPE_SINGULAR[type] ?? type;
56
+ return count === 1 ? s : `${s}s`;
57
+ }
69
58
 
70
- if (namespaces.length === 1) {
71
- return `sync: ${parts.join(', ')} in ${namespaces[0]}`;
59
+ function generatePrTitle(files, awHome = null) {
60
+ if (files.length === 0) {
61
+ const commits = awHome ? logAheadOfMain(awHome) : [];
62
+ const count = commits.length;
63
+ return count > 0
64
+ ? `feat(registry): sync ${count} unpublished commit${count > 1 ? 's' : ''}`
65
+ : 'feat(registry): sync current state';
72
66
  }
73
- return `sync: ${parts.join(', ')} across ${namespaces.join(', ')}`;
74
- }
75
67
 
76
- function generatePrBody(files, newNamespaces) {
68
+ const namespaces = [...new Set(files.map(f => f.namespace))];
69
+
77
70
  if (files.length === 1) {
78
71
  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');
72
+ const verb = f.deleted ? 'remove' : 'update';
73
+ const scope = f.namespace;
74
+ return `feat(${scope}): ${verb} ${f.slug} ${TYPE_SINGULAR[f.type] ?? f.type}`;
96
75
  }
97
76
 
98
77
  const added = files.filter(f => !f.deleted);
99
78
  const deleted = files.filter(f => f.deleted);
79
+ const countsByType = groupBy(added.length ? added : deleted, 'type');
80
+ const summary = Object.entries(countsByType)
81
+ .map(([t, items]) => `${items.length} ${singular(t, items.length)}`)
82
+ .join(', ');
83
+ const verb = added.length === 0 ? 'remove' : deleted.length === 0 ? 'sync' : 'sync';
84
+
85
+ if (namespaces.length === 1) {
86
+ return `feat(${namespaces[0]}): ${verb} ${summary}`;
87
+ }
88
+ return `feat(registry): ${verb} ${files.length} files across ${namespaces.length} namespaces`;
89
+ }
100
90
 
101
- const bodyParts = ['## Registry Sync', ''];
91
+ const AW_BRANDING = `---\nšŸ¤– Generated by [AW CLI](https://github.com/GoHighLevel/ghl-agentic-workspace)`;
102
92
 
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})`);
93
+ function generatePrBody(files, newNamespaces, awHome = null) {
94
+ const added = files.filter(f => !f.deleted);
95
+ const deleted = files.filter(f => f.deleted);
96
+ const bodyParts = [];
97
+
98
+ if (files.length === 0) {
99
+ const commits = awHome ? logAheadOfMain(awHome) : [];
100
+ bodyParts.push('## Unpublished Commits', '');
101
+ if (commits.length > 0) {
102
+ bodyParts.push('| Commit | Message |', '|--------|---------|');
103
+ for (const c of commits) {
104
+ bodyParts.push(`| \`${c.hash}\` | ${c.message} |`);
110
105
  }
106
+ } else {
107
+ bodyParts.push('Syncing current state to registry.');
111
108
  }
112
- bodyParts.push('');
109
+ bodyParts.push('', AW_BRANDING);
110
+ return bodyParts.join('\n');
113
111
  }
114
112
 
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})`);
113
+ if (files.length === 1) {
114
+ const f = files[0];
115
+ const verb = f.deleted ? 'Removed' : 'Updated';
116
+ bodyParts.push(
117
+ `## ${verb} \`${f.slug}\` ${TYPE_SINGULAR[f.type] ?? f.type}`,
118
+ '',
119
+ `| Field | Value |`,
120
+ `|-------|-------|`,
121
+ `| Namespace | \`${f.namespace}\` |`,
122
+ `| Type | ${f.type} |`,
123
+ `| Path | \`${f.registryTarget}\` |`,
124
+ );
125
+ } else {
126
+ bodyParts.push('## Registry Sync', '');
127
+
128
+ if (added.length > 0) {
129
+ bodyParts.push('### Added / Updated', '');
130
+ bodyParts.push('| Type | Slug | Namespace |', '|------|------|-----------|');
131
+ for (const f of added.sort((a, b) => a.slug.localeCompare(b.slug))) {
132
+ bodyParts.push(`| ${f.type} | \`${f.slug}\` | \`${f.namespace}\` |`);
133
+ }
134
+ bodyParts.push('');
135
+ }
136
+
137
+ if (deleted.length > 0) {
138
+ bodyParts.push('### Removed', '');
139
+ bodyParts.push('| Type | Slug | Namespace |', '|------|------|-----------|');
140
+ for (const f of deleted.sort((a, b) => a.slug.localeCompare(b.slug))) {
141
+ bodyParts.push(`| ${f.type} | ~~\`${f.slug}\`~~ | \`${f.namespace}\` |`);
122
142
  }
143
+ bodyParts.push('');
123
144
  }
124
- bodyParts.push('');
125
145
  }
126
146
 
127
147
  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('');
148
+ bodyParts.push('', `> **New namespace${newNamespaces.length > 1 ? 's' : ''}:** \`${newNamespaces.join('`, `')}\` — CODEOWNERS entr${newNamespaces.length > 1 ? 'ies' : 'y'} auto-added.`);
130
149
  }
131
150
 
132
- bodyParts.push(`---`, `Synced via \`aw push\` (${files.length} files: ${added.length} added/updated, ${deleted.length} removed)`);
151
+ bodyParts.push('', AW_BRANDING);
133
152
  return bodyParts.join('\n');
134
153
  }
135
154
 
@@ -277,7 +296,11 @@ function doPush(files, awHome, dryRun, worktreeFlow = false, preStaged = false)
277
296
  const deletedParts = Object.entries(groupBy(deleted, 'type')).map(([t, items]) => `${items.length} ${t} removed`);
278
297
  const countParts = [...addedParts, ...deletedParts];
279
298
 
280
- fmt.logInfo(`${chalk.bold(files.length)} file${files.length > 1 ? 's' : ''} to push (${countParts.join(', ')})`);
299
+ if (files.length === 0) {
300
+ fmt.logInfo('Branching current state (no new changes)');
301
+ } else {
302
+ fmt.logInfo(`${chalk.bold(files.length)} file${files.length > 1 ? 's' : ''} to push (${countParts.join(', ')})`);
303
+ }
281
304
 
282
305
  if (dryRun) {
283
306
  for (const f of files) {
@@ -309,8 +332,8 @@ function doPush(files, awHome, dryRun, worktreeFlow = false, preStaged = false)
309
332
  }
310
333
 
311
334
  const commitMsg = generateCommitMsg(files);
312
- const prTitle = generatePrTitle(files);
313
- const prBody = generatePrBody(files, newNamespaces);
335
+ const prTitle = generatePrTitle(files, awHome);
336
+ const prBody = generatePrBody(files, newNamespaces, awHome);
314
337
 
315
338
  const s = fmt.spinner();
316
339
  s.start('Committing and pushing...');
@@ -416,23 +439,42 @@ export function pushCommand(args) {
416
439
  ...changes.deleted.map(e => ({ ...e, deleted: true })),
417
440
  ];
418
441
 
442
+ if (allEntries.length === 0 && worktreeFlow && commitsAheadOfMain(awHome) > 0) {
443
+ fmt.logInfo(`${chalk.dim('mode:')} auto (no new changes — branching current state)`);
444
+ doPush([], awHome, dryRun, worktreeFlow, false);
445
+ return;
446
+ }
447
+
419
448
  if (allEntries.length === 0) {
420
449
  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
450
  return;
422
451
  }
423
452
 
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
- });
453
+ const files = allEntries
454
+ .filter(f => parseRegistryPath(f.registryPath) !== null)
455
+ .map(f => {
456
+ const meta = parseRegistryPath(f.registryPath);
457
+ const parts = f.registryPath.split('/');
458
+ return {
459
+ absPath: join(awHome, f.path),
460
+ registryTarget: f.path,
461
+ type: meta?.type ?? 'file',
462
+ namespace: meta?.namespace ?? parts.slice(0, -1).join('/'),
463
+ slug: meta?.slug ?? parts[parts.length - 1].replace(/\.md$/, ''),
464
+ deleted: f.deleted,
465
+ };
466
+ });
467
+
468
+ if (files.length === 0) {
469
+ // In worktree flow, still push if there are commits ahead of main not yet in a PR
470
+ if (worktreeFlow && commitsAheadOfMain(awHome) > 0) {
471
+ fmt.logInfo(`${chalk.dim('mode:')} auto (no new changes — branching current state)`);
472
+ doPush([], awHome, dryRun, worktreeFlow, false);
473
+ return;
474
+ }
475
+ fmt.cancel('Nothing to push — no staged or modified files.\n\n Stage files in your IDE or use `aw status` to see changes.');
476
+ return;
477
+ }
436
478
 
437
479
  fmt.logInfo(`${chalk.dim('mode:')} auto (${files.length} file${files.length > 1 ? 's' : ''} — stage specific files to push a subset)`);
438
480
  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,40 @@ 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
+
400
+ /**
401
+ * Get one-line log of commits ahead of main.
402
+ * Returns array of { hash, message } objects.
403
+ */
404
+ export function logAheadOfMain(awHome) {
405
+ try {
406
+ const out = execSync(
407
+ `git -C "${awHome}" log ${REGISTRY_BASE_BRANCH}..HEAD --oneline`,
408
+ { stdio: 'pipe', encoding: 'utf8' },
409
+ );
410
+ return out.trim().split('\n').filter(Boolean).map(line => {
411
+ const spaceIdx = line.indexOf(' ');
412
+ return { hash: line.slice(0, spaceIdx), message: line.slice(spaceIdx + 1) };
413
+ });
414
+ } catch {
415
+ return [];
416
+ }
417
+ }
418
+
382
419
  /**
383
420
  * Get the current branch name in awHome.
384
421
  */
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.14",
4
4
  "description": "Agentic Workspace CLI — pull, push & manage agents, skills and commands from the registry",
5
5
  "type": "module",
6
6
  "bin": {