@ghl-ai/aw 0.1.35 → 0.1.36-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/commands/push.mjs CHANGED
@@ -1,32 +1,157 @@
1
- // commands/push.mjs — Push local agents/skills to registry via PR (single file or batch)
1
+ // commands/push.mjs — Push local agents/skills to registry via PR using persistent git clone
2
2
 
3
- import { existsSync, statSync, mkdirSync, cpSync, mkdtempSync, readFileSync, appendFileSync, readdirSync } from 'node:fs';
4
- import { basename, dirname, resolve, join, relative } from 'node:path';
3
+ import { existsSync, statSync, readFileSync, appendFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
5
  import { execSync, execFileSync } from 'node:child_process';
6
- import { tmpdir } from 'node:os';
6
+ import { homedir } 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, save as saveManifest } from '../manifest.mjs';
12
- import { hashFile, walkRegistryTree } from '../registry.mjs';
11
+ import { walkRegistryTree } from '../registry.mjs';
12
+ import {
13
+ detectChanges,
14
+ createPushBranch,
15
+ checkoutMain,
16
+ isValidClone,
17
+ } from '../git.mjs';
13
18
 
14
19
  const PUSHABLE_TYPES = ['agents', 'skills', 'commands', 'evals'];
15
20
 
16
- // ── Batch file collection ────────────────────────────────────────────
21
+ // ── PR content generation ────────────────────────────────────────────
22
+
23
+ function generateBranchName(files) {
24
+ const shortId = Date.now().toString(36).slice(-5);
25
+ const namespaces = [...new Set(files.map(f => f.namespace))];
26
+ const hasDeletes = files.some(f => f.deleted);
27
+ const allDeletes = files.every(f => f.deleted);
28
+ const prefix = allDeletes ? 'remove' : hasDeletes ? 'sync' : 'upload';
29
+
30
+ if (files.length === 1) {
31
+ const f = files[0];
32
+ const nsSlug = f.namespace.replace(/\//g, '-');
33
+ return `${prefix}/${nsSlug}-${f.type}-${f.slug}-${shortId}`;
34
+ }
35
+
36
+ if (namespaces.length === 1) {
37
+ return `${prefix}/${namespaces[0].replace(/\//g, '-')}-${shortId}`;
38
+ }
39
+
40
+ return `${prefix}/batch-${shortId}`;
41
+ }
42
+
43
+ function generatePrTitle(files) {
44
+ if (files.length === 1) {
45
+ const f = files[0];
46
+ const verb = f.deleted ? 'Remove' : 'Add';
47
+ const prep = f.deleted ? 'from' : 'to';
48
+ return `${verb} ${f.slug} (${f.type}) ${prep} ${f.namespace}`;
49
+ }
50
+
51
+ const addCounts = {};
52
+ const deleteCounts = {};
53
+ for (const f of files) {
54
+ const target = f.deleted ? deleteCounts : addCounts;
55
+ target[f.type] = (target[f.type] || 0) + 1;
56
+ }
57
+ const parts = [];
58
+ for (const [type, count] of Object.entries(addCounts)) {
59
+ parts.push(`+${count} ${type}`);
60
+ }
61
+ for (const [type, count] of Object.entries(deleteCounts)) {
62
+ parts.push(`-${count} ${type}`);
63
+ }
64
+ const namespaces = [...new Set(files.map(f => f.namespace))];
65
+
66
+ if (namespaces.length === 1) {
67
+ return `sync: ${parts.join(', ')} in ${namespaces[0]}`;
68
+ }
69
+ return `sync: ${parts.join(', ')} across ${namespaces.join(', ')}`;
70
+ }
71
+
72
+ function generatePrBody(files, newNamespaces) {
73
+ if (files.length === 1) {
74
+ const f = files[0];
75
+ const action = f.deleted ? 'Registry Removal' : 'Registry Upload';
76
+ const bodyParts = [
77
+ `## ${action}`,
78
+ '',
79
+ `- **Type:** ${f.type}`,
80
+ `- **Slug:** ${f.slug}`,
81
+ `- **Namespace:** ${f.namespace}`,
82
+ `- **Path:** \`${f.registryTarget}\``,
83
+ ];
84
+ if (f.deleted) {
85
+ bodyParts.push('', '> File was deleted locally and removed from registry.');
86
+ }
87
+ if (newNamespaces.length > 0) {
88
+ bodyParts.push('', '> **New namespace** — CODEOWNERS entry auto-added.');
89
+ }
90
+ bodyParts.push('', `${f.deleted ? 'Removed' : 'Uploaded'} via \`aw push\``);
91
+ return bodyParts.join('\n');
92
+ }
93
+
94
+ const added = files.filter(f => !f.deleted);
95
+ const deleted = files.filter(f => f.deleted);
96
+
97
+ const bodyParts = ['## Registry Sync', ''];
98
+
99
+ if (added.length > 0) {
100
+ const grouped = groupBy(added, 'type');
101
+ bodyParts.push('### Added / Updated');
102
+ for (const [type, items] of Object.entries(grouped)) {
103
+ bodyParts.push(`**${type}**`);
104
+ for (const item of items.sort((a, b) => a.slug.localeCompare(b.slug))) {
105
+ bodyParts.push(`- \`${item.slug}\` (${item.namespace})`);
106
+ }
107
+ }
108
+ bodyParts.push('');
109
+ }
110
+
111
+ if (deleted.length > 0) {
112
+ const grouped = groupBy(deleted, 'type');
113
+ bodyParts.push('### Removed');
114
+ for (const [type, items] of Object.entries(grouped)) {
115
+ bodyParts.push(`**${type}**`);
116
+ for (const item of items.sort((a, b) => a.slug.localeCompare(b.slug))) {
117
+ bodyParts.push(`- ~~\`${item.slug}\`~~ (${item.namespace})`);
118
+ }
119
+ }
120
+ bodyParts.push('');
121
+ }
122
+
123
+ if (newNamespaces.length > 0) {
124
+ bodyParts.push(`> **New namespace${newNamespaces.length > 1 ? 's' : ''}:** ${newNamespaces.join(', ')} — CODEOWNERS entr${newNamespaces.length > 1 ? 'ies' : 'y'} auto-added.`);
125
+ bodyParts.push('');
126
+ }
127
+
128
+ bodyParts.push(`---`, `Synced via \`aw push\` (${files.length} files: ${added.length} added/updated, ${deleted.length} removed)`);
129
+ return bodyParts.join('\n');
130
+ }
131
+
132
+ function generateCommitMsg(files) {
133
+ const added = files.filter(f => !f.deleted);
134
+ const deleted = files.filter(f => f.deleted);
135
+ const addedParts = Object.entries(groupBy(added, 'type')).map(([t, items]) => `${items.length} ${t}`);
136
+ const deletedParts = Object.entries(groupBy(deleted, 'type')).map(([t, items]) => `${items.length} ${t} removed`);
137
+ const countParts = [...addedParts, ...deletedParts];
138
+
139
+ if (files.length === 1) {
140
+ const f = files[0];
141
+ return `registry: ${f.deleted ? 'remove' : 'add'} ${f.type}/${f.slug} ${f.deleted ? 'from' : 'to'} ${f.namespace}`;
142
+ }
143
+ return `registry: sync ${files.length} files (${countParts.join(', ')})`;
144
+ }
145
+
146
+ // ── Batch file collection from folder ────────────────────────────────
147
+
148
+ function collectBatchFiles(folderAbsPath, registrySubDir) {
149
+ const relPath = folderAbsPath.startsWith(registrySubDir + '/')
150
+ ? folderAbsPath.slice(registrySubDir.length + 1)
151
+ : folderAbsPath;
17
152
 
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
- const relPath = relative(workspaceDir, folderAbsPath);
24
153
  const segments = relPath.split('/');
25
154
 
26
- // Detect if user pointed at or inside a type dir (agents/, skills/, etc.).
27
- // e.g. "revex/courses/core/evals" → typeFilter=evals, subPathFilter=null
28
- // e.g. "revex/courses/core/evals/agents" → typeFilter=evals, subPathFilter="agents"
29
- // e.g. "revex/courses/core/evals/agents/architect" → typeFilter=evals, subPathFilter="agents/architect"
30
155
  let typeFilter = null;
31
156
  let subPathFilter = null;
32
157
  let walkDir = folderAbsPath;
@@ -34,13 +159,10 @@ function collectBatchFiles(folderAbsPath, workspaceDir) {
34
159
 
35
160
  for (let i = 0; i < segments.length; i++) {
36
161
  if (PUSHABLE_TYPES.includes(segments[i])) {
37
- // Everything before this segment is the namespace
38
162
  const namespaceParts = segments.slice(0, i);
39
163
  walkBaseName = namespaceParts.join('/');
40
- // Walk from the namespace dir (parent of the type dir)
41
- walkDir = join(workspaceDir, ...namespaceParts);
164
+ walkDir = join(registrySubDir, ...namespaceParts);
42
165
  typeFilter = segments[i];
43
- // Anything after the type dir is a sub-path filter (e.g., "agents/architect")
44
166
  if (i + 1 < segments.length) {
45
167
  subPathFilter = segments.slice(i + 1).join('/');
46
168
  }
@@ -52,12 +174,10 @@ function collectBatchFiles(folderAbsPath, workspaceDir) {
52
174
  return entries
53
175
  .filter(entry => {
54
176
  if (typeFilter && entry.type !== typeFilter) return false;
55
- // For sub-path filtering (e.g., only evals under "agents" or "agents/architect")
56
177
  if (subPathFilter && !entry.slug.startsWith(subPathFilter)) return false;
57
178
  return true;
58
179
  })
59
180
  .map(entry => {
60
- // Skills and evals both have nested slug subdirs (e.g. skills/slug/file, evals/agents/developer/file)
61
181
  const registryTarget = (entry.type === 'skills' || entry.type === 'evals')
62
182
  ? `${REGISTRY_DIR}/${entry.namespacePath}/${entry.type}/${entry.slug}/${entry.skillRelPath || entry.filename}`
63
183
  : `${REGISTRY_DIR}/${entry.namespacePath}/${entry.type}/${entry.filename}`;
@@ -68,45 +188,15 @@ function collectBatchFiles(folderAbsPath, workspaceDir) {
68
188
  namespace: entry.namespacePath,
69
189
  slug: entry.slug,
70
190
  isDir: false,
191
+ deleted: false,
71
192
  };
72
193
  });
73
194
  }
74
195
 
75
- /**
76
- * Collect all modified files from manifest (for no-args push).
77
- * Returns array of { absPath, registryTarget, type, namespace, slug, isDir }.
78
- */
79
- function collectModifiedFiles(workspaceDir) {
80
- const manifest = loadManifest(workspaceDir);
81
- const files = [];
82
- for (const [key, entry] of Object.entries(manifest.files || {})) {
83
- const filePath = join(workspaceDir, key);
84
- if (!existsSync(filePath)) continue;
85
- const currentHash = hashFile(filePath);
86
- const isModified = currentHash !== entry.sha256;
87
- const isNew = !entry.registry_sha256; // Template-derived, never pushed to remote
88
- if (isModified || isNew) {
89
- const meta = parseManifestKey(key);
90
- if (meta) {
91
- files.push({
92
- absPath: filePath,
93
- registryTarget: `${REGISTRY_DIR}/${key}`,
94
- type: meta.type,
95
- namespace: meta.namespace,
96
- slug: meta.slug,
97
- isDir: false,
98
- });
99
- }
100
- }
101
- }
102
- return files;
103
- }
196
+ // ── Parse type/namespace/slug from a registry-relative path ──────────
104
197
 
105
- /**
106
- * Parse a manifest key like "commerce/agents/unit-tester.md" into { type, namespace, slug }.
107
- */
108
- function parseManifestKey(key) {
109
- const parts = key.split('/');
198
+ function parseRegistryPath(relPath) {
199
+ const parts = relPath.split('/');
110
200
  for (let i = 0; i < parts.length; i++) {
111
201
  if (PUSHABLE_TYPES.includes(parts[i]) && i + 1 < parts.length) {
112
202
  return {
@@ -119,244 +209,125 @@ function parseManifestKey(key) {
119
209
  return null;
120
210
  }
121
211
 
122
- // ── PR content generation ────────────────────────────────────────────
212
+ // ── CODEOWNERS helpers ────────────────────────────────────────────────
123
213
 
124
- function generateBranchName(files) {
125
- const shortId = Date.now().toString(36).slice(-5);
126
- const namespaces = [...new Set(files.map(f => f.namespace))];
127
-
128
- if (files.length === 1) {
129
- const f = files[0];
130
- const nsSlug = f.namespace.replace(/\//g, '-');
131
- return `upload/${nsSlug}-${f.type}-${f.slug}-${shortId}`;
132
- }
133
-
134
- if (namespaces.length === 1) {
135
- return `sync/${namespaces[0].replace(/\//g, '-')}-${shortId}`;
214
+ function getGitHubUser() {
215
+ try {
216
+ return execSync('gh api user --jq .login', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
217
+ } catch {
218
+ try {
219
+ return execSync('git config user.name', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
220
+ } catch {
221
+ return null;
222
+ }
136
223
  }
137
-
138
- return `sync/batch-${shortId}`;
139
224
  }
140
225
 
141
- function generatePrTitle(files) {
142
- if (files.length === 1) {
143
- const f = files[0];
144
- return `Add ${f.slug} (${f.type}) to ${f.namespace}`;
145
- }
146
-
147
- const counts = {};
148
- for (const f of files) {
149
- counts[f.type] = (counts[f.type] || 0) + 1;
150
- }
151
- const countParts = Object.entries(counts).map(([type, count]) => `${count} ${type}`);
152
- const namespaces = [...new Set(files.map(f => f.namespace))];
153
-
154
- if (namespaces.length === 1) {
155
- return `sync: ${countParts.join(', ')} in ${namespaces[0]}`;
156
- }
157
- return `sync: ${countParts.join(', ')} across ${namespaces.join(', ')}`;
226
+ function isNewNamespaceInCodeowners(codeownersPath, namespace) {
227
+ if (!existsSync(codeownersPath)) return true;
228
+ const content = readFileSync(codeownersPath, 'utf8');
229
+ return !content.includes(`/${REGISTRY_DIR}/${namespace}/`);
158
230
  }
159
231
 
160
- function generatePrBody(files, newNamespaces) {
161
- if (files.length === 1) {
162
- const f = files[0];
163
- const bodyParts = [
164
- '## Registry Upload',
165
- '',
166
- `- **Type:** ${f.type}`,
167
- `- **Slug:** ${f.slug}`,
168
- `- **Namespace:** ${f.namespace}`,
169
- `- **Path:** \`${f.registryTarget}\``,
170
- ];
171
- if (newNamespaces.length > 0) {
172
- bodyParts.push('', '> **New namespace** — CODEOWNERS entry auto-added.');
173
- }
174
- bodyParts.push('', 'Uploaded via `aw push`');
175
- return bodyParts.join('\n');
176
- }
232
+ // ── Create PR via gh or fallback URL ─────────────────────────────────
177
233
 
178
- // Batch body group by type
179
- const grouped = {};
180
- for (const f of files) {
181
- if (!grouped[f.type]) grouped[f.type] = [];
182
- grouped[f.type].push(f);
234
+ function createPR(awHome, branch, prTitle, prBody) {
235
+ try {
236
+ return execFileSync('gh', [
237
+ 'pr', 'create',
238
+ '--base', REGISTRY_BASE_BRANCH,
239
+ '--title', prTitle,
240
+ '--body', prBody,
241
+ ], { cwd: awHome, encoding: 'utf8' }).trim();
242
+ } catch {
243
+ return `https://github.com/${REGISTRY_REPO}/compare/${REGISTRY_BASE_BRANCH}...${branch}?expand=1`;
183
244
  }
245
+ }
184
246
 
185
- const bodyParts = ['## Registry Sync', ''];
186
- for (const [type, items] of Object.entries(grouped)) {
187
- bodyParts.push(`### ${type}`);
188
- for (const item of items.sort((a, b) => a.slug.localeCompare(b.slug))) {
189
- bodyParts.push(`- \`${item.slug}\` (${item.namespace})`);
190
- }
191
- bodyParts.push('');
192
- }
247
+ // ── Main push pipeline ────────────────────────────────────────────────
193
248
 
194
- if (newNamespaces.length > 0) {
195
- bodyParts.push(`> **New namespace${newNamespaces.length > 1 ? 's' : ''}:** ${newNamespaces.join(', ')} — CODEOWNERS entr${newNamespaces.length > 1 ? 'ies' : 'y'} auto-added.`);
196
- bodyParts.push('');
197
- }
249
+ function doPush(files, awHome, dryRun) {
250
+ const added = files.filter(f => !f.deleted);
251
+ const deleted = files.filter(f => f.deleted);
198
252
 
199
- bodyParts.push(`---`, `Synced via \`aw push\` (${files.length} files)`);
200
- return bodyParts.join('\n');
201
- }
253
+ const addedParts = Object.entries(groupBy(added, 'type')).map(([t, items]) => `${items.length} ${t}`);
254
+ const deletedParts = Object.entries(groupBy(deleted, 'type')).map(([t, items]) => `${items.length} ${t} removed`);
255
+ const countParts = [...addedParts, ...deletedParts];
202
256
 
203
- // ── Git pipeline (works for single file or batch) ────────────────────
204
-
205
- function pushFiles(files, { repo, dryRun, workspaceDir }) {
206
- // Summary
207
- const counts = {};
208
- for (const f of files) {
209
- counts[f.type] = (counts[f.type] || 0) + 1;
210
- }
211
- const countParts = Object.entries(counts).map(([type, count]) => `${count} ${type}`);
212
257
  fmt.logInfo(`${chalk.bold(files.length)} file${files.length > 1 ? 's' : ''} to push (${countParts.join(', ')})`);
213
258
 
214
259
  if (dryRun) {
215
260
  for (const f of files) {
216
261
  const ns = chalk.dim(` [${f.namespace}]`);
217
- fmt.logMessage(` ${chalk.yellow(f.type)}/${f.slug}${ns}`);
262
+ const label = f.deleted ? chalk.red('DELETE') : chalk.yellow(f.type);
263
+ fmt.logMessage(` ${label}/${f.slug}${ns}`);
218
264
  }
219
265
  fmt.logWarn('No changes made (--dry-run)');
220
266
  fmt.outro(chalk.dim('Remove --dry-run to push'));
221
267
  return;
222
268
  }
223
269
 
224
- const s = fmt.spinner();
225
- s.start('Cloning registry...');
226
-
227
- const tempDir = mkdtempSync(join(tmpdir(), 'aw-upload-'));
228
-
270
+ // Make sure we're on main before branching
229
271
  try {
230
- const repoUrl = repo.startsWith('http') ? repo : `https://github.com/${repo}.git`;
231
- execSync(`git clone --filter=blob:none --no-checkout "${repoUrl}" "${tempDir}"`, { stdio: 'pipe' });
232
- execSync(`git checkout ${REGISTRY_BASE_BRANCH}`, { cwd: tempDir, stdio: 'pipe' });
233
- s.stop('Repository cloned');
234
-
235
- const branch = generateBranchName(files);
236
- execSync(`git checkout -b ${branch}`, { cwd: tempDir, stdio: 'pipe' });
237
-
238
- const s2 = fmt.spinner();
239
- s2.start(`Copying ${files.length} file${files.length > 1 ? 's' : ''} to registry...`);
240
-
241
- // Copy each file
242
- let copyErrors = [];
243
- for (const file of files) {
244
- try {
245
- const targetFull = join(tempDir, file.registryTarget);
246
- mkdirSync(dirname(targetFull), { recursive: true });
247
- if (file.isDir) {
248
- cpSync(file.absPath, targetFull, { recursive: true });
249
- } else {
250
- cpSync(file.absPath, targetFull);
251
- }
252
- } catch (e) {
253
- copyErrors.push({ file: file.registryTarget, error: e.message });
254
- }
255
- }
256
-
257
- if (copyErrors.length > 0) {
258
- for (const err of copyErrors) {
259
- fmt.logWarn(`Failed to copy ${err.file}: ${err.error}`);
260
- }
261
- }
262
-
263
- // Check for new namespaces — auto-add CODEOWNERS entries
264
- const topNamespaces = [...new Set(files.map(f => f.namespace.split('/')[0]))];
265
- const codeownersPath = join(tempDir, 'CODEOWNERS');
266
- const newNamespaces = [];
267
- const ghUser = getGitHubUser();
268
- for (const ns of topNamespaces) {
269
- if (ghUser && isNewNamespaceInCodeowners(codeownersPath, ns)) {
270
- newNamespaces.push(ns);
271
- // Create CODEOWNERS if it doesn't exist yet
272
- const line = `/${REGISTRY_DIR}/${ns}/ @${ghUser}\n`;
273
- appendFileSync(codeownersPath, line);
274
- }
275
- }
276
- if (newNamespaces.length > 0 && existsSync(codeownersPath)) {
277
- execSync('git add CODEOWNERS', { cwd: tempDir, stdio: 'pipe' });
278
- }
279
-
280
- // Stage all registry changes
281
- execSync(`git add "${REGISTRY_DIR}"`, { cwd: tempDir, stdio: 'pipe' });
272
+ checkoutMain(awHome);
273
+ } catch (e) {
274
+ fmt.cancel(`Could not checkout main: ${e.message}`);
275
+ return;
276
+ }
282
277
 
283
- // Check if there are actual changes to commit (files may already exist in remote)
284
- const diffStatus = execSync('git diff --cached --name-only', { cwd: tempDir, encoding: 'utf8' }).trim();
285
- if (!diffStatus) {
286
- s2.stop('No changes');
287
- fmt.cancel('Nothing to push all files already exist in registry with identical content.');
278
+ const branch = generateBranchName(files);
279
+
280
+ // Handle CODEOWNERS for new namespaces
281
+ const topNamespaces = [...new Set(files.map(f => f.namespace.split('/')[0]))];
282
+ const codeownersPath = join(awHome, 'CODEOWNERS');
283
+ const newNamespaces = [];
284
+ const ghUser = getGitHubUser();
285
+ for (const ns of topNamespaces) {
286
+ if (ghUser && isNewNamespaceInCodeowners(codeownersPath, ns)) {
287
+ newNamespaces.push(ns);
288
+ const line = `/${REGISTRY_DIR}/${ns}/ @${ghUser}\n`;
289
+ appendFileSync(codeownersPath, line);
288
290
  }
291
+ }
289
292
 
290
- const prTitle = generatePrTitle(files);
291
- const commitMsg = files.length === 1
292
- ? `registry: add ${files[0].type}/${files[0].slug} to ${files[0].namespace}`
293
- : `registry: sync ${files.length} files (${countParts.join(', ')})`;
294
- execSync(`git commit -m "${commitMsg}"`, { cwd: tempDir, stdio: 'pipe' });
295
-
296
- s2.stop('Upload prepared');
297
-
298
- const s3 = fmt.spinner();
299
- s3.start('Pushing and creating PR...');
300
-
301
- execSync(`git push -u origin ${branch}`, { cwd: tempDir, stdio: 'pipe' });
302
-
303
- const prBody = generatePrBody(files, newNamespaces);
304
-
305
- // Try gh for PR creation, fall back to manual URL
306
- let prUrl;
307
- try {
308
- prUrl = execFileSync('gh', [
309
- 'pr', 'create',
310
- '--base', REGISTRY_BASE_BRANCH,
311
- '--title', prTitle,
312
- '--body', prBody,
313
- ], { cwd: tempDir, encoding: 'utf8' }).trim();
314
- } catch {
315
- const repoBase = repo.replace(/\.git$/, '');
316
- prUrl = `https://github.com/${repoBase}/compare/${REGISTRY_BASE_BRANCH}...${branch}?expand=1`;
317
- }
293
+ // Build list of repo-relative paths to stage
294
+ const pathsToStage = files.map(f => f.registryTarget);
295
+ if (newNamespaces.length > 0 && existsSync(codeownersPath)) {
296
+ pathsToStage.push('CODEOWNERS');
297
+ }
318
298
 
319
- s3.stop('Branch pushed');
299
+ const commitMsg = generateCommitMsg(files);
300
+ const prTitle = generatePrTitle(files);
301
+ const prBody = generatePrBody(files, newNamespaces);
320
302
 
321
- // Print summary
322
- if (newNamespaces.length > 0) {
323
- fmt.logInfo(`New namespace${newNamespaces.length > 1 ? 's' : ''} ${chalk.cyan(newNamespaces.join(', '))} — CODEOWNERS entr${newNamespaces.length > 1 ? 'ies' : 'y'} added`);
324
- }
325
- if (files.length > 1) {
326
- for (const [type, items] of Object.entries(groupBy(files, 'type'))) {
327
- fmt.logSuccess(`${items.length} ${type} pushed`);
328
- }
329
- }
330
- // Update manifest — mark pushed files as synced (set registry_sha256 = sha256)
331
- if (workspaceDir) {
332
- const manifest = loadManifest(workspaceDir);
333
- for (const file of files) {
334
- // Convert registryTarget back to manifest key (strip REGISTRY_DIR/ prefix)
335
- const manifestKey = file.registryTarget.replace(`${REGISTRY_DIR}/`, '');
336
- if (manifest.files[manifestKey]) {
337
- manifest.files[manifestKey].registry_sha256 = manifest.files[manifestKey].sha256;
338
- }
339
- }
340
- saveManifest(workspaceDir, manifest);
341
- }
303
+ const s = fmt.spinner();
304
+ s.start('Creating branch and pushing...');
342
305
 
343
- fmt.logSuccess(`PR: ${chalk.cyan(prUrl)}`);
344
- fmt.outro('Push complete');
306
+ let finalBranch;
307
+ try {
308
+ finalBranch = createPushBranch(awHome, branch, pathsToStage, commitMsg);
309
+ s.stop('Branch pushed');
345
310
  } catch (e) {
311
+ s.stop(chalk.red('Push failed'));
346
312
  fmt.cancel(`Push failed: ${e.message}`);
347
- } finally {
348
- execSync(`rm -rf "${tempDir}"`, { stdio: 'pipe' });
313
+ return;
349
314
  }
350
- }
351
315
 
352
- function groupBy(arr, key) {
353
- const result = {};
354
- for (const item of arr) {
355
- const k = item[key];
356
- if (!result[k]) result[k] = [];
357
- result[k].push(item);
316
+ const s2 = fmt.spinner();
317
+ s2.start('Creating PR...');
318
+ const prUrl = createPR(awHome, finalBranch, prTitle, prBody);
319
+ s2.stop('PR created');
320
+
321
+ // Go back to main
322
+ try {
323
+ checkoutMain(awHome);
324
+ } catch { /* best effort */ }
325
+
326
+ if (newNamespaces.length > 0) {
327
+ fmt.logInfo(`New namespace${newNamespaces.length > 1 ? 's' : ''} ${chalk.cyan(newNamespaces.join(', '))} — CODEOWNERS entr${newNamespaces.length > 1 ? 'ies' : 'y'} added`);
358
328
  }
359
- return result;
329
+ fmt.logSuccess(`PR: ${chalk.cyan(prUrl)}`);
330
+ fmt.outro('Push complete — branch kept locally for iteration');
360
331
  }
361
332
 
362
333
  // ── Main command ─────────────────────────────────────────────────────
@@ -364,19 +335,57 @@ function groupBy(arr, key) {
364
335
  export function pushCommand(args) {
365
336
  const input = args._positional?.[0];
366
337
  const dryRun = args['--dry-run'] === true;
367
- const repo = args['--repo'] || REGISTRY_REPO;
368
338
  const cwd = process.cwd();
339
+
340
+ const HOME = homedir();
341
+ const awHome = join(HOME, '.aw');
342
+ const registrySubDir = join(awHome, REGISTRY_DIR);
343
+ // workspaceDir for resolveInput: use local .aw_registry if present, otherwise global
369
344
  const workspaceDir = join(cwd, '.aw_registry');
370
345
 
371
346
  fmt.intro('aw push');
372
347
 
373
- // No args = push all modified + new (unpushed) files
348
+ const repoUrl = `https://github.com/${REGISTRY_REPO}.git`;
349
+ if (!isValidClone(awHome, repoUrl)) {
350
+ fmt.cancel('Registry not initialized. Run: aw init');
351
+ return;
352
+ }
353
+
354
+ // No args = detect changed files via git status
374
355
  if (!input) {
375
- const files = collectModifiedFiles(workspaceDir);
356
+ const changes = detectChanges(awHome, REGISTRY_DIR);
357
+ const allEntries = [
358
+ ...changes.modified.map(e => ({ ...e, deleted: false })),
359
+ ...changes.untracked.map(e => ({ ...e, deleted: false })),
360
+ ...changes.deleted.map(e => ({ ...e, deleted: true })),
361
+ ];
362
+
363
+ if (allEntries.length === 0) {
364
+ fmt.cancel('Nothing to push — no modified, new, or deleted files.\n\n Use `aw status` to see workspace state.');
365
+ return;
366
+ }
367
+
368
+ const files = allEntries
369
+ .map(f => {
370
+ const meta = parseRegistryPath(f.registryPath);
371
+ if (!meta) return null;
372
+ return {
373
+ absPath: join(awHome, f.path),
374
+ registryTarget: f.path,
375
+ type: meta.type,
376
+ namespace: meta.namespace,
377
+ slug: meta.slug,
378
+ deleted: f.deleted,
379
+ };
380
+ })
381
+ .filter(Boolean);
382
+
376
383
  if (files.length === 0) {
377
- fmt.cancel('Nothing to pushno modified or new files.\n\n Use `aw status` to see synced files.');
384
+ fmt.cancel('Nothing pushable foundchanges must be under agents/, skills/, commands/, or evals/.');
385
+ return;
378
386
  }
379
- pushFiles(files, { repo, dryRun, workspaceDir });
387
+
388
+ doPush(files, awHome, dryRun);
380
389
  return;
381
390
  }
382
391
 
@@ -388,6 +397,7 @@ export function pushCommand(args) {
388
397
  ? `\n\n Tip: Use the .aw_registry/ path instead:\n aw push .aw_registry/${input.split('/').slice(1).join('/')}`
389
398
  : '';
390
399
  fmt.cancel(`Could not resolve "${input}" to a registry path.${hint}\n\n Only files inside .aw_registry/ can be pushed.`);
400
+ return;
391
401
  }
392
402
 
393
403
  let absPath = resolved.localAbsPath;
@@ -396,20 +406,26 @@ export function pushCommand(args) {
396
406
  absPath = absPath + '.md';
397
407
  } else {
398
408
  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.`);
409
+ return;
399
410
  }
400
411
  }
401
412
 
402
- // Folder/namespace input → batch push
413
+ // Folder/namespace input → batch push using registry content in awHome
403
414
  if (statSync(absPath).isDirectory()) {
404
- const files = collectBatchFiles(absPath, workspaceDir);
415
+ const relFromRegistry = absPath.startsWith(workspaceDir + '/')
416
+ ? absPath.slice(workspaceDir.length + 1)
417
+ : resolved.registryPath;
418
+ const registryAbsPath = join(registrySubDir, relFromRegistry);
419
+ const files = collectBatchFiles(registryAbsPath, registrySubDir);
405
420
  if (files.length === 0) {
406
421
  fmt.cancel(`Nothing to push in ${chalk.cyan(input)} — no agents, skills, commands, or evals found.`);
422
+ return;
407
423
  }
408
- pushFiles(files, { repo, dryRun, workspaceDir });
424
+ doPush(files, awHome, dryRun);
409
425
  return;
410
426
  }
411
427
 
412
- // Single file input → wrap in array and push
428
+ // Single file input
413
429
  const regParts = resolved.registryPath.split('/');
414
430
  let typeIdx = -1;
415
431
  for (let i = regParts.length - 1; i >= 0; i--) {
@@ -427,6 +443,7 @@ export function pushCommand(args) {
427
443
  ` aw push .aw_registry/platform/services/skills/development`,
428
444
  ` aw push .aw_registry/commerce/shared/commands/ship.md`,
429
445
  ].join('\n'));
446
+ return;
430
447
  }
431
448
 
432
449
  const namespaceParts = regParts.slice(0, typeIdx);
@@ -438,32 +455,25 @@ export function pushCommand(args) {
438
455
  ? `${REGISTRY_DIR}/${namespacePath}/${parentDir}/${slug}`
439
456
  : `${REGISTRY_DIR}/${namespacePath}/${parentDir}/${slug}.md`;
440
457
 
441
- pushFiles([{
458
+ doPush([{
442
459
  absPath,
443
460
  registryTarget,
444
461
  type: parentDir,
445
462
  namespace: namespacePath,
446
463
  slug,
447
464
  isDir,
448
- }], { repo, dryRun, workspaceDir });
465
+ deleted: false,
466
+ }], awHome, dryRun);
449
467
  }
450
468
 
451
- // ── Helpers ──────────────────────────────────────────────────────────
469
+ // ── Utilities ─────────────────────────────────────────────────────────
452
470
 
453
- function getGitHubUser() {
454
- try {
455
- return execSync('gh api user --jq .login', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
456
- } catch {
457
- try {
458
- return execSync('git config user.name', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
459
- } catch {
460
- return null;
461
- }
471
+ function groupBy(arr, key) {
472
+ const result = {};
473
+ for (const item of arr) {
474
+ const k = item[key];
475
+ if (!result[k]) result[k] = [];
476
+ result[k].push(item);
462
477
  }
463
- }
464
-
465
- function isNewNamespaceInCodeowners(codeownersPath, namespace) {
466
- if (!existsSync(codeownersPath)) return true;
467
- const content = readFileSync(codeownersPath, 'utf8');
468
- return !content.includes(`/${REGISTRY_DIR}/${namespace}/`);
478
+ return result;
469
479
  }