@ghl-ai/aw 0.1.36-beta.99 → 0.1.36

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/pull.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // commands/pull.mjs — Pull content from registry using persistent git clone
2
2
 
3
- import { mkdirSync, existsSync, readdirSync, copyFileSync, unlinkSync, rmdirSync, lstatSync } from 'node:fs';
4
- import { join, relative, extname } from 'node:path';
3
+ import { existsSync, lstatSync } from 'node:fs';
4
+ import { join, extname } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
6
  import { exec as execCb } from 'node:child_process';
7
7
  import { promisify } from 'node:util';
@@ -11,7 +11,7 @@ import * as config from '../config.mjs';
11
11
  import * as fmt from '../fmt.mjs';
12
12
  import { chalk } from '../fmt.mjs';
13
13
  import { fetchAndMerge, addToSparseCheckout, removeFromSparseCheckout, syncWorktreeSparseCheckout, isValidClone, findNearestWorktree, rebaseOntoOriginMain } from '../git.mjs';
14
- import { REGISTRY_DIR, REGISTRY_REPO, REGISTRY_URL, DOCS_SOURCE_DIR } from '../constants.mjs';
14
+ import { REGISTRY_DIR, REGISTRY_REPO, REGISTRY_URL } from '../constants.mjs';
15
15
  import { linkWorkspace } from '../link.mjs';
16
16
  import { generateCommands, copyInstructions } from '../integrate.mjs';
17
17
 
@@ -74,14 +74,48 @@ export async function pullCommand(args) {
74
74
  }
75
75
  }
76
76
 
77
- // Guard: if a rebase or merge is already in progress on awHome, don't proceed
77
+ // Guard: if a rebase or merge is already in progress on awHome, try to continue it.
78
78
  const awGitDir = join(AW_HOME, '.git');
79
- if (existsSync(join(awGitDir, 'rebase-merge')) ||
80
- existsSync(join(awGitDir, 'rebase-apply')) ||
81
- existsSync(join(awGitDir, 'MERGE_HEAD'))) {
82
- log.logWarn('Rebase paused resolve conflicts in your IDE, then run `aw pull` again.');
83
- if (!silent) fmt.outro(chalk.yellow('Pull skipped'));
84
- return;
79
+ const rebaseInProgress = existsSync(join(awGitDir, 'rebase-merge')) || existsSync(join(awGitDir, 'rebase-apply'));
80
+ const mergeInProgress = existsSync(join(awGitDir, 'MERGE_HEAD'));
81
+ if (rebaseInProgress || mergeInProgress) {
82
+ // Check for still-unresolved files (conflict markers present in index)
83
+ let unresolved = [];
84
+ try {
85
+ const { stdout } = await exec(`git -C "${AW_HOME}" diff --name-only --diff-filter=U`);
86
+ unresolved = stdout.trim().split('\n').filter(Boolean);
87
+ } catch { /* best effort */ }
88
+
89
+ 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.`);
92
+ if (!silent) fmt.outro(chalk.yellow('Pull skipped'));
93
+ return;
94
+ }
95
+
96
+ // All conflicts resolved (files are staged) — continue the rebase automatically
97
+ try {
98
+ await exec(`git -C "${AW_HOME}" rebase --continue`, { env: { ...process.env, GIT_EDITOR: 'true' } });
99
+ log.logStep('Rebase continued after conflict resolution.');
100
+ // Force-push if on a push branch so origin stays in sync
101
+ const { stdout: branchOut } = await exec(`git -C "${AW_HOME}" rev-parse --abbrev-ref HEAD`);
102
+ 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 */ }
105
+ }
106
+ } 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
+ try {
110
+ await exec(`git -C "${AW_HOME}" rebase --skip`);
111
+ log.logStep('Empty commit skipped during rebase continuation.');
112
+ } catch {
113
+ log.logWarn('Rebase continuation failed — check `~/.aw` and resolve manually.');
114
+ if (!silent) fmt.outro(chalk.yellow('Pull skipped'));
115
+ return;
116
+ }
117
+ }
118
+ // Fall through to re-link IDE dirs after successful rebase continuation
85
119
  }
86
120
 
87
121
  // Fetch + merge latest
@@ -89,7 +123,7 @@ export async function pullCommand(args) {
89
123
  s.start('Fetching latest from registry...');
90
124
  let fetchResult = { updated: false, conflicts: [] };
91
125
  try {
92
- fetchResult = await fetchAndMerge(AW_HOME);
126
+ fetchResult = await fetchAndMerge(AW_HOME, { silent });
93
127
  s.stop(fetchResult.updated ? 'Registry updated' : 'Already up to date');
94
128
  } catch (e) {
95
129
  s.stop(chalk.yellow('Fetch failed'));
@@ -113,13 +147,13 @@ export async function pullCommand(args) {
113
147
 
114
148
  if (fetchResult.conflicts.length > 0) {
115
149
  if (!silent) {
116
- // Interactive mode: abort the merge so the working tree is clean again
117
- try { await exec(`git -C "${AW_HOME}" merge --abort`); } catch { /* best effort */ }
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`.
118
152
  log.logWarn(`Merge conflict in: ${fetchResult.conflicts.join(', ')}`);
119
153
  log.logWarn('Merge aborted — your branch is unchanged. Resolve conflicts and run `aw pull` again.');
120
154
  return;
121
155
  }
122
- // Silent mode: leave conflict markers in place for IDE to show
156
+ // Silent mode: rebase was already aborted in fetchAndMerge; just report.
123
157
  log.logWarn(`Conflicts in: ${fetchResult.conflicts.join(', ')}`);
124
158
  }
