@ghl-ai/aw 0.1.32 → 0.1.34-beta.1

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
@@ -73,7 +73,7 @@ function printHelp() {
73
73
  const help = [
74
74
  sec('Setup'),
75
75
  cmd('aw init --namespace <team/sub-team>', 'Initialize workspace (required)'),
76
- ` ${chalk.dim('Teams: revex, mobile, commerce, leadgen, crm, marketplace, ai')}`,
76
+ ` ${chalk.dim('Teams: platform, revex, mobile, commerce, leadgen, crm, marketplace, ai')}`,
77
77
  ` ${chalk.dim('Example: aw init --namespace revex/courses')}`,
78
78
 
79
79
  sec('Download'),
@@ -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/init.mjs CHANGED
@@ -92,7 +92,7 @@ function printPullSummary(pattern, actions) {
92
92
  }
93
93
  }
94
94
 
95
- const ALLOWED_NAMESPACES = ['revex', 'mobile', 'commerce', 'leadgen', 'crm', 'marketplace', 'ai'];
95
+ const ALLOWED_NAMESPACES = ['platform', 'revex', 'mobile', 'commerce', 'leadgen', 'crm', 'marketplace', 'ai'];
96
96
 
97
97
  export async function initCommand(args) {
98
98
  const namespace = args['--namespace'] || null;
@@ -133,7 +133,7 @@ export async function initCommand(args) {
133
133
  ].join('\n'));
134
134
  }
135
135
 
136
- if (team && !subTeam) {
136
+ if (team && !subTeam && team !== 'platform') {
137
137
  fmt.cancel([
138
138
  `Missing sub-team in ${chalk.red(namespace)}`,
139
139
  '',
package/commands/push.mjs CHANGED
@@ -1,7 +1,7 @@
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';
@@ -9,112 +9,183 @@ 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
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');
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
+ if (currentHash !== entry.sha256) {
57
+ const meta = parseManifestKey(key);
58
+ if (meta) {
59
+ files.push({
60
+ absPath: filePath,
61
+ registryTarget: `${REGISTRY_DIR}/${key}`,
62
+ type: meta.type,
63
+ namespace: meta.namespace,
64
+ slug: meta.slug,
65
+ isDir: false,
66
+ });
33
67
  }
34
68
  }
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)}`);
69
+ }
70
+ return files;
71
+ }
72
+
73
+ /**
74
+ * Parse a manifest key like "commerce/agents/unit-tester.md" into { type, namespace, slug }.
75
+ */
76
+ function parseManifestKey(key) {
77
+ const parts = key.split('/');
78
+ for (let i = 0; i < parts.length; i++) {
79
+ if (PUSHABLE_TYPES.includes(parts[i]) && i + 1 < parts.length) {
80
+ return {
81
+ type: parts[i],
82
+ namespace: parts.slice(0, i).join('/'),
83
+ slug: parts[i + 1].replace(/\.md$/, ''),
84
+ };
41
85
  }
42
- fmt.cancel(`\nSpecify which file to push:\n aw push .aw_registry/${modified[0]}`);
43
86
  }
87
+ return null;
88
+ }
44
89
 
45
- // Resolve input accept both registry path and local path
46
- const resolved = resolveInput(input, workspaceDir);
90
+ // ── PR content generation ────────────────────────────────────────────
47
91
 
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.`);
92
+ function generateBranchName(files) {
93
+ const shortId = Date.now().toString(36).slice(-5);
94
+ const namespaces = [...new Set(files.map(f => f.namespace))];
95
+
96
+ if (files.length === 1) {
97
+ const f = files[0];
98
+ const nsSlug = f.namespace.replace(/\//g, '-');
99
+ return `upload/${nsSlug}-${f.type}-${f.slug}-${shortId}`;
53
100
  }
54
101
 
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
- }
102
+ if (namespaces.length === 1) {
103
+ return `sync/${namespaces[0].replace(/\//g, '-')}-${shortId}`;
64
104
  }
65
105
 
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; }
106
+ return `sync/batch-${shortId}`;
107
+ }
108
+
109
+ function generatePrTitle(files) {
110
+ if (files.length === 1) {
111
+ const f = files[0];
112
+ return `Add ${f.slug} (${f.type}) to ${f.namespace}`;
74
113
  }
75
114
 
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.`,
115
+ const counts = {};
116
+ for (const f of files) {
117
+ counts[f.type] = (counts[f.type] || 0) + 1;
118
+ }
119
+ const countParts = Object.entries(counts).map(([type, count]) => `${count} ${type}`);
120
+ const namespaces = [...new Set(files.map(f => f.namespace))];
121
+
122
+ if (namespaces.length === 1) {
123
+ return `sync: ${countParts.join(', ')} in ${namespaces[0]}`;
124
+ }
125
+ return `sync: ${countParts.join(', ')} across ${namespaces.join(', ')}`;
126
+ }
127
+
128
+ function generatePrBody(files, newNamespaces) {
129
+ if (files.length === 1) {
130
+ const f = files[0];
131
+ const bodyParts = [
132
+ '## Registry Upload',
81
133
  '',
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'));
134
+ `- **Type:** ${f.type}`,
135
+ `- **Slug:** ${f.slug}`,
136
+ `- **Namespace:** ${f.namespace}`,
137
+ `- **Path:** \`${f.registryTarget}\``,
138
+ ];
139
+ if (newNamespaces.length > 0) {
140
+ bodyParts.push('', '> **New namespace** — CODEOWNERS entry auto-added.');
141
+ }
142
+ bodyParts.push('', 'Uploaded via `aw push`');
143
+ return bodyParts.join('\n');
87
144
  }
