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

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/cli.mjs CHANGED
@@ -82,9 +82,9 @@ function printHelp() {
82
82
  cmd('aw pull --dry-run <path>', 'Preview what would be downloaded'),
83
83
 
84
84
  sec('Upload'),
85
- cmd('aw push', 'Show modified files ready to push'),
86
- cmd('aw push <path>', 'Create a PR to add/update registry content'),
87
- cmd('aw push --dry-run <path>', 'Preview the upload without creating PR'),
85
+ cmd('aw push', 'Push all modified files (creates one PR)'),
86
+ cmd('aw push <path>', 'Push file, folder, or namespace to registry'),
87
+ cmd('aw push --dry-run [path]', 'Preview what would be pushed'),
88
88
 
89
89
  sec('Discover'),
90
90
  cmd('aw search <query>', 'Find agents & skills (local + registry)'),
@@ -108,8 +108,10 @@ function printHelp() {
108
108
  cmd('aw pull <team>/skills/<name>', 'One specific skill folder'),
109
109
  '',
110
110
  ` ${chalk.dim('# Push your local changes to registry')}`,
111
- cmd('aw push .aw_registry/agents/<name>.md', 'Upload an agent'),
112
- cmd('aw push .aw_registry/skills/<name>/', 'Upload a skill folder'),
111
+ cmd('aw push', 'Push all modified files (one PR)'),
112
+ cmd('aw push .aw_registry/<team>/', 'Push entire namespace (one PR)'),
113
+ cmd('aw push .aw_registry/agents/<name>.md', 'Push a single agent'),
114
+ cmd('aw push .aw_registry/skills/<name>/', 'Push a single skill folder'),
113
115
  '',
114
116
  ` ${chalk.dim('# Remove content from workspace')}`,
115
117
  cmd('aw drop <team>', 'Stop syncing a namespace (removes all files)'),
package/commands/pull.mjs CHANGED
@@ -178,7 +178,7 @@ export async function pullCommand(args) {
178
178
  const s2 = log.spinner();
179
179
  s2.start('Applying changes...');
180
180
  const conflictCount = applyActions(actions, { teamNS: teamNSOverride || renameNamespace || undefined });
181
- updateManifest(workspaceDir, actions, cfg.namespace);
181
+ updateManifest(workspaceDir, actions, cfg.namespace, { fromTemplate: !!renameNamespace });
182
182
  s2.stop('Changes applied');
183
183
 
184
184
  // Copy root-level registry files (e.g. AW-PROTOCOL.md) that are fetched via
@@ -282,7 +282,7 @@ export async function pullAsync(args) {
282
282
  const { actions: rawActions } = computePlan(registryDirs, workspaceDir, [pattern], { skipOrphans: true });
283
283
  const actions = filterActions(rawActions, pattern);
284
284
  const conflictCount = applyActions(actions, { teamNS: teamNSOverride || renameNamespace || undefined });
285
- updateManifest(workspaceDir, actions, cfg.namespace);
285
+ updateManifest(workspaceDir, actions, cfg.namespace, { fromTemplate: !!renameNamespace });
286
286
 
287
287
  const ROOT_REGISTRY_FILES = ['AW-PROTOCOL.md'];
288
288
  for (const fname of ROOT_REGISTRY_FILES) {
package/commands/push.mjs CHANGED
@@ -1,120 +1,193 @@
1
- // commands/push.mjs — Push local agent/skill to registry via PR
1
+ // commands/push.mjs — Push local agents/skills to registry via PR (single file or batch)
2
2
 
3
- import { existsSync, statSync, mkdirSync, cpSync, mkdtempSync, readFileSync, appendFileSync } from 'node:fs';
4
- import { basename, dirname, resolve, join } from 'node:path';
3
+ import { existsSync, statSync, mkdirSync, cpSync, mkdtempSync, readFileSync, appendFileSync, readdirSync } from 'node:fs';
4
+ import { basename, dirname, resolve, join, relative } from 'node:path';
5
5
  import { execSync, execFileSync } from 'node:child_process';
6
6
  import { tmpdir } from 'node:os';
7
7
  import * as fmt from '../fmt.mjs';
8
8
  import { chalk } from '../fmt.mjs';
9
9
  import { REGISTRY_REPO, REGISTRY_BASE_BRANCH, REGISTRY_DIR } from '../constants.mjs';
10
10
  import { resolveInput } from '../paths.mjs';
11
- import { load as loadManifest } from '../manifest.mjs';
12
- import { hashFile } from '../registry.mjs';
13
-
14
- export function pushCommand(args) {
15
- const input = args._positional?.[0];
16
- const dryRun = args['--dry-run'] === true;
17
- const repo = args['--repo'] || REGISTRY_REPO;
18
- const cwd = process.cwd();
19
- const workspaceDir = join(cwd, '.aw_registry');
20
-
21
- fmt.intro('aw push');
11
+ import { load as loadManifest, save as saveManifest } from '../manifest.mjs';
12
+ import { hashFile, walkRegistryTree } from '../registry.mjs';
13
+
14
+ const PUSHABLE_TYPES = ['agents', 'skills', 'commands', 'evals'];
15
+
16
+ // ── Batch file collection ────────────────────────────────────────────
17
+
18
+ /**
19
+ * Collect all pushable files under a local .aw_registry folder.
20
+ * Returns array of { absPath, registryTarget, type, namespace, slug, isDir }.
21
+ */
22
+ function collectBatchFiles(folderAbsPath, workspaceDir) {
23
+ // Determine the namespace name from path relative to workspaceDir
24
+ 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
+ });
43
+ }
22
44
 
23
- // No args = find and list modified files for user to push
24
- if (!input) {
25
- const manifest = loadManifest(workspaceDir);
26
- const modified = [];
27
- for (const [key, entry] of Object.entries(manifest.files || {})) {
28
- const filePath = join(workspaceDir, key);
29
- if (!existsSync(filePath)) continue;
30
- const currentHash = hashFile(filePath);
31
- if (currentHash !== entry.sha256) {
32
- modified.push(key);
45
+ /**
46
+ * Collect all modified files from manifest (for no-args push).
47
+ * Returns array of { absPath, registryTarget, type, namespace, slug, isDir }.
48
+ */
49
+ function collectModifiedFiles(workspaceDir) {
50
+ const manifest = loadManifest(workspaceDir);
51
+ const files = [];
52
+ for (const [key, entry] of Object.entries(manifest.files || {})) {
53
+ const filePath = join(workspaceDir, key);
54
+ if (!existsSync(filePath)) continue;
55
+ const currentHash = hashFile(filePath);
56
+ const isModified = currentHash !== entry.sha256;
57
+ const isNew = !entry.registry_sha256; // Template-derived, never pushed to remote
58
+ if (isModified || isNew) {
59
+ const meta = parseManifestKey(key);
60
+ if (meta) {
61
+ files.push({
62
+ absPath: filePath,
63
+ registryTarget: `${REGISTRY_DIR}/${key}`,
64
+ type: meta.type,
65
+ namespace: meta.namespace,
66
+ slug: meta.slug,
67
+ isDir: false,
68
+ });
33
69
  }
34
70
  }
35
- if (modified.length === 0) {
36
- fmt.cancel('Nothing to push — no modified files.\n\n To push a specific file:\n aw push <path>');
37
- }
38
- fmt.logInfo(`${chalk.bold(modified.length)} modified file${modified.length > 1 ? 's' : ''}:`);
39
- for (const m of modified) {
40
- fmt.logMessage(` ${chalk.yellow(m)}`);
71
+ }
72
+ return files;
73
+ }
74
+
75
+ /**
76
+ * Parse a manifest key like "commerce/agents/unit-tester.md" into { type, namespace, slug }.
77
+ */
78
+ function parseManifestKey(key) {
79
+ const parts = key.split('/');
80
+ for (let i = 0; i < parts.length; i++) {
81
+ if (PUSHABLE_TYPES.includes(parts[i]) && i + 1 < parts.length) {
82
+ return {
83
+ type: parts[i],
84
+ namespace: parts.slice(0, i).join('/'),
85
+ slug: parts[i + 1].replace(/\.md$/, ''),
86
+ };
41
87
  }
42
- fmt.cancel(`\nSpecify which file to push:\n aw push .aw_registry/${modified[0]}`);
43
88
  }
89
+ return null;
90
+ }
44
91
 
45
- // Resolve input accept both registry path and local path
46
- const resolved = resolveInput(input, workspaceDir);
92
+ // ── PR content generation ────────────────────────────────────────────
47
93
 
48
- if (!resolved.registryPath) {
49
- const hint = input.startsWith('.claude/') || input.startsWith('.cursor/') || input.startsWith('.codex/')
50
- ? `\n\n Tip: Use the .aw_registry/ path instead:\n aw push .aw_registry/${input.split('/').slice(1).join('/')}`
51
- : '';
52
- fmt.cancel(`Could not resolve "${input}" to a registry path.${hint}\n\n Only files inside .aw_registry/ can be pushed.`);
94
+ function generateBranchName(files) {
95
+ const shortId = Date.now().toString(36).slice(-5);
96
+ const namespaces = [...new Set(files.map(f => f.namespace))];
97
+
98
+ if (files.length === 1) {
99
+ const f = files[0];
100
+ const nsSlug = f.namespace.replace(/\//g, '-');
101
+ return `upload/${nsSlug}-${f.type}-${f.slug}-${shortId}`;
53
102
  }
54
103
 
55
- // Find the local file to upload
56
- let absPath = resolved.localAbsPath;
57
- if (!absPath || !existsSync(absPath)) {
58
- // Try resolving with .md extension for flat files
59
- if (absPath && !absPath.endsWith('.md') && existsSync(absPath + '.md')) {
60
- absPath = absPath + '.md';
61
- } else {
62
- fmt.cancel(`File not found: ${absPath || input}\n\n Only files inside .aw_registry/ can be pushed.\n Use ${chalk.dim('aw status')} to see modified files.`);
63
- }
104
+ if (namespaces.length === 1) {
105
+ return `sync/${namespaces[0].replace(/\//g, '-')}-${shortId}`;
64
106
  }
65
107
 
66
- // Parse registry path to get type, namespace, slug
67
- // The path must contain agents/, skills/, or commands/ as a parent directory.
68
- // Nesting before that doesn't matter (e.g. platform/services/agents/ is fine).
69
- const regParts = resolved.registryPath.split('/');
70
- let typeIdx = -1;
71
- const PUSHABLE_TYPES = ['agents', 'skills', 'commands', 'evals'];
72
- for (let i = regParts.length - 1; i >= 0; i--) {
73
- if (PUSHABLE_TYPES.includes(regParts[i])) { typeIdx = i; break; }
108
+ return `sync/batch-${shortId}`;
109
+ }
110
+
111
+ function generatePrTitle(files) {
112
+ if (files.length === 1) {
113
+ const f = files[0];
114
+ return `Add ${f.slug} (${f.type}) to ${f.namespace}`;
74
115
  }
75
116
 
76
- if (typeIdx === -1 || typeIdx + 1 >= regParts.length) {
77
- fmt.cancel([
78
- `Invalid push path: ${chalk.red(resolved.registryPath)}`,
79
- '',
80
- ` Content must live under an ${chalk.bold('agents/')}, ${chalk.bold('skills/')}, ${chalk.bold('commands/')}, or ${chalk.bold('evals/')} directory.`,
117
+ const counts = {};
118
+ for (const f of files) {
119
+ counts[f.type] = (counts[f.type] || 0) + 1;
120
+ }
121
+ const countParts = Object.entries(counts).map(([type, count]) => `${count} ${type}`);
122
+ const namespaces = [...new Set(files.map(f => f.namespace))];
123
+
124
+ if (namespaces.length === 1) {
125
+ return `sync: ${countParts.join(', ')} in ${namespaces[0]}`;
126
+ }
127
+ return `sync: ${countParts.join(', ')} across ${namespaces.join(', ')}`;
128
+ }
129
+
130
+ function generatePrBody(files, newNamespaces) {
131
+ if (files.length === 1) {
132
+ const f = files[0];
133
+ const bodyParts = [
134
+ '## Registry Upload',
81
135
  '',
82
- ` ${chalk.dim('Valid examples:')}`,
83
- ` aw push .aw_registry/commerce/quality/agents/unit-tester.md`,
84
- ` aw push .aw_registry/platform/services/skills/development`,
85
- ` aw push .aw_registry/commerce/shared/commands/ship.md`,
86
- ].join('\n'));
136
+ `- **Type:** ${f.type}`,
137
+ `- **Slug:** ${f.slug}`,
138
+ `- **Namespace:** ${f.namespace}`,
139
+ `- **Path:** \`${f.registryTarget}\``,
140
+ ];
141
+ if (newNamespaces.length > 0) {
142
+ bodyParts.push('', '> **New namespace** — CODEOWNERS entry auto-added.');
143
+ }
144
+ bodyParts.push('', 'Uploaded via `aw push`');
145
+ return bodyParts.join('\n');
87
146
  }
88
147
 
89
- const namespaceParts = regParts.slice(0, typeIdx);
90
- const parentDir = regParts[typeIdx];
91
- const slug = regParts[typeIdx + 1];
92
- const namespacePath = namespaceParts.join('/');
93
- const topNamespace = namespaceParts[0];
148
+ // Batch body group by type
149
+ const grouped = {};
150
+ for (const f of files) {
151
+ if (!grouped[f.type]) grouped[f.type] = [];
152
+ grouped[f.type].push(f);
153
+ }
94
154
 
95
- if (topNamespace === 'platform') {
96
- fmt.cancel("Cannot push to 'platform' namespace — it is the shared platform layer");
155
+ const bodyParts = ['## Registry Sync', ''];
156
+ for (const [type, items] of Object.entries(grouped)) {
157
+ bodyParts.push(`### ${type}`);
158
+ for (const item of items.sort((a, b) => a.slug.localeCompare(b.slug))) {
159
+ bodyParts.push(`- \`${item.slug}\` (${item.namespace})`);
160
+ }
161
+ bodyParts.push('');
97
162
  }
98
163
 
99
- const isDir = statSync(absPath).isDirectory();
100
- const registryTarget = isDir
101
- ? `${REGISTRY_DIR}/${namespacePath}/${parentDir}/${slug}`
102
- : `${REGISTRY_DIR}/${namespacePath}/${parentDir}/${slug}.md`;
164
+ if (newNamespaces.length > 0) {
165
+ bodyParts.push(`> **New namespace${newNamespaces.length > 1 ? 's' : ''}:** ${newNamespaces.join(', ')} — CODEOWNERS entr${newNamespaces.length > 1 ? 'ies' : 'y'} auto-added.`);
166
+ bodyParts.push('');
167
+ }
168
+
169
+ bodyParts.push(`---`, `Synced via \`aw push\` (${files.length} files)`);
170
+ return bodyParts.join('\n');
171
+ }
172
+
173
+ // ── Git pipeline (works for single file or batch) ────────────────────
103
174
 
104
- fmt.note(
105
- [
106
- `${chalk.dim('source:')} ${absPath}`,
107
- `${chalk.dim('type:')} ${parentDir}`,
108
- `${chalk.dim('namespace:')} ${namespacePath}`,
109
- `${chalk.dim('slug:')} ${slug}`,
110
- `${chalk.dim('target:')} ${registryTarget}`,
111
- ].join('\n'),
112
- 'Upload mapping'
113
- );
175
+ function pushFiles(files, { repo, dryRun, workspaceDir }) {
176
+ // Summary
177
+ const counts = {};
178
+ for (const f of files) {
179
+ counts[f.type] = (counts[f.type] || 0) + 1;
180
+ }
181
+ const countParts = Object.entries(counts).map(([type, count]) => `${count} ${type}`);
182
+ fmt.logInfo(`${chalk.bold(files.length)} file${files.length > 1 ? 's' : ''} to push (${countParts.join(', ')})`);
114
183
 
115
184
  if (dryRun) {
185
+ for (const f of files) {
186
+ const ns = chalk.dim(` [${f.namespace}]`);
187
+ fmt.logMessage(` ${chalk.yellow(f.type)}/${f.slug}${ns}`);
188
+ }
116
189
  fmt.logWarn('No changes made (--dry-run)');
117
- fmt.outro(chalk.dim('Remove --dry-run to upload'));
190
+ fmt.outro(chalk.dim('Remove --dry-run to push'));
118
191
  return;
119
192
  }
120
193
 
@@ -129,43 +202,59 @@ export function pushCommand(args) {
129
202
  execSync(`git checkout ${REGISTRY_BASE_BRANCH}`, { cwd: tempDir, stdio: 'pipe' });
130
203
  s.stop('Repository cloned');
131
204
 
132
- const shortId = Date.now().toString(36).slice(-5);
133
- const branch = `upload/${namespacePath.replace(/\//g, '-')}-${parentDir}-${slug}-${shortId}`;
205
+ const branch = generateBranchName(files);
134
206
  execSync(`git checkout -b ${branch}`, { cwd: tempDir, stdio: 'pipe' });
135
207
 
136
208
  const s2 = fmt.spinner();
137
- s2.start('Preparing upload...');
209
+ s2.start(`Copying ${files.length} file${files.length > 1 ? 's' : ''} to registry...`);
210
+
211
+ // Copy each file
212
+ let copyErrors = [];
213
+ for (const file of files) {
214
+ try {
215
+ const targetFull = join(tempDir, file.registryTarget);
216
+ mkdirSync(dirname(targetFull), { recursive: true });
217
+ if (file.isDir) {
218
+ cpSync(file.absPath, targetFull, { recursive: true });
219
+ } else {
220
+ cpSync(file.absPath, targetFull);
221
+ }
222
+ } catch (e) {
223
+ copyErrors.push({ file: file.registryTarget, error: e.message });
224
+ }
225
+ }
138
226
 
139
- // Copy to target
140
- const targetFull = join(tempDir, registryTarget);
141
- if (isDir) {
142
- mkdirSync(targetFull, { recursive: true });
143
- cpSync(absPath, targetFull, { recursive: true });
144
- } else {
145
- mkdirSync(dirname(targetFull), { recursive: true });
146
- cpSync(absPath, targetFull);
227
+ if (copyErrors.length > 0) {
228
+ for (const err of copyErrors) {
229
+ fmt.logWarn(`Failed to copy ${err.file}: ${err.error}`);
230
+ }
147
231
  }
148
232
 
149
- // Check if this is a new namespace — auto-add CODEOWNERS entry
150
- let newNamespace = false;
151
- const nsDir = join(tempDir, REGISTRY_DIR, topNamespace);
233
+ // Check for new namespaces — auto-add CODEOWNERS entries
234
+ const topNamespaces = [...new Set(files.map(f => f.namespace.split('/')[0]))];
152
235
  const codeownersPath = join(tempDir, 'CODEOWNERS');
153
- if (!existsSync(nsDir) || isNewNamespaceInCodeowners(codeownersPath, topNamespace)) {
154
- newNamespace = true;
155
- const ghUser = getGitHubUser();
156
- if (ghUser && existsSync(codeownersPath)) {
157
- const line = `/${REGISTRY_DIR}/${topNamespace}/ @${ghUser}\n`;
158
- appendFileSync(codeownersPath, line);
159
- execSync('git add CODEOWNERS', { cwd: tempDir, stdio: 'pipe' });
236
+ const newNamespaces = [];
237
+ for (const ns of topNamespaces) {
238
+ if (isNewNamespaceInCodeowners(codeownersPath, ns)) {
239
+ 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
+ }
160
245
  }
161
246
  }
247
+ if (newNamespaces.length > 0) {
248
+ execSync('git add CODEOWNERS', { cwd: tempDir, stdio: 'pipe' });
249
+ }
162
250
 
163
- // Stage + commit + push + PR
164
- execSync(`git add "${registryTarget}"`, { cwd: tempDir, stdio: 'pipe' });
251
+ // Stage all registry changes
252
+ execSync(`git add "${REGISTRY_DIR}"`, { cwd: tempDir, stdio: 'pipe' });
165
253
 
166
- const commitMsg = newNamespace
167
- ? `registry: create namespace ${topNamespace} + add ${parentDir}/${slug}`
168
- : `registry: add ${parentDir}/${slug} to ${namespacePath}`;
254
+ const prTitle = generatePrTitle(files);
255
+ const commitMsg = files.length === 1
256
+ ? `registry: add ${files[0].type}/${files[0].slug} to ${files[0].namespace}`
257
+ : `registry: sync ${files.length} files (${countParts.join(', ')})`;
169
258
  execSync(`git commit -m "${commitMsg}"`, { cwd: tempDir, stdio: 'pipe' });
170
259
 
171
260
  s2.stop('Upload prepared');
@@ -175,21 +264,7 @@ export function pushCommand(args) {
175
264
 
176
265
  execSync(`git push -u origin ${branch}`, { cwd: tempDir, stdio: 'pipe' });
177
266
 
178
- const bodyParts = [
179
- '## Registry Upload',
180
- '',
181
- `- **Type:** ${parentDir}`,
182
- `- **Slug:** ${slug}`,
183
- `- **Namespace:** ${namespacePath}`,
184
- `- **Path:** \`${registryTarget}\``,
185
- ];
186
- if (newNamespace) {
187
- bodyParts.push('', '> **New namespace** — CODEOWNERS entry auto-added.');
188
- }
189
- bodyParts.push('', 'Uploaded via `aw push`');
190
-
191
- const prTitle = `Add ${slug} (${parentDir}) to ${namespacePath}`;
192
- const prBody = bodyParts.join('\n');
267
+ const prBody = generatePrBody(files, newNamespaces);
193
268
 
194
269
  // Try gh for PR creation, fall back to manual URL
195
270
  let prUrl;
@@ -201,27 +276,145 @@ export function pushCommand(args) {
201
276
  '--body', prBody,
202
277
  ], { cwd: tempDir, encoding: 'utf8' }).trim();
203
278
  } catch {
204
- // gh not available — construct PR URL manually
205
279
  const repoBase = repo.replace(/\.git$/, '');
206
280
  prUrl = `https://github.com/${repoBase}/compare/${REGISTRY_BASE_BRANCH}...${branch}?expand=1`;
207
281
  }
208
282
 
209
283
  s3.stop('Branch pushed');
210
284
 
211
- if (newNamespace) {
212
- fmt.logInfo(`New namespace ${chalk.cyan(topNamespace)} — CODEOWNERS entry added`);
285
+ // Print summary
286
+ if (newNamespaces.length > 0) {
287
+ fmt.logInfo(`New namespace${newNamespaces.length > 1 ? 's' : ''} ${chalk.cyan(newNamespaces.join(', '))} — CODEOWNERS entr${newNamespaces.length > 1 ? 'ies' : 'y'} added`);
213
288
  }
289
+ if (files.length > 1) {
290
+ for (const [type, items] of Object.entries(groupBy(files, 'type'))) {
291
+ fmt.logSuccess(`${items.length} ${type} pushed`);
292
+ }
293
+ }
294
+ // Update manifest — mark pushed files as synced (set registry_sha256 = sha256)
295
+ if (workspaceDir) {
296
+ const manifest = loadManifest(workspaceDir);
297
+ for (const file of files) {
298
+ // Convert registryTarget back to manifest key (strip REGISTRY_DIR/ prefix)
299
+ const manifestKey = file.registryTarget.replace(`${REGISTRY_DIR}/`, '');
300
+ if (manifest.files[manifestKey]) {
301
+ manifest.files[manifestKey].registry_sha256 = manifest.files[manifestKey].sha256;
302
+ }
303
+ }
304
+ saveManifest(workspaceDir, manifest);
305
+ }
306
+
214
307
  fmt.logSuccess(`PR: ${chalk.cyan(prUrl)}`);
215
- fmt.outro('Upload complete — open the link above to create the PR');
308
+ fmt.outro('Push complete');
216
309
  } catch (e) {
217
- fmt.cancel(`Upload failed: ${e.message}`);
310
+ fmt.cancel(`Push failed: ${e.message}`);
218
311
  } finally {
219
312
  execSync(`rm -rf "${tempDir}"`, { stdio: 'pipe' });
220
313
  }
221
314
  }
222
315
 
316
+ function groupBy(arr, key) {
317
+ const result = {};
318
+ for (const item of arr) {
319
+ const k = item[key];
320
+ if (!result[k]) result[k] = [];
321
+ result[k].push(item);
322
+ }
323
+ return result;
324
+ }
325
+
326
+ // ── Main command ─────────────────────────────────────────────────────
327
+
328
+ export function pushCommand(args) {
329
+ const input = args._positional?.[0];
330
+ const dryRun = args['--dry-run'] === true;
331
+ const repo = args['--repo'] || REGISTRY_REPO;
332
+ const cwd = process.cwd();
333
+ const workspaceDir = join(cwd, '.aw_registry');
334
+
335
+ fmt.intro('aw push');
336
+
337
+ // No args = push all modified + new (unpushed) files
338
+ if (!input) {
339
+ const files = collectModifiedFiles(workspaceDir);
340
+ if (files.length === 0) {
341
+ fmt.cancel('Nothing to push — no modified or new files.\n\n Use `aw status` to see synced files.');
342
+ }
343
+ pushFiles(files, { repo, dryRun, workspaceDir });
344
+ return;
345
+ }
346
+
347
+ // Resolve input — accept both registry path and local path
348
+ const resolved = resolveInput(input, workspaceDir);
349
+
350
+ if (!resolved.registryPath) {
351
+ const hint = input.startsWith('.claude/') || input.startsWith('.cursor/') || input.startsWith('.codex/')
352
+ ? `\n\n Tip: Use the .aw_registry/ path instead:\n aw push .aw_registry/${input.split('/').slice(1).join('/')}`
353
+ : '';
354
+ fmt.cancel(`Could not resolve "${input}" to a registry path.${hint}\n\n Only files inside .aw_registry/ can be pushed.`);
355
+ }
356
+
357
+ let absPath = resolved.localAbsPath;
358
+ if (!absPath || !existsSync(absPath)) {
359
+ if (absPath && !absPath.endsWith('.md') && existsSync(absPath + '.md')) {
360
+ absPath = absPath + '.md';
361
+ } else {
362
+ fmt.cancel(`Path not found: ${absPath || input}\n\n Only files inside .aw_registry/ can be pushed.\n Use ${chalk.dim('aw status')} to see modified files.`);
363
+ }
364
+ }
365
+
366
+ // Folder/namespace input → batch push
367
+ if (statSync(absPath).isDirectory()) {
368
+ const files = collectBatchFiles(absPath, workspaceDir);
369
+ if (files.length === 0) {
370
+ fmt.cancel(`Nothing to push in ${chalk.cyan(input)} — no agents, skills, commands, or evals found.`);
371
+ }
372
+ pushFiles(files, { repo, dryRun, workspaceDir });
373
+ return;
374
+ }
375
+
376
+ // Single file input → wrap in array and push
377
+ const regParts = resolved.registryPath.split('/');
378
+ let typeIdx = -1;
379
+ for (let i = regParts.length - 1; i >= 0; i--) {
380
+ if (PUSHABLE_TYPES.includes(regParts[i])) { typeIdx = i; break; }
381
+ }
382
+
383
+ if (typeIdx === -1 || typeIdx + 1 >= regParts.length) {
384
+ fmt.cancel([
385
+ `Invalid push path: ${chalk.red(resolved.registryPath)}`,
386
+ '',
387
+ ` Content must live under an ${chalk.bold('agents/')}, ${chalk.bold('skills/')}, ${chalk.bold('commands/')}, or ${chalk.bold('evals/')} directory.`,
388
+ '',
389
+ ` ${chalk.dim('Valid examples:')}`,
390
+ ` aw push .aw_registry/commerce/quality/agents/unit-tester.md`,
391
+ ` aw push .aw_registry/platform/services/skills/development`,
392
+ ` aw push .aw_registry/commerce/shared/commands/ship.md`,
393
+ ].join('\n'));
394
+ }
395
+
396
+ const namespaceParts = regParts.slice(0, typeIdx);
397
+ const parentDir = regParts[typeIdx];
398
+ const slug = regParts[typeIdx + 1];
399
+ const namespacePath = namespaceParts.join('/');
400
+ const isDir = statSync(absPath).isDirectory();
401
+ const registryTarget = isDir
402
+ ? `${REGISTRY_DIR}/${namespacePath}/${parentDir}/${slug}`
403
+ : `${REGISTRY_DIR}/${namespacePath}/${parentDir}/${slug}.md`;
404
+
405
+ pushFiles([{
406
+ absPath,
407
+ registryTarget,
408
+ type: parentDir,
409
+ namespace: namespacePath,
410
+ slug,
411
+ isDir,
412
+ }], { repo, dryRun, workspaceDir });
413
+ }
414
+
415
+ // ── Helpers ──────────────────────────────────────────────────────────
416
+
223
417
  function getGitHubUser() {
224
- // Try gh first, fall back to git config
225
418
  try {
226
419
  return execSync('gh api user --jq .login', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
227
420
  } catch {
@@ -39,6 +39,7 @@ export function statusCommand(args) {
39
39
 
40
40
  const files = Object.entries(manifest.files || {});
41
41
  const modified = [];
42
+ const unpushed = [];
42
43
  const missing = [];
43
44
  const conflicts = [];
44
45
 
@@ -51,6 +52,12 @@ export function statusCommand(args) {
51
52
 
52
53
  const currentHash = hashFile(filePath);
53
54
 
55
+ // Template-derived files that were never pushed to the remote registry
56
+ if (!entry.registry_sha256) {
57
+ unpushed.push(key);
58
+ continue;
59
+ }
60
+
54
61
  if (currentHash !== entry.sha256) {
55
62
  const content = readFileSync(filePath, 'utf8');
56
63
  if (content.includes('<<<<<<<')) {
@@ -64,6 +71,7 @@ export function statusCommand(args) {
64
71
  // Summary line
65
72
  const summaryParts = [
66
73
  `${files.length} synced`,
74
+ unpushed.length > 0 ? chalk.green(`${unpushed.length} new (unpushed)`) : null,
67
75
  modified.length > 0 ? chalk.yellow(`${modified.length} modified`) : null,
68
76
  conflicts.length > 0 ? chalk.red(`${conflicts.length} conflicts`) : null,
69
77
  missing.length > 0 ? chalk.dim(`${missing.length} missing`) : null,
@@ -78,6 +86,17 @@ export function statusCommand(args) {
78
86
  );
79
87
  }
80
88
 
89
+ if (unpushed.length > 0) {
90
+ // Group by namespace for cleaner display
91
+ const nsCounts = {};
92
+ for (const key of unpushed) {
93
+ const ns = key.split('/').slice(0, 2).join('/');
94
+ nsCounts[ns] = (nsCounts[ns] || 0) + 1;
95
+ }
96
+ const nsLines = Object.entries(nsCounts).map(([ns, count]) => chalk.green(` ${ns}: ${count} files`)).join('\n');
97
+ fmt.note(nsLines, chalk.green('New (unpushed)'));
98
+ }
99
+
81
100
  if (modified.length > 0) {
82
101
  fmt.note(
83
102
  modified.map(m => chalk.yellow(m)).join('\n'),
@@ -92,7 +111,7 @@ export function statusCommand(args) {
92
111
  );
93
112
  }
94
113
 
95
- if (modified.length === 0 && conflicts.length === 0 && missing.length === 0) {
114
+ if (modified.length === 0 && conflicts.length === 0 && missing.length === 0 && unpushed.length === 0) {
96
115
  fmt.logSuccess('Workspace is clean');
97
116
  }
98
117
 
@@ -100,6 +119,9 @@ export function statusCommand(args) {
100
119
  if (conflicts.length > 0) {
101
120
  fmt.logWarn(`Fix conflicts: ${chalk.dim('grep -r "<<<<<<< " .aw_registry/')}`);
102
121
  }
122
+ if (unpushed.length > 0) {
123
+ fmt.logInfo(`Push new files: ${chalk.dim('aw push')} or ${chalk.dim('aw push <namespace>')}`);
124
+ }
103
125
  if (modified.length > 0) {
104
126
  fmt.logInfo(`Push changes: ${chalk.dim('aw push <path>')}`);
105
127
  }
package/constants.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // constants.mjs — Single source of truth for registry settings.
2
2
 
3
3
  /** Base branch for PRs and sync checkout */
4
- export const REGISTRY_BASE_BRANCH = 'main';
4
+ export const REGISTRY_BASE_BRANCH = 'feat/aw-push-batch';
5
5
 
6
6
  /** Default registry repository */
7
7
  export const REGISTRY_REPO = 'GoHighLevel/platform-docs';
package/manifest.mjs CHANGED
@@ -24,7 +24,7 @@ export function save(workspaceDir, manifest) {
24
24
  writeFileSync(manifestPath(workspaceDir), JSON.stringify(manifest, null, 2) + '\n');
25
25
  }
26
26
 
27
- export function update(workspaceDir, actions, namespaceName) {
27
+ export function update(workspaceDir, actions, namespaceName, opts = {}) {
28
28
  const manifest = load(workspaceDir);
29
29
  manifest.synced_at = new Date().toISOString();
30
30
  manifest.namespace = namespaceName || manifest.namespace;
@@ -38,7 +38,9 @@ export function update(workspaceDir, actions, namespaceName) {
38
38
  manifest.files[manifestKey] = {
39
39
  sha256: hashFile(act.targetPath),
40
40
  source: act.source,
41
- registry_sha256: act.registryHash,
41
+ // When pulled from template (renamed namespace), these files don't exist
42
+ // in the remote registry yet — mark as null so push detects them as new.
43
+ registry_sha256: opts.fromTemplate ? null : act.registryHash,
42
44
  };
43
45
  }
44
46
  } else if (act.action === 'CONFLICT') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghl-ai/aw",
3
- "version": "0.1.33",
3
+ "version": "0.1.34-beta.2",
4
4
  "description": "Agentic Workspace CLI — pull, push & manage agents, skills and commands from the registry",
5
5
  "type": "module",
6
6
  "bin": {
package/paths.mjs CHANGED
@@ -89,20 +89,25 @@ export function resolveInput(input, workspaceDir) {
89
89
  if (rewritten.startsWith('.aw_registry/') || rewritten.startsWith('.aw_registry\\')) {
90
90
  const registryPath = localToRegistry(rewritten);
91
91
  const absPath = resolve(rewritten);
92
+ const isDirectory = existsSync(absPath) && statSync(absPath).isDirectory();
92
93
  return {
93
94
  registryPath: registryPath?.replace(/\.md$/, '') || null,
94
95
  localAbsPath: absPath,
95
96
  isLocalPath: true,
97
+ isDirectory,
96
98
  };
97
99
  }
98
100
 
99
101
  // Registry path
100
102
  const cleanPath = rewritten.replace(/\.md$/, '');
101
103
  const localRel = registryToLocal(cleanPath);
104
+ const absPath = localRel ? join(workspaceDir, localRel) : null;
105
+ const isDirectory = absPath && existsSync(absPath) && statSync(absPath).isDirectory();
102
106
  return {
103
107
  registryPath: cleanPath,
104
- localAbsPath: localRel ? join(workspaceDir, localRel) : null,
108
+ localAbsPath: absPath,
105
109
  isLocalPath: false,
110
+ isDirectory: !!isDirectory,
106
111
  };
107
112
  }
108
113
 
package/plan.mjs CHANGED
@@ -68,7 +68,10 @@ export function computePlan(registryDirs, workspaceDir, includePatterns = [], {
68
68
  action = 'UPDATE';
69
69
  } else {
70
70
  const localChanged = localHash !== manifestEntry.sha256;
71
- const registryChanged = registryHash !== manifestEntry.registry_sha256;
71
+ // registry_sha256 is null for template-derived files (never pushed).
72
+ // Treat null as "no known registry version" — can't conflict with unknown.
73
+ const registryChanged = manifestEntry.registry_sha256 != null
74
+ && registryHash !== manifestEntry.registry_sha256;
72
75
  if (localChanged && registryChanged) {
73
76
  action = 'CONFLICT';
74
77
  } else if (localChanged) {