125
159
 
@@ -169,9 +203,6 @@ export async function pullCommand(args) {
169
203
  }
170
204
  }
171
205
 
172
- // Sync content/ → platform/docs/
173
- syncDocs(AW_HOME, GLOBAL_AW_DIR);
174
-
175
206
  // Re-link IDE dirs
176
207
  if (!args._skipIntegrate) {
177
208
  const projectRegistryDir = cwd !== HOME ? join(cwd, '.aw', REGISTRY_DIR) : null;
@@ -198,16 +229,6 @@ export async function pullCommand(args) {
198
229
  }
199
230
  }
200
231
 
201
- /**
202
- * Sync ~/.aw/content/ markdown files → ~/.aw_registry/platform/docs/
203
- */
204
- function syncDocs(awHome, globalAwDir) {
205
- const contentSrc = join(awHome, DOCS_SOURCE_DIR);
206
- if (!existsSync(contentSrc)) return;
207
- const docsDest = join(globalAwDir, 'platform', 'docs');
208
- syncMarkdownTree(contentSrc, docsDest);
209
- }
210
-
211
232
  /**
212
233
  * pullAsync — kept for backward compat; now delegates to pullCommand.
213
234
  */
@@ -216,58 +237,6 @@ export async function pullAsync(args) {
216
237
  return { pattern: args._positional?.[0] || '', actions: [], conflictCount: 0 };
217
238
  }
218
239
 
219
- /**
220
- * Collect all .md file paths (relative) in a directory tree.
221
- */
222
- function collectMarkdownPaths(dir, base) {
223
- const paths = new Set();
224
- if (!existsSync(dir)) return paths;
225
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
226
- if (entry.name.startsWith('.')) continue;
227
- const full = join(dir, entry.name);
228
- if (entry.isDirectory()) {
229
- for (const p of collectMarkdownPaths(full, base)) paths.add(p);
230
- } else if (entry.name.endsWith('.md')) {
231
- paths.add(relative(base, full));
232
- }
233
- }
234
- return paths;
235
- }
236
-
237
- /**
238
- * Sync .md files from src to dest: copy new/changed, delete removed, prune empty dirs.
239
- */
240
- function syncMarkdownTree(src, dest) {
241
- mkdirSync(dest, { recursive: true });
242
-
243
- const remotePaths = collectMarkdownPaths(src, src);
244
- const localPaths = collectMarkdownPaths(dest, dest);
245
-
246
- for (const rel of remotePaths) {
247
- const srcPath = join(src, rel);
248
- const destPath = join(dest, rel);
249
- mkdirSync(join(dest, rel, '..'), { recursive: true });
250
- copyFileSync(srcPath, destPath);
251
- }
252
-
253
- for (const rel of localPaths) {
254
- if (!remotePaths.has(rel)) {
255
- const destPath = join(dest, rel);
256
- try { unlinkSync(destPath); } catch { /* best effort */ }
257
- }
258
- }
259
-
260
- function pruneEmpty(dir) {
261
- if (!existsSync(dir)) return;
262
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
263
- if (entry.isDirectory()) pruneEmpty(join(dir, entry.name));
264
- }
265
- try {
266
- if (readdirSync(dir).length === 0 && dir !== dest) rmdirSync(dir);
267
- } catch { /* best effort */ }
268
- }
269
- pruneEmpty(dest);
270
- }
271
240
 