88
145
 
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];
146
+ // Batch body group by type
147
+ const grouped = {};
148
+ for (const f of files) {
149
+ if (!grouped[f.type]) grouped[f.type] = [];
150
+ grouped[f.type].push(f);
151
+ }
94
152
 
95
- if (topNamespace === 'platform') {
96
- fmt.cancel("Cannot push to 'platform' namespace — it is the shared platform layer");
153
+ const bodyParts = ['## Registry Sync', ''];
154
+ for (const [type, items] of Object.entries(grouped)) {
155
+ bodyParts.push(`### ${type}`);
156
+ for (const item of items.sort((a, b) => a.slug.localeCompare(b.slug))) {
157
+ bodyParts.push(`- \`${item.slug}\` (${item.namespace})`);
158
+ }
159
+ bodyParts.push('');
97
160
  }
98
161
 
99
- const isDir = statSync(absPath).isDirectory();
100
- const registryTarget = isDir
101
- ? `${REGISTRY_DIR}/${namespacePath}/${parentDir}/${slug}`
102
- : `${REGISTRY_DIR}/${namespacePath}/${parentDir}/${slug}.md`;
162
+ if (newNamespaces.length > 0) {
163
+ bodyParts.push(`> **New namespace${newNamespaces.length > 1 ? 's' : ''}:** ${newNamespaces.join(', ')} — CODEOWNERS entr${newNamespaces.length > 1 ? 'ies' : 'y'} auto-added.`);
164
+ bodyParts.push('');
165
+ }
166
+
167
+ bodyParts.push(`---`, `Synced via \`aw push\` (${files.length} files)`);
168
+ return bodyParts.join('\n');
169
+ }
103
170
 
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
- );
171
+ // ── Git pipeline (works for single file or batch) ────────────────────
172
+
173
+ function pushFiles(files, { repo, dryRun }) {
174
+ // Summary
175
+ const counts = {};
176
+ for (const f of files) {
177
+ counts[f.type] = (counts[f.type] || 0) + 1;
178
+ }
179
+ const countParts = Object.entries(counts).map(([type, count]) => `${count} ${type}`);
180
+ fmt.logInfo(`${chalk.bold(files.length)} file${files.length > 1 ? 's' : ''} to push (${countParts.join(', ')})`);
114
181
 
115
182
  if (dryRun) {
183
+ for (const f of files) {
184
+ const ns = chalk.dim(` [${f.namespace}]`);
185
+ fmt.logMessage(` ${chalk.yellow(f.type)}/${f.slug}${ns}`);
186
+ }
116
187
  fmt.logWarn('No changes made (--dry-run)');
117
- fmt.outro(chalk.dim('Remove --dry-run to upload'));
188
+ fmt.outro(chalk.dim('Remove --dry-run to push'));
118
189
  return;
119
190
  }
120
191
 
@@ -129,43 +200,59 @@ export function pushCommand(args) {
129
200
  execSync(`git checkout ${REGISTRY_BASE_BRANCH}`, { cwd: tempDir, stdio: 'pipe' });
130
201
  s.stop('Repository cloned');
131
202
 
132
- const shortId = Date.now().toString(36).slice(-5);
133
- const branch = `upload/${namespacePath.replace(/\//g, '-')}-${parentDir}-${slug}-${shortId}`;
203
+ const branch = generateBranchName(files);
134
204
  execSync(`git checkout -b ${branch}`, { cwd: tempDir, stdio: 'pipe' });
135
205
 
136
206
  const s2 = fmt.spinner();
137
- s2.start('Preparing upload...');
207
+ s2.start(`Copying ${files.length} file${files.length > 1 ? 's' : ''} to registry...`);
208
+
209
+ // Copy each file
210
+ let copyErrors = [];
211
+ for (const file of files) {
212
+ try {
213
+ const targetFull = join(tempDir, file.registryTarget);
214
+ mkdirSync(dirname(targetFull), { recursive: true });
215
+ if (file.isDir) {
216
+ cpSync(file.absPath, targetFull, { recursive: true });
217
+ } else {
218
+ cpSync(file.absPath, targetFull);
219
+ }
220
+ } catch (e) {
221
+ copyErrors.push({ file: file.registryTarget, error: e.message });
222
+ }
223
+ }
138
224
 
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);
225
+ if (copyErrors.length > 0) {
226
+ for (const err of copyErrors) {
227
+ fmt.logWarn(`Failed to copy ${err.file}: ${err.error}`);
228
+ }
147
229
  }
148
230
 
149
- // Check if this is a new namespace — auto-add CODEOWNERS entry
150
- let newNamespace = false;
151
- const nsDir = join(tempDir, REGISTRY_DIR, topNamespace);
231
+ // Check for new namespaces — auto-add CODEOWNERS entries
232
+ const topNamespaces = [...new Set(files.map(f => f.namespace.split('/')[0]))];
152
233
  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' });
234
+ const newNamespaces = [];
235
+ for (const ns of topNamespaces) {
236
+ if (isNewNamespaceInCodeowners(codeownersPath, ns)) {
237
+ newNamespaces.push(ns);
238
+ const ghUser = getGitHubUser();
239
+ if (ghUser && existsSync(codeownersPath)) {
240
+ const line = `/${REGISTRY_DIR}/${ns}/ @${ghUser}\n`;
241
+ appendFileSync(codeownersPath, line);
242
+ }
160
243
  }
161
244
  }
245
+ if (newNamespaces.length > 0) {
246
+ execSync('git add CODEOWNERS', { cwd: tempDir, stdio: 'pipe' });
247
+ }
162
248
 
163
- // Stage + commit + push + PR
164
- execSync(`git add "${registryTarget}"`, { cwd: tempDir, stdio: 'pipe' });
249
+ // Stage all registry changes
250
+ execSync(`git add "${REGISTRY_DIR}"`, { cwd: tempDir, stdio: 'pipe' });
165
251
 
166
- const commitMsg = newNamespace
167
- ? `registry: create namespace ${topNamespace} + add ${parentDir}/${slug}`
168
- : `registry: add ${parentDir}/${slug} to ${namespacePath}`;
252
+ const prTitle = generatePrTitle(files);
253
+ const commitMsg = files.length === 1
254
+ ? `registry: add ${files[0].type}/${files[0].slug} to ${files[0].namespace}`
255
+ : `registry: sync ${files.length} files (${countParts.join(', ')})`;
169
256
  execSync(`git commit -m "${commitMsg}"`, { cwd: tempDir, stdio: 'pipe' });
170
257
 
171
258
  s2.stop('Upload prepared');
@@ -175,21 +262,7 @@ export function pushCommand(args) {
175
262
 
176
263
  execSync(`git push -u origin ${branch}`, { cwd: tempDir, stdio: 'pipe' });
177
264
 
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');
265
+ const prBody = generatePrBody(files, newNamespaces);
193
266
 
194
267
  // Try gh for PR creation, fall back to manual URL
195
268
  let prUrl;
@@ -201,27 +274,132 @@ export function pushCommand(args) {
201
274
  '--body', prBody,
202
275
  ], { cwd: tempDir, encoding: 'utf8' }).trim();
203
276
  } catch {
204
- // gh not available — construct PR URL manually
205
277
  const repoBase = repo.replace(/\.git$/, '');
206
278
  prUrl = `https://github.com/${repoBase}/compare/${REGISTRY_BASE_BRANCH}...${branch}?expand=1`;
207
279
  }
