@ghl-ai/aw 0.1.37-beta.9 → 0.1.37

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
@@ -15,6 +15,7 @@ const COMMANDS = {
15
15
  init: () => import('./commands/init.mjs').then(m => m.initCommand),
16
16
  pull: () => import('./commands/pull.mjs').then(m => m.pullCommand),
17
17
  push: () => import('./commands/push.mjs').then(m => m.pushCommand),
18
+ 'push-rules': () => import('./commands/push-rules.mjs').then(m => m.pushRulesCommand),
18
19
  drop: () => import('./commands/drop.mjs').then(m => m.dropCommand),
19
20
  status: () => import('./commands/status.mjs').then(m => m.statusCommand),
20
21
  search: () => import('./commands/search.mjs').then(m => m.searchCommand),
@@ -88,6 +89,7 @@ function printHelp() {
88
89
  sec('Upload'),
89
90
  cmd('aw push', 'Push all modified files (creates one PR)'),
90
91
  cmd('aw push <path>', 'Push file, folder, or namespace to registry'),
92
+ cmd('aw push-rules [path]', 'Push platform rules to platform-docs'),
91
93
  cmd('aw push --dry-run [path]', 'Preview what would be pushed'),
92
94
 
93
95
  sec('Discover'),
@@ -102,6 +104,8 @@ function printHelp() {
102
104
  cmd('aw daemon install --interval 30m', 'Set custom interval (e.g. 30m, 2h, 3600)'),
103
105
  cmd('aw daemon uninstall', 'Stop the background daemon'),
104
106
  cmd('aw daemon status', 'Check if daemon is running'),
107
+ cmd('aw slack-sim run <scenario>', 'Replay Slack-like scenarios against real runtime'),
108
+ cmd('aw slack-sim list-scenarios', 'List built-in Slack simulator scenarios'),
105
109
 
106
110
  sec('Settings'),
107
111
  cmd('aw telemetry status', 'Show telemetry status'),
@@ -122,6 +126,8 @@ function printHelp() {
122
126
  cmd('aw push .aw_registry/<team>/', 'Push entire namespace (one PR)'),
123
127
  cmd('aw push .aw_registry/agents/<name>.md', 'Push a single agent'),
124
128
  cmd('aw push .aw_registry/skills/<name>/', 'Push a single skill folder'),
129
+ cmd('aw push .aw_rules', 'Auto-redirects to aw push-rules'),
130
+ cmd('aw push-rules', 'Pushes .aw_rules or .aw_registry/.aw_rules'),
125
131
  '',
126
132
  ` ${chalk.dim('# Remove content from workspace')}`,
127
133
  cmd('aw drop <team>', 'Stop syncing a namespace (removes all files)'),
package/commands/init.mjs CHANGED
@@ -4,7 +4,17 @@
4
4
  // Uses core.hooksPath (git-lfs pattern) for system-wide hook interception.
5
5
  // Uses IDE tasks for auto-pull on workspace open.
6
6
 
7
- import { existsSync, writeFileSync, symlinkSync, lstatSync, readdirSync, readFileSync, rmSync, realpathSync, appendFileSync } from 'node:fs';
7
+ import {
8
+ existsSync,
9
+ writeFileSync,
10
+ symlinkSync,
11
+ lstatSync,
12
+ readdirSync,
13
+ readFileSync,
14
+ rmSync,
15
+ realpathSync,
16
+ appendFileSync,
17
+ } from 'node:fs';
8
18
  import { execSync } from 'node:child_process';
9
19
  import { join, dirname, sep } from 'node:path';
10
20
  import { homedir } from 'node:os';
@@ -15,6 +25,7 @@ import { chalk } from '../fmt.mjs';
15
25
  import { linkWorkspace } from '../link.mjs';
16
26
  import { generateCommands, copyInstructions, initAwDocs } from '../integrate.mjs';
17
27
  import { setupMcp } from '../mcp.mjs';
28
+ import { installLocalCommitHook } from '../hooks.mjs';
18
29
  import { autoUpdate, promptUpdate } from '../update.mjs';
19
30
  import { installGlobalHooks } from '../hooks.mjs';
20
31
  import { installAwEcc } from '../ecc.mjs';
@@ -31,7 +42,8 @@ import {
31
42
  syncWorktreeSparseCheckout,
32
43
  findNearestWorktree,
33
44
  } from '../git.mjs';
34
- import { REGISTRY_DIR, REGISTRY_REPO, REGISTRY_URL } from '../constants.mjs';
45
+ import { REGISTRY_DIR, REGISTRY_REPO, REGISTRY_URL, RULES_SOURCE_DIR } from '../constants.mjs';
46
+ import { syncFileTree } from '../file-tree.mjs';
35
47
 
36
48
  const __dirname = dirname(fileURLToPath(import.meta.url));
37
49
  const VERSION = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')).version;
@@ -195,7 +207,7 @@ export async function initCommand(args) {
195
207
  }
196
208
 
197
209
  if (choice === 'platform-only') {
198
- namespace = null; team = null; subTeam = null; folderName = null;
210
+ namespace = 'platform'; team = 'platform'; subTeam = null; folderName = null;
199
211
  }
200
212
  }
201
213
 
@@ -210,7 +222,7 @@ export async function initCommand(args) {
210
222
  const isNewSubTeam = folderName && cfg && !cfg.include.includes(folderName);
211
223
  if (isNewSubTeam) {
212
224
  if (!silent) fmt.logStep(`Adding sub-team ${chalk.cyan(folderName)}...`);
213
- const newSparsePaths = [`.aw_registry/${folderName}`, `content`];
225
+ const newSparsePaths = [`.aw_registry/${folderName}`, 'content', RULES_SOURCE_DIR];
214
226
  addToSparseCheckout(AW_HOME, newSparsePaths);
215
227
  config.addPattern(GLOBAL_AW_DIR, folderName);
216
228
  } else {
@@ -235,6 +247,9 @@ export async function initCommand(args) {
235
247
 
236
248
  ensureAwGitignore(AW_HOME);
237
249
  const freshCfg = config.load(GLOBAL_AW_DIR);
250
+ if (existsSync(GLOBAL_AW_DIR)) {
251
+ syncFileTree(join(AW_HOME, RULES_SOURCE_DIR), join(GLOBAL_AW_DIR, RULES_SOURCE_DIR));
252
+ }
238
253
 
239
254
  // Ensure project worktree sparse checkout matches the global clone.
240
255
  // Covers the case where a namespace was added from HOME (or another project)
@@ -279,6 +294,7 @@ export async function initCommand(args) {
279
294
  const awDirForLinks = (projectRegistryDir && existsSync(projectRegistryDir)) ? projectRegistryDir : null;
280
295
  const symlinks = linkWorkspace(HOME, awDirForLinks, { silent: true });
281
296
  const commands = generateCommands(HOME, { silent: true });
297
+ if (cwd !== HOME) installLocalCommitHook(cwd);
282
298
 
283
299
  if (silent) {
284
300
  autoUpdate(await args._updateCheck);
@@ -317,7 +333,7 @@ export async function initCommand(args) {
317
333
  }
318
334
 
319
335
  // Determine sparse paths
320
- const sparsePaths = [`.aw_registry/platform`, `content`, `.aw_registry/AW-PROTOCOL.md`, `CODEOWNERS`];
336
+ const sparsePaths = [`.aw_registry/platform`, `content`, RULES_SOURCE_DIR, `.aw_registry/AW-PROTOCOL.md`, `CODEOWNERS`];
321
337
  if (folderName) {
322
338
  sparsePaths.push(`.aw_registry/${folderName}`);
323
339
  }
@@ -363,11 +379,14 @@ export async function initCommand(args) {
363
379
  }
364
380
  }
365
381
 
366
- // Create sync config
367
- const cfg = config.create(GLOBAL_AW_DIR, { namespace: team, user });
382
+ // Create sync config — default to 'platform' when no namespace specified
383
+ const cfg = config.create(GLOBAL_AW_DIR, { namespace: team || 'platform', user });
368
384
  if (folderName) {
369
385
  config.addPattern(GLOBAL_AW_DIR, folderName);
370
386
  }
387
+ if (existsSync(GLOBAL_AW_DIR)) {
388
+ syncFileTree(join(AW_HOME, RULES_SOURCE_DIR), join(GLOBAL_AW_DIR, RULES_SOURCE_DIR));
389
+ }
371
390
 
372
391
  // Step 3: Setup tasks, MCP, hooks
373
392
  await installAwEcc(cwd, { silent });
@@ -406,6 +425,7 @@ export async function initCommand(args) {
406
425
  const projectRegistryDir = cwd !== HOME ? join(cwd, '.aw', REGISTRY_DIR) : null;
407
426
  const awDirForLinks = (projectRegistryDir && existsSync(projectRegistryDir)) ? projectRegistryDir : null;
408
427
  const symlinks = linkWorkspace(HOME, awDirForLinks, { silent: true });
428
+ if (cwd !== HOME) installLocalCommitHook(cwd);
409
429
  ideSpinner.message('Generating commands...');
410
430
  const commands = generateCommands(HOME, { silent: true });
411
431
  ideSpinner.stop(`IDE wired — ${chalk.bold(symlinks)} symlinks · ${chalk.bold(commands)} commands`);
@@ -9,6 +9,7 @@ import { addProjectWorktree, isWorktree, isValidClone } from '../git.mjs';
9
9
  import { REGISTRY_DIR, REGISTRY_URL } from '../constants.mjs';
10
10
  import { linkWorkspace } from '../link.mjs';
11
11
  import { generateCommands } from '../integrate.mjs';
12
+ import { installLocalCommitHook } from '../hooks.mjs';
12
13
 
13
14
  const HOME = homedir();
14
15
  const AW_HOME = join(HOME, '.aw');
@@ -40,6 +41,7 @@ export function linkProjectCommand(args) {
40
41
  const awDirForLinks = existsSync(projectRegistryDir) ? projectRegistryDir : null;
41
42
  const symlinks = linkWorkspace(HOME, awDirForLinks, { silent: true });
42
43
  const commands = generateCommands(HOME, { silent: true });
44
+ installLocalCommitHook(cwd);
43
45
  fmt.logSuccess(`Already linked — refreshed ${chalk.bold(symlinks)} IDE symlinks · ${chalk.bold(commands)} commands`);
44
46
  return;
45
47
  }
@@ -50,6 +52,7 @@ export function linkProjectCommand(args) {
50
52
  const awDirForLinks = existsSync(projectRegistryDir) ? projectRegistryDir : null;
51
53
  const symlinks = linkWorkspace(HOME, awDirForLinks, { silent: true });
52
54
  const commands = generateCommands(HOME, { silent: true });
55
+ installLocalCommitHook(cwd);
53
56
  fmt.logSuccess([
54
57
  `Project linked — ${chalk.bold(symlinks)} IDE symlinks · ${chalk.bold(commands)} commands`,
55
58
  '',
package/commands/nuke.mjs CHANGED
@@ -143,19 +143,24 @@ async function removeProjectSymlinks() {
143
143
  }
144
144
 
145
145
  // Also remove legacy local .git/hooks/post-checkout installed by old aw versions
146
+ // and prepare-commit-msg hooks installed by installLocalCommitHook
146
147
  let hooksRemoved = 0;
147
- const { stdout: hookFiles } = await exec(
148
- `find "${HOME}" -maxdepth 5 -path "*/.git/hooks/post-checkout" -type f 2>/dev/null || true`,
149
- { encoding: 'utf8', timeout: 30000 }
150
- );
151
- for (const hookPath of hookFiles.trim().split('\n').filter(Boolean)) {
152
- try {
153
- const content = readFileSync(hookPath, 'utf8');
154
- if (content.includes('aw: auto-link registry') || content.includes('ln -s "$AW_REGISTRY"')) {
155
- unlinkSync(hookPath);
156
- hooksRemoved++;
157
- }
158
- } catch { /* best effort */ }
148
+ const hookNames = ['post-checkout', 'prepare-commit-msg'];
149
+ for (const hookName of hookNames) {
150
+ const { stdout: hookFiles } = await exec(
151
+ `find "${HOME}" -maxdepth 5 -path "*/.git/hooks/${hookName}" -type f 2>/dev/null || true`,
152
+ { encoding: 'utf8', timeout: 30000 }
153
+ );
154
+ for (const hookPath of hookFiles.trim().split('\n').filter(Boolean)) {
155
+ try {
156
+ const content = readFileSync(hookPath, 'utf8');
157
+ // Only remove hooks that AW installed — identified by our marker comment
158
+ if (content.includes('aw:') || content.includes('aw: auto-link registry') || content.includes('ln -s "$AW_REGISTRY"')) {
159
+ unlinkSync(hookPath);
160
+ hooksRemoved++;
161
+ }
162
+ } catch { /* best effort */ }
163
+ }
159
164
  }
160
165
 
161
166
  return { removed, hooksRemoved };
package/commands/pull.mjs CHANGED
@@ -1,17 +1,34 @@
1
1
  // commands/pull.mjs — Pull content from registry using persistent git clone
2
2
 
3
- import { existsSync, lstatSync } from 'node:fs';
3
+ import {
4
+ existsSync,
5
+ lstatSync,
6
+ } from 'node:fs';
4
7
  import { join, extname } from 'node:path';
5
8
  import { homedir } from 'node:os';
6
- import { exec as execCb } from 'node:child_process';
9
+ import { exec as execCb, execSync } from 'node:child_process';
7
10
  import { promisify } from 'node:util';
8
11
 
9
12
  const exec = promisify(execCb);
10
13
  import * as config from '../config.mjs';
11
14
  import * as fmt from '../fmt.mjs';
12
15
  import { chalk } from '../fmt.mjs';
13
- import { fetchAndMerge, addToSparseCheckout, removeFromSparseCheckout, syncWorktreeSparseCheckout, isValidClone, findNearestWorktree, rebaseOntoOriginMain } from '../git.mjs';
14
- import { REGISTRY_DIR, REGISTRY_REPO, REGISTRY_URL } from '../constants.mjs';
16
+ import {
17
+ fetchAndMerge,
18
+ addToSparseCheckout,
19
+ removeFromSparseCheckout,
20
+ syncWorktreeSparseCheckout,
21
+ isValidClone,
22
+ findNearestWorktree,
23
+ rebaseOntoOriginMain,
24
+ } from '../git.mjs';
25
+ import {
26
+ REGISTRY_DIR,
27
+ REGISTRY_URL,
28
+ DOCS_SOURCE_DIR,
29
+ RULES_SOURCE_DIR,
30
+ } from '../constants.mjs';
31
+ import { collectAllPaths, syncFileTree } from '../file-tree.mjs';
15
32
  import { linkWorkspace } from '../link.mjs';
16
33
  import { generateCommands, copyInstructions } from '../integrate.mjs';
17
34
 
@@ -52,6 +69,15 @@ export async function pullCommand(args) {
52
69
  return;
53
70
  }
54
71
 
72
+ // Ensure platform pulls also fetch docs and rules on older installs that
73
+ // pre-date the new sparse-checkout paths.
74
+ if (input === 'platform') {
75
+ addToSparseCheckout(AW_HOME, [`.aw_registry/platform`, DOCS_SOURCE_DIR, RULES_SOURCE_DIR]);
76
+ if (!cfg.include.includes('platform')) {
77
+ config.addPattern(GLOBAL_AW_DIR, 'platform');
78
+ }
79
+ }
80
+
55
81
  // If input is a new namespace to add
56
82
  let addedInput = null;
57
83
  let addedSparsePath = null;
@@ -64,7 +90,7 @@ export async function pullCommand(args) {
64
90
  const label = input.split('/').pop();
65
91
  if (!cfg.include.includes(input)) {
66
92
  log.logStep(`Adding ${chalk.cyan(label)} to sparse checkout...`);
67
- addToSparseCheckout(AW_HOME, [sparsePath, 'content']);
93
+ addToSparseCheckout(AW_HOME, [sparsePath, DOCS_SOURCE_DIR]);
68
94
  config.addPattern(GLOBAL_AW_DIR, input);
69
95
  addedInput = input;
70
96
  addedSparsePath = sparsePath;
@@ -79,33 +105,33 @@ export async function pullCommand(args) {
79
105
  const rebaseInProgress = existsSync(join(awGitDir, 'rebase-merge')) || existsSync(join(awGitDir, 'rebase-apply'));
80
106
  const mergeInProgress = existsSync(join(awGitDir, 'MERGE_HEAD'));
81
107
  if (rebaseInProgress || mergeInProgress) {
82
- // Check for still-unresolved files (conflict markers present in index)
83
108
  let unresolved = [];
84
109
  try {
85
110
  const { stdout } = await exec(`git -C "${AW_HOME}" diff --name-only --diff-filter=U`);
86
111
  unresolved = stdout.trim().split('\n').filter(Boolean);
87
- } catch { /* best effort */ }
112
+ } catch {
113
+ // best effort
114
+ }
88
115
 
89
116
  if (unresolved.length > 0) {
90
- // Still has conflicts user needs to finish resolving
91
- log.logWarn(`Rebase paused — resolve conflicts in your IDE, then run \`aw pull\` again.`);
117
+ log.logWarn('Rebase paused resolve conflicts in your IDE, then run `aw pull` again.');
92
118
  if (!silent) fmt.outro(chalk.yellow('Pull skipped'));
93
119
  return;
94
120
  }
95
121
 
96
- // All conflicts resolved (files are staged) — continue the rebase automatically
97
122
  try {
98
123
  await exec(`git -C "${AW_HOME}" rebase --continue`, { env: { ...process.env, GIT_EDITOR: 'true' } });
99
124
  log.logStep('Rebase continued after conflict resolution.');
100
- // Force-push if on a push branch so origin stays in sync
101
125
  const { stdout: branchOut } = await exec(`git -C "${AW_HOME}" rev-parse --abbrev-ref HEAD`);
102
126
  const resumedBranch = branchOut.trim();
103
- if (['upload/', 'remove/', 'sync/'].some(p => resumedBranch.startsWith(p))) {
104
- try { await exec(`git -C "${AW_HOME}" push --force-with-lease origin "${resumedBranch}"`); } catch { /* non-blocking */ }
127
+ if (['upload/', 'remove/', 'sync/'].some(prefix => resumedBranch.startsWith(prefix))) {
128
+ try {
129
+ await exec(`git -C "${AW_HOME}" push --force-with-lease origin "${resumedBranch}"`);
130
+ } catch {
131
+ // non-blocking
132
+ }
105
133
  }
106
134
  } catch {
107
- // Could happen if there are more conflicting commits in the rebase sequence,
108
- // or if the resolved changes result in an empty commit (skip it).
109
135
  try {
110
136
  await exec(`git -C "${AW_HOME}" rebase --skip`);
111
137
  log.logStep('Empty commit skipped during rebase continuation.');
@@ -115,10 +141,8 @@ export async function pullCommand(args) {
115
141
  return;
116
142
  }
117
143
  }
118
- // Fall through to re-link IDE dirs after successful rebase continuation
119
144
  }
120
145
 
121
- // Fetch + merge latest
122
146
  const s = log.spinner();
123
147
  s.start('Fetching latest from registry...');
124
148
  let fetchResult = { updated: false, conflicts: [] };
@@ -130,16 +154,15 @@ export async function pullCommand(args) {
130
154
  if (!silent) log.logWarn(`Fetch error: ${e.message}`);
131
155
  }
132
156
 
133
- // Validate that the requested path actually exists in the registry after fetch.
134
- // If not, undo the sparse checkout addition and inform the user.
135
157
  if (addedInput) {
136
158
  const localPath = join(AW_HOME, REGISTRY_DIR, addedInput);
137
159
  if (!existsSync(localPath)) {
138
- // Undo: remove from sparse checkout and config
139
160
  try {
140
161
  removeFromSparseCheckout(AW_HOME, [addedSparsePath]);
141
162
  config.removePattern(GLOBAL_AW_DIR, addedInput);
142
- } catch { /* best effort */ }
163
+ } catch {
164
+ // best effort
165
+ }
143
166
  log.cancel(`Path ${chalk.red(addedInput)} not found in the registry.\nCheck the exact path with ${chalk.bold('aw search <query>')} or browse ${chalk.dim('~/.aw_registry/')}.`);
144
167
  return;
145
168
  }
@@ -147,23 +170,31 @@ export async function pullCommand(args) {
147
170
 
148
171
  if (fetchResult.conflicts.length > 0) {
149
172
  if (!silent) {
150
- // Interactive mode: rebase is paused with conflict markers in the working tree.
151
- // Leave it for the user to resolve in their IDE, then re-run `aw pull`.
152
173
  log.logWarn(`Merge conflict in: ${fetchResult.conflicts.join(', ')}`);
153
174
  log.logWarn('Merge aborted — your branch is unchanged. Resolve conflicts and run `aw pull` again.');
154
175
  return;
155
176
  }
156
- // Silent mode: rebase was already aborted in fetchAndMerge; just report.
157
177
  log.logWarn(`Conflicts in: ${fetchResult.conflicts.join(', ')}`);
158
178
  }
159
179
 
180
+ const rulesSrc = join(AW_HOME, RULES_SOURCE_DIR);
181
+ if (existsSync(rulesSrc)) {
182
+ const rulesDest = join(GLOBAL_AW_DIR, RULES_SOURCE_DIR);
183
+ syncFileTree(rulesSrc, rulesDest);
184
+ if (!silent) log.logSuccess('Synced .aw_rules');
185
+ }
186
+
160
187
  // Rebase project worktree branch onto origin/main — only for legacy git worktrees.
161
188
  // In the symlink model, <project>/.aw IS ~/.aw (same repo), so fetchAndMerge already
162
189
  // brought it up to date. Nothing to rebase.
163
190
  const localAw = cwd !== HOME ? findNearestWorktree(cwd, HOME) : null;
164
191
  let isSymlinkWorktree = false;
165
192
  if (localAw) {
166
- try { isSymlinkWorktree = lstatSync(localAw).isSymbolicLink(); } catch {}
193
+ try {
194
+ isSymlinkWorktree = lstatSync(localAw).isSymbolicLink();
195
+ } catch {
196
+ // ignore
197
+ }
167
198
  }
168
199
  if (localAw && !isSymlinkWorktree) {
169
200
  const rebaseSpinner = log.spinner();
@@ -173,7 +204,9 @@ export async function pullCommand(args) {
173
204
  rebaseSpinner.stop('Local branch up to date');
174
205
  } catch (e) {
175
206
  const isConflict = e.message?.includes('could not apply') || e.message?.includes('CONFLICT');
176
- const isAlreadyInProgress = e.message?.includes('rebase-merge') || e.message?.includes('rebase-apply') || e.message?.includes('already in progress');
207
+ const isAlreadyInProgress = e.message?.includes('rebase-merge')
208
+ || e.message?.includes('rebase-apply')
209
+ || e.message?.includes('already in progress');
177
210
 
178
211
  if (isAlreadyInProgress) {
179
212
  rebaseSpinner.stop(chalk.yellow('Rebase paused — conflicts pending'));
@@ -184,26 +217,31 @@ export async function pullCommand(args) {
184
217
  try {
185
218
  const { stdout } = await exec(`git -C "${localAw}" diff --name-only --diff-filter=U`);
186
219
  conflictedFiles = stdout.trim().split('\n').filter(Boolean);
187
- } catch { /* best effort */ }
220
+ } catch {
221
+ // best effort
222
+ }
188
223
 
189
224
  if (!silent) {
190
- try { await exec(`git -C "${localAw}" rebase --abort`); } catch { /* best effort */ }
225
+ try {
226
+ await exec(`git -C "${localAw}" rebase --abort`);
227
+ } catch {
228
+ // best effort
229
+ }
191
230
  log.logWarn('Rebase aborted — your branch is unchanged.');
192
231
  if (conflictedFiles.length > 0) {
193
232
  log.logWarn(`Conflicting file${conflictedFiles.length > 1 ? 's' : ''}:`);
194
- for (const f of conflictedFiles) log.logMessage(` ${chalk.red('✗')} ${f}`);
233
+ for (const file of conflictedFiles) log.logMessage(` ${chalk.red('✗')} ${file}`);
195
234
  }
196
235
  log.logWarn('Resolve the conflict and run `aw pull` again, or check with `aw status`.');
197
236
  }
198
237
  } else {
199
- const msg = e.message?.split('\n').find(l => l.trim()) ?? e.message;
238
+ const msg = e.message?.split('\n').find(line => line.trim()) ?? e.message;
200
239
  rebaseSpinner.stop(chalk.dim('Rebase skipped'));
201
240
  if (!silent) log.logWarn(`Rebase skipped: ${msg}`);
202
241
  }
203
242
  }
204
243
  }
205
244
 
206
- // Re-link IDE dirs
207
245
  if (!args._skipIntegrate) {
208
246
  const projectRegistryDir = cwd !== HOME ? join(cwd, '.aw', REGISTRY_DIR) : null;
209
247
  const awDirForLinks = (projectRegistryDir && existsSync(projectRegistryDir)) ? projectRegistryDir : null;
@@ -237,6 +275,10 @@ export async function pullAsync(args) {
237
275
  return { pattern: args._positional?.[0] || '', actions: [], conflictCount: 0 };
238
276
  }
239
277
 
278
+ export const __test__ = {
279
+ collectAllPaths,
280
+ syncFileTree,
281
+ };
240
282
 
241
283
  function registerMcp(namespace) {
242
284
  const mcpUrl = process.env.GHL_MCP_URL;
@@ -0,0 +1,212 @@
1
+ import { existsSync, mkdtempSync, statSync } from 'node:fs';
2
+ import { execFileSync, execSync } from 'node:child_process';
3
+ import { join, normalize, relative } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+
6
+ import * as fmt from '../fmt.mjs';
7
+ import { chalk } from '../fmt.mjs';
8
+ import { REGISTRY_BASE_BRANCH, REGISTRY_REPO, RULES_SOURCE_DIR, AW_CO_AUTHOR } from '../constants.mjs';
9
+ import { syncFileTree } from '../file-tree.mjs';
10
+
11
+ function normalizeSlashes(path) {
12
+ return path.replace(/\\/g, '/');
13
+ }
14
+
15
+ export function isRulesPushInput(input) {
16
+ if (!input) return false;
17
+ const normalized = normalizeSlashes(input).replace(/\/+$/, '');
18
+ return normalized === RULES_SOURCE_DIR
19
+ || normalized.startsWith(`${RULES_SOURCE_DIR}/`)
20
+ || normalized === `.aw_registry/${RULES_SOURCE_DIR}`
21
+ || normalized.startsWith(`.aw_registry/${RULES_SOURCE_DIR}/`);
22
+ }
23
+
24
+ export function resolveRulesPushSource(input, cwd = process.cwd()) {
25
+ const localRulesRoot = join(cwd, RULES_SOURCE_DIR);
26
+ const syncedRulesRoot = join(cwd, '.aw_registry', RULES_SOURCE_DIR);
27
+
28
+ if (!input) {
29
+ if (existsSync(localRulesRoot)) return { sourceRoot: localRulesRoot, sourceType: 'local' };
30
+ if (existsSync(syncedRulesRoot)) return { sourceRoot: syncedRulesRoot, sourceType: 'synced' };
31
+ return null;
32
+ }
33
+
34
+ const normalizedInput = normalizeSlashes(input).replace(/\/+$/, '');
35
+
36
+ if (normalizedInput === RULES_SOURCE_DIR || normalizedInput.startsWith(`${RULES_SOURCE_DIR}/`)) {
37
+ if (!existsSync(localRulesRoot)) return null;
38
+ return { sourceRoot: localRulesRoot, sourceType: 'local' };
39
+ }
40
+
41
+ if (normalizedInput === `.aw_registry/${RULES_SOURCE_DIR}` || normalizedInput.startsWith(`.aw_registry/${RULES_SOURCE_DIR}/`)) {
42
+ if (!existsSync(syncedRulesRoot)) return null;
43
+
44
+ const relativeRulesPath = normalizedInput.slice(`.aw_registry/${RULES_SOURCE_DIR}`.length).replace(/^\/+/, '');
45
+ const localOverridePath = relativeRulesPath ? join(localRulesRoot, relativeRulesPath) : localRulesRoot;
46
+ if (existsSync(localOverridePath)) {
47
+ return { sourceRoot: localRulesRoot, sourceType: 'local' };
48
+ }
49
+
50
+ return { sourceRoot: syncedRulesRoot, sourceType: 'synced' };
51
+ }
52
+
53
+ return null;
54
+ }
55
+
56
+ export function hasRulesChanges(cwd = process.cwd()) {
57
+ const candidateDirs = [RULES_SOURCE_DIR, `.aw_registry/${RULES_SOURCE_DIR}`]
58
+ .filter(rel => existsSync(join(cwd, rel)));
59
+
60
+ if (candidateDirs.length === 0) return false;
61
+
62
+ try {
63
+ const output = execSync(
64
+ `git status --short --untracked-files=all -- ${candidateDirs.map(dir => `"${dir}"`).join(' ')}`,
65
+ { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
66
+ ).trim();
67
+ return output.length > 0;
68
+ } catch {
69
+ return false;
70
+ }
71
+ }
72
+
73
+ export function buildRulesPushFile(input, cwd = process.cwd()) {
74
+ const resolved = resolveRulesPushSource(input, cwd);
75
+ if (!resolved) return null;
76
+
77
+ return {
78
+ absPath: resolved.sourceRoot,
79
+ registryTarget: RULES_SOURCE_DIR,
80
+ type: 'rules',
81
+ namespace: 'platform',
82
+ slug: 'rules',
83
+ isDir: true,
84
+ };
85
+ }
86
+
87
+ function buildRulesPrTitle(sourceRoot, cwd) {
88
+ const rel = normalizeSlashes(relative(cwd, sourceRoot));
89
+ return rel === RULES_SOURCE_DIR
90
+ ? 'sync: update platform rules'
91
+ : `sync: update platform rules from ${rel}`;
92
+ }
93
+
94
+ function buildRulesPrBody(sourceRoot, sourceType, cwd) {
95
+ const rel = normalizeSlashes(relative(cwd, sourceRoot)) || RULES_SOURCE_DIR;
96
+ return [
97
+ '## Platform Rules Sync',
98
+ '',
99
+ `- **Source:** \`${rel}\``,
100
+ `- **Mode:** ${sourceType === 'local' ? 'canonical local rules' : 'synced rules copy'}`,
101
+ '',
102
+ 'Uploaded via `aw push-rules`',
103
+ ].join('\n');
104
+ }
105
+
106
+ function pushRulesTree(sourceRoot, { repo, dryRun, cwd }) {
107
+ if (!existsSync(sourceRoot) || !statSync(sourceRoot).isDirectory()) {
108
+ fmt.cancel(`Rules path not found: ${sourceRoot}`);
109
+ }
110
+
111
+ if (dryRun) {
112
+ fmt.logInfo(`Would push rules from ${chalk.cyan(normalizeSlashes(relative(cwd, sourceRoot)) || RULES_SOURCE_DIR)}`);
113
+ fmt.logWarn('No changes made (--dry-run)');
114
+ fmt.outro(chalk.dim('Remove --dry-run to push'));
115
+ return;
116
+ }
117
+
118
+ const s = fmt.spinner();
119
+ s.start('Cloning registry...');
120
+
121
+ const tempDir = mkdtempSync(join(tmpdir(), 'aw-push-rules-'));
122
+
123
+ try {
124
+ const repoUrl = repo.startsWith('http') ? repo : `https://github.com/${repo}.git`;
125
+ execSync(`git clone --filter=blob:none --no-checkout "${repoUrl}" "${tempDir}"`, { stdio: 'pipe' });
126
+ execSync(`git checkout ${REGISTRY_BASE_BRANCH}`, { cwd: tempDir, stdio: 'pipe' });
127
+ s.stop('Repository cloned');
128
+
129
+ const branch = `sync/rules-${Date.now().toString(36).slice(-5)}`;
130
+ execSync(`git checkout -b ${branch}`, { cwd: tempDir, stdio: 'pipe' });
131
+
132
+ const s2 = fmt.spinner();
133
+ s2.start('Copying platform rules...');
134
+
135
+ syncFileTree(sourceRoot, join(tempDir, RULES_SOURCE_DIR));
136
+ execSync(`git add -A "${RULES_SOURCE_DIR}"`, { cwd: tempDir, stdio: 'pipe' });
137
+
138
+ const diffStatus = execSync('git diff --cached --name-only', { cwd: tempDir, encoding: 'utf8' }).trim();
139
+ if (!diffStatus) {
140
+ s2.stop('No changes');
141
+ fmt.cancel('Nothing to push — remote rules already match local content.');
142
+ }
143
+
144
+ const sourceType = normalize(sourceRoot).includes(normalize(join('.aw_registry', RULES_SOURCE_DIR)))
145
+ ? 'synced'
146
+ : 'local';
147
+ const prTitle = buildRulesPrTitle(sourceRoot, cwd);
148
+ const prBody = buildRulesPrBody(sourceRoot, sourceType, cwd);
149
+
150
+ execSync(`git commit -m "registry: sync platform rules\n\n${AW_CO_AUTHOR}"`, { cwd: tempDir, stdio: 'pipe' });
151
+ s2.stop('Rules sync prepared');
152
+
153
+ const s3 = fmt.spinner();
154
+ s3.start('Pushing and creating PR...');
155
+
156
+ execSync(`git push -u origin ${branch}`, { cwd: tempDir, stdio: 'pipe' });
157
+
158
+ let prUrl;
159
+ try {
160
+ prUrl = execFileSync('gh', [
161
+ 'pr', 'create',
162
+ '--base', REGISTRY_BASE_BRANCH,
163
+ '--title', prTitle,
164
+ '--body', prBody,
165
+ ], { cwd: tempDir, encoding: 'utf8' }).trim();
166
+ } catch {
167
+ const repoBase = repo.replace(/\.git$/, '');
168
+ prUrl = `https://github.com/${repoBase}/compare/${REGISTRY_BASE_BRANCH}...${branch}?expand=1`;
169
+ }
170
+
171
+ s3.stop('Branch pushed');
172
+ fmt.logSuccess(`PR: ${chalk.cyan(prUrl)}`);
173
+ fmt.outro('Rules push complete');
174
+ } catch (e) {
175
+ fmt.cancel(`Rules push failed: ${e.message}`);
176
+ } finally {
177
+ execSync(`rm -rf "${tempDir}"`, { stdio: 'pipe' });
178
+ }
179
+ }
180
+
181
+ export function pushRulesCommand(args) {
182
+ const input = args._positional?.[0];
183
+ const dryRun = args['--dry-run'] === true;
184
+ const repo = args['--repo'] || REGISTRY_REPO;
185
+ const cwd = process.cwd();
186
+
187
+ fmt.intro('aw push-rules');
188
+
189
+ const resolved = resolveRulesPushSource(input, cwd);
190
+ if (!resolved) {
191
+ fmt.cancel([
192
+ 'Could not find a rules source to push.',
193
+ '',
194
+ ` Checked ${chalk.cyan('.aw_rules/')} and ${chalk.cyan('.aw_registry/.aw_rules/')}.`,
195
+ '',
196
+ ' Use `aw pull platform` first or create a local `.aw_rules/` authoring tree.',
197
+ ].join('\n'));
198
+ }
199
+
200
+ if (resolved.sourceType === 'synced') {
201
+ fmt.logWarn('Pushing from synced `.aw_registry/.aw_rules/`. Local `.aw_rules/` is safer for authoring.');
202
+ }
203
+
204
+ pushRulesTree(resolved.sourceRoot, { repo, dryRun, cwd });
205
+ }
206
+
207
+ export const __test__ = {
208
+ buildRulesPushFile,
209
+ isRulesPushInput,
210
+ resolveRulesPushSource,
211
+ hasRulesChanges,
212
+ };