272
241
  function registerMcp(namespace) {
273
242
  const mcpUrl = process.env.GHL_MCP_URL;
package/commands/push.mjs CHANGED
@@ -319,7 +319,7 @@ async function createOrUpdatePR(awHome, branch, prTitle, prBody) {
319
319
  // - Always creates a new branch from current state, commits, pushes, stays there.
320
320
  // - Every aw push = one new branch + one new PR. No force-push, no reuse.
321
321
  // Global flow (worktreeFlow=false): same but returns to main after push.
322
- async function doPush(files, awHome, dryRun, worktreeFlow = false, preStaged = false) {
322
+ async function doPush(files, awHome, dryRun, worktreeFlow = false, preStaged = false, extraPaths = []) {
323
323
  const added = files.filter(f => !f.deleted);
324
324
  const deleted = files.filter(f => f.deleted);
325
325
 
@@ -369,6 +369,10 @@ async function doPush(files, awHome, dryRun, worktreeFlow = false, preStaged = f
369
369
  if (newNamespaces.length > 0 && existsSync(codeownersPath)) {
370
370
  pathsToStage.push('CODEOWNERS');
371
371
  }
372
+ // Also stage any extra paths (content/, CODEOWNERS manual edits) passed from the caller
373
+ for (const p of extraPaths) {
374
+ if (!pathsToStage.includes(p)) pathsToStage.push(p);
375
+ }
372
376
 
373
377
  const commitMsg = generateCommitMsg(files);
374
378
  const prTitle = generatePrTitle(files, awHome);
@@ -449,10 +453,31 @@ export async function pushCommand(args) {
449
453
 
450
454
  // No args = staged files first (git commit behaviour), else auto-detect all changes
451
455
  if (!input) {
456
+ // Extra paths outside .aw_registry/ that aw also manages: content/ and CODEOWNERS.
457
+ // Detect staged variants for staged-mode and unstaged variants for auto-mode.
458
+ const getExtraStagedPaths = async () => {
459
+ try {
460
+ const { stdout } = await exec(`git -C "${awHome}" diff --cached --name-only -- content/ CODEOWNERS`);
461
+ return stdout.trim().split('\n').filter(Boolean);
462
+ } catch { return []; }
463
+ };
464
+ const getExtraChangedPaths = async () => {
465
+ try {
466
+ const { stdout } = await exec(`git -C "${awHome}" status --porcelain -- content/ CODEOWNERS`);
467
+ // git status --porcelain prefix is XY (2 chars) + optional space + path.
468
+ // Staged-only files: `M path` (2-char prefix); unstaged files: ` M path` (3-char prefix).
469
+ // slice(2).trimStart() handles both cases correctly.
470
+ return stdout.trim().split('\n').filter(Boolean)
471
+ .map(l => l.slice(2).trimStart())
472
+ .filter(Boolean);
473
+ } catch { return []; }
474
+ };
475
+
452
476
  // ── Staged mode: use whatever is in the index ──────────────────────
453
477
  const staged = getStagedFiles(awHome, REGISTRY_DIR);
478
+ const extraStaged = await getExtraStagedPaths();
454
479
 
455
- if (staged.length > 0) {
480
+ if (staged.length > 0 || extraStaged.length > 0) {
456
481
  const files = staged.map(f => {
457
482
  const meta = parseRegistryPath(f.registryPath);
458
483
  const parts = f.registryPath.split('/');
@@ -465,26 +490,28 @@ export async function pushCommand(args) {
465
490
  deleted: f.deleted,
466
491
  };
467
492
  });
468
- fmt.logInfo(`${chalk.dim('mode:')} staged (${files.length} file${files.length > 1 ? 's' : ''})`);
469
- await doPush(files, awHome, dryRun, worktreeFlow, true);
493
+ const totalCount = files.length + extraStaged.length;
494
+ fmt.logInfo(`${chalk.dim('mode:')} staged (${totalCount} file${totalCount > 1 ? 's' : ''})`);
495
+ await doPush(files, awHome, dryRun, worktreeFlow, true, extraStaged);
470
496
  return;
471
497
  }
472
498
 
473
499
  // ── Auto mode: stage all changes in .aw_registry/ ─────────────────
474
500
  const changes = detectChanges(awHome, REGISTRY_DIR);
501
+ const extraChanged = await getExtraChangedPaths();
475
502
  const allEntries = [
476
503
  ...changes.modified.map(e => ({ ...e, deleted: false })),
477
504
  ...changes.untracked.map(e => ({ ...e, deleted: false })),
478
505
  ...changes.deleted.map(e => ({ ...e, deleted: true })),
479
506
  ];
480
507
 
481
- if (allEntries.length === 0 && commitsAheadOfMain(awHome) > 0) {
508
+ if (allEntries.length === 0 && extraChanged.length === 0 && commitsAheadOfMain(awHome) > 0) {
482
509
  fmt.logInfo(`${chalk.dim('mode:')} auto (no new changes — branching current state)`);
483
510
  await doPush([], awHome, dryRun, worktreeFlow, false);
484
511
  return;
485
512
  }
486
513
 
487
- if (allEntries.length === 0) {
514
+ if (allEntries.length === 0 && extraChanged.length === 0) {
488
515
  fmt.cancel('Nothing to push — no staged or modified files.\n\n Stage files in your IDE or use `aw status` to see changes.');
489
516
  return;
490
517
  }
@@ -503,7 +530,7 @@ export async function pushCommand(args) {
503
530
  };
504
531
  });
505
532
 
506
- if (files.length === 0) {
533
+ if (files.length === 0 && extraChanged.length === 0) {
507
534
  if (commitsAheadOfMain(awHome) > 0) {
508
535
  fmt.logInfo(`${chalk.dim('mode:')} auto (no new changes — branching current state)`);
509
536
  await doPush([], awHome, dryRun, worktreeFlow, false);
@@ -513,8 +540,9 @@ export async function pushCommand(args) {
513
540
  return;
514
541
  }
515
542
 
516
- fmt.logInfo(`${chalk.dim('mode:')} auto (${files.length} file${files.length > 1 ? 's' : ''} — stage specific files to push a subset)`);
517
- await doPush(files, awHome, dryRun, worktreeFlow, false);
543
+ const totalCount = files.length + extraChanged.length;
544
+ fmt.logInfo(`${chalk.dim('mode:')} auto (${totalCount} file${totalCount > 1 ? 's' : ''} — stage specific files to push a subset)`);
545
+ await doPush(files, awHome, dryRun, worktreeFlow, false, extraChanged);
518
546
  return;
519
547
  }
520
548
 
package/constants.mjs CHANGED
@@ -4,7 +4,7 @@ import { homedir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
 
6
6
  /** Base branch for PRs and sync checkout */
7
- export const REGISTRY_BASE_BRANCH = process.env.AW_REGISTRY_BASE_BRANCH || 'chore/stream-registry';
7
+ export const REGISTRY_BASE_BRANCH = process.env.AW_REGISTRY_BASE_BRANCH || 'main';
8
8
 
9
9
  /** Default registry repository */
10
10
  export const REGISTRY_REPO = 'GoHighLevel/platform-docs';
package/git.mjs CHANGED
@@ -293,22 +293,30 @@ export async function fetchAndMerge(awHome, { silent = true } = {}) {
293
293
  let updated = false;
294
294
  const conflicts = [];
295
295
 
296
- // ── 3. Fast-forward (no local commits ahead of remote) ───────────────────
297
- try {
298
- const { stdout } = await exec(
299
- `git -C "${awHome}" merge origin/${REGISTRY_BASE_BRANCH} --ff-only`,
300
- );
301
- updated = !stdout.includes('Already up to date');
302
- return { updated, conflicts };
303
- } catch { /* local commits exist fall through to rebase */ }
304
-
305
- // ── 4. Rebase current branch onto remote REGISTRY_BASE_BRANCH ────────────
306
- // For push branches: stacks our local commits on top of latest remote.
307
- // For base branch: only reached if local commits exist (unusual).
308
- // Never uses --no-edit merge — that disables sparse checkout on blob:none.
296
+ // ── 3 + 4. Rebase onto remote REGISTRY_BASE_BRANCH ──────────────────────
297
+ // Handles both cases in one path:
298
+ // • No local commits (base branch, clean tree) → fast-forward via rebase
299
+ // • Local commits (push branch) → rebases on top
300
+ // • Uncommitted local changes → rebase refuses to run,
301
+ // sync is skipped this run, changes are preserved
302
+ //
303
+ // We avoid `merge --ff-only` and `merge --no-edit` entirely: both trigger a
304
+ // git 2.46+ bug on blob:none + no-cone sparse-checkout repos that silently
305
+ // drops bare-name patterns (e.g. "content", "CODEOWNERS") when HEAD advances.
309
306
  try {
310
307
  await exec(`git -C "${awHome}" rebase origin/${REGISTRY_BASE_BRANCH}`);
311
308
  updated = true;
309
+ // Push branch rebase rewrites commit SHAs — force-push so origin/upload/...
310
+ // stays in sync with the rebased local branch. Without this, VS Code and
311
+ // plain `git pull` show a false divergence ("2↑ 1↓") that can't be resolved
312
+ // without specifying a reconcile strategy.
313
+ // --force-with-lease is safer than --force: it refuses to overwrite if
314
+ // someone else pushed to the remote tracking branch since our last fetch.
315
+ if (isPushBranch) {
316
+ try {
317
+ await exec(`git -C "${awHome}" push --force-with-lease origin "${currentBranch}"`);
318
+ } catch { /* non-blocking — divergence will be resolved on next aw push */ }
319
+ }
312
320
  } catch {
313
321
  try {
314
322
  const { stdout } = await exec(`git -C "${awHome}" diff --name-only --diff-filter=U`);
package/hooks.mjs CHANGED
@@ -51,15 +51,21 @@ exit 0
51
51
 
52
52
  const POST_MERGE = makeDispatcher('post-merge', `\
53
53
  if command -v aw >/dev/null 2>&1; then
54
- aw init --silent >/dev/null 2>&1 &
54
+ # Unset ALL git env vars so aw's "git -C ~/.aw" runs against the correct repo,
55
+ # not the project repo that triggered this hook. Using git's own list of
56
+ # local env vars is more robust than hardcoding specific names.
57
+ unset $(git rev-parse --local-env-vars 2>/dev/null)
58
+ aw pull --silent >/dev/null 2>&1 &
55
59
  fi`);
56
60
 
57
61
  const POST_CHECKOUT = makeDispatcher('post-checkout', `\
62
+ # Unset ALL git env vars so aw's "git -C ~/.aw" runs against the correct repo.
63
+ unset $(git rev-parse --local-env-vars 2>/dev/null)
58
64
  if [ -d "$HOME/.aw" ] && [ ! -d ".aw" ] && [ -d ".git" ] && command -v aw >/dev/null 2>&1; then
59
65
  aw link >/dev/null 2>&1 &
60
66
  fi
61
67
  if command -v aw >/dev/null 2>&1; then
62
- aw init --silent >/dev/null 2>&1 &
68
+ aw pull --silent >/dev/null 2>&1 &
63
69
  fi`);
64
70
 
65
71
  // post-commit: written separately — needs different guard logic than other hooks.
@@ -68,6 +74,9 @@ fi`);
68
74
  const POST_COMMIT = `#!/bin/sh
69
75
  # aw: global post-commit dispatcher (installed by aw init)
70
76
 
77
+ # Unset ALL git env vars so aw's "git -C ~/.aw" runs against the correct repo.
78
+ unset $(git rev-parse --local-env-vars 2>/dev/null)
79
+
71
80
  # Skip temp sparse checkouts
72
81
  case "$(pwd)" in /tmp/aw-*|/var/folders/*/aw-*) exit 0 ;; esac
73
82
 
package/mcp.mjs CHANGED
@@ -297,10 +297,16 @@ export async function setupMcp(cwd, namespace, { silent = false } = {}) {
297
297
  }
298
298
 
299
299
  // ── Codex: ~/.codex/config.toml (TOML format) ──
300
+ // Also merge into the ECC source file (~/.aw-ecc/.codex/config.toml) so
301
+ // that when installAwEcc re-copies it on subsequent inits the ghl-ai entry
302
+ // survives — without this, each re-init overwrites ~/.codex/config.toml
303
+ // from the ECC source which doesn't have the ghl-ai block.
300
304
  const codexTomlPath = join(HOME, '.codex', 'config.toml');
301
305
  if (mergeTomlMcpServer(codexTomlPath, 'ghl-ai', ghlAiServerLocal)) {
302
306
  updatedFiles.push(codexTomlPath);
303
307
  }
308
+ const eccCodexTomlPath = join(HOME, '.aw-ecc', '.codex', 'config.toml');
309
+ mergeTomlMcpServer(eccCodexTomlPath, 'ghl-ai', ghlAiServerLocal);
304
310
 
305
311
  // Deduplicate
306
312
  const unique = [...new Set(updatedFiles)];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghl-ai/aw",
3
- "version": "0.1.36-beta.99",
3
+ "version": "0.1.36",
4
4
  "description": "Agentic Workspace CLI — pull, push & manage agents, skills and commands from the registry",
5
5
  "type": "module",
6
6
  "bin": "bin.js",