208
280
 
209
281
  s3.stop('Branch pushed');
210
282
 
211
- if (newNamespace) {
212
- fmt.logInfo(`New namespace ${chalk.cyan(topNamespace)} — CODEOWNERS entry added`);
283
+ // Print summary
284
+ if (newNamespaces.length > 0) {
285
+ fmt.logInfo(`New namespace${newNamespaces.length > 1 ? 's' : ''} ${chalk.cyan(newNamespaces.join(', '))} — CODEOWNERS entr${newNamespaces.length > 1 ? 'ies' : 'y'} added`);
286
+ }
287
+ if (files.length > 1) {
288
+ for (const [type, items] of Object.entries(groupBy(files, 'type'))) {
289
+ fmt.logSuccess(`${items.length} ${type} pushed`);
290
+ }
213
291
  }
214
292
  fmt.logSuccess(`PR: ${chalk.cyan(prUrl)}`);
215
- fmt.outro('Upload complete — open the link above to create the PR');
293
+ fmt.outro('Push complete');
216
294
  } catch (e) {
217
- fmt.cancel(`Upload failed: ${e.message}`);
295
+ fmt.cancel(`Push failed: ${e.message}`);
218
296
  } finally {
219
297
  execSync(`rm -rf "${tempDir}"`, { stdio: 'pipe' });
220
298
  }
221
299
  }
222
300
 
301
+ function groupBy(arr, key) {
302
+ const result = {};
303
+ for (const item of arr) {
304
+ const k = item[key];
305
+ if (!result[k]) result[k] = [];
306
+ result[k].push(item);
307
+ }
308
+ return result;
309
+ }
310
+
311
+ // ── Main command ─────────────────────────────────────────────────────
312
+
313
+ export function pushCommand(args) {
314
+ const input = args._positional?.[0];
315
+ const dryRun = args['--dry-run'] === true;
316
+ const repo = args['--repo'] || REGISTRY_REPO;
317
+ const cwd = process.cwd();
318
+ const workspaceDir = join(cwd, '.aw_registry');
319
+
320
+ fmt.intro('aw push');
321
+
322
+ // No args = push all modified files
323
+ if (!input) {
324
+ const files = collectModifiedFiles(workspaceDir);
325
+ if (files.length === 0) {
326
+ fmt.cancel('Nothing to push — no modified files.\n\n Use `aw status` to see synced files.');
327
+ }
328
+ pushFiles(files, { repo, dryRun });
329
+ return;
330
+ }
331
+
332
+ // Resolve input — accept both registry path and local path
333
+ const resolved = resolveInput(input, workspaceDir);
334
+
335
+ if (!resolved.registryPath) {
336
+ const hint = input.startsWith('.claude/') || input.startsWith('.cursor/') || input.startsWith('.codex/')
337
+ ? `\n\n Tip: Use the .aw_registry/ path instead:\n aw push .aw_registry/${input.split('/').slice(1).join('/')}`
338
+ : '';
339
+ fmt.cancel(`Could not resolve "${input}" to a registry path.${hint}\n\n Only files inside .aw_registry/ can be pushed.`);
340
+ }
341
+
342
+ let absPath = resolved.localAbsPath;
343
+ if (!absPath || !existsSync(absPath)) {
344
+ if (absPath && !absPath.endsWith('.md') && existsSync(absPath + '.md')) {
345
+ absPath = absPath + '.md';
346
+ } else {
347
+ 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.`);
348
+ }
349
+ }
350
+
351
+ // Folder/namespace input → batch push
352
+ if (statSync(absPath).isDirectory()) {
353
+ const files = collectBatchFiles(absPath, workspaceDir);
354
+ if (files.length === 0) {
355
+ fmt.cancel(`Nothing to push in ${chalk.cyan(input)} — no agents, skills, commands, or evals found.`);
356
+ }
357
+ pushFiles(files, { repo, dryRun });
358
+ return;
359
+ }
360
+
361
+ // Single file input → wrap in array and push
362
+ const regParts = resolved.registryPath.split('/');
363
+ let typeIdx = -1;
364
+ for (let i = regParts.length - 1; i >= 0; i--) {
365
+ if (PUSHABLE_TYPES.includes(regParts[i])) { typeIdx = i; break; }
366
+ }
367
+
368
+ if (typeIdx === -1 || typeIdx + 1 >= regParts.length) {
369
+ fmt.cancel([
370
+ `Invalid push path: ${chalk.red(resolved.registryPath)}`,
371
+ '',
372
+ ` Content must live under an ${chalk.bold('agents/')}, ${chalk.bold('skills/')}, ${chalk.bold('commands/')}, or ${chalk.bold('evals/')} directory.`,
373
+ '',
374
+ ` ${chalk.dim('Valid examples:')}`,
375
+ ` aw push .aw_registry/commerce/quality/agents/unit-tester.md`,
376
+ ` aw push .aw_registry/platform/services/skills/development`,
377
+ ` aw push .aw_registry/commerce/shared/commands/ship.md`,
378
+ ].join('\n'));
379
+ }
380
+
381
+ const namespaceParts = regParts.slice(0, typeIdx);
382
+ const parentDir = regParts[typeIdx];
383
+ const slug = regParts[typeIdx + 1];
384
+ const namespacePath = namespaceParts.join('/');
385
+ const isDir = statSync(absPath).isDirectory();
386
+ const registryTarget = isDir
387
+ ? `${REGISTRY_DIR}/${namespacePath}/${parentDir}/${slug}`
388
+ : `${REGISTRY_DIR}/${namespacePath}/${parentDir}/${slug}.md`;
389
+
390
+ pushFiles([{
391
+ absPath,
392
+ registryTarget,
393
+ type: parentDir,
394
+ namespace: namespacePath,
395
+ slug,
396
+ isDir,
397
+ }], { repo, dryRun });
398
+ }
399
+
400
+ // ── Helpers ──────────────────────────────────────────────────────────
401
+
223
402
  function getGitHubUser() {
224
- // Try gh first, fall back to git config
225
403
  try {
226
404
  return execSync('gh api user --jq .login', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
227
405
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghl-ai/aw",
3
- "version": "0.1.32",
3
+ "version": "0.1.34-beta.1",
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