@ghl-ai/aw 0.1.52 → 0.1.54-beta.0

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.
@@ -13,8 +13,9 @@
13
13
  * On Codex Web, neither Claude's plugin marketplace nor Cursor's direct
14
14
  * `~/.cursor/skills/...` resolution applies. The only way Codex can see
15
15
  * ECC skills is if the registry path is populated. We bridge by symlinking
16
- * each ECC skill's SKILL.md into the registry layout, idempotently, without
17
- * clobbering any user-authored content already present at the target.
16
+ * each ECC skill's SKILL.md into the registry layout. The default mode keeps
17
+ * the original non-clobbering behavior. `preferEccSkills` only retargets
18
+ * existing symlinks; regular files are treated as user-owned and preserved.
18
19
  *
19
20
  * Also installs a fallback symlink at `<homeOf(awHome)>/.aw_registry` →
20
21
  * `<awHome>/.aw_registry` because some legacy hooks read from that path.
@@ -46,6 +47,26 @@ function isBrokenSymlink(path) {
46
47
  }
47
48
  }
48
49
 
50
+ /** True iff `path` is a symlink that already points at `target`. */
51
+ function isSymlinkTo(path, target) {
52
+ try {
53
+ const st = lstatSync(path);
54
+ if (!st.isSymbolicLink()) return false;
55
+ return readlinkSync(path) === target;
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ /** True iff `path` is any symlink, regardless of where it points. */
62
+ function isSymlink(path) {
63
+ try {
64
+ return lstatSync(path).isSymbolicLink();
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
49
70
  /**
50
71
  * Install the fallback symlink `~/.aw_registry → ~/.aw/.aw_registry` if not
51
72
  * present and not blocked by an existing real directory. Best-effort.
@@ -82,7 +103,8 @@ function installRegistryFallbackLink(awHome) {
82
103
  *
83
104
  * Result shape:
84
105
  * - linked — skill names freshly bridged this call
85
- * - skipped — skill names whose registry target already existed (file or healthy symlink)
106
+ * - skipped — skill names whose registry target already existed and was preserved
107
+ * - replaced — skill names whose registry target was replaced with the ECC symlink
86
108
  * - broken — skill directories that exist in eccHome/skills/ but have no SKILL.md
87
109
  * (broken ECC layout — distinct from already-bridged so the orchestrator
88
110
  * can surface a real diagnostic rather than treating it as success)
@@ -94,14 +116,18 @@ function installRegistryFallbackLink(awHome) {
94
116
  * @param {object} opts
95
117
  * @param {string} opts.awHome Path of the AW home (e.g. ~/.aw).
96
118
  * @param {string} opts.eccHome Path of the ECC home (e.g. ~/.aw-ecc).
119
+ * @param {boolean} [opts.preferEccSkills=false]
120
+ * Retarget existing registry SKILL.md symlinks to ECC. Regular files are
121
+ * never replaced because they may be user-owned overrides.
97
122
  * @returns {{
98
123
  * linked: string[],
99
124
  * skipped: string[],
125
+ * replaced: string[],
100
126
  * broken: string[],
101
127
  * reason?: 'already-bridged' | 'ecc-missing'
102
128
  * }}
103
129
  */
104
- export function applyEccRegistryBridge({ awHome, eccHome } = {}) {
130
+ export function applyEccRegistryBridge({ awHome, eccHome, preferEccSkills = false } = {}) {
105
131
  if (!awHome || !eccHome) {
106
132
  throw new Error('applyEccRegistryBridge: awHome and eccHome are required');
107
133
  }
@@ -113,6 +139,7 @@ export function applyEccRegistryBridge({ awHome, eccHome } = {}) {
113
139
 
114
140
  const linked = [];
115
141
  const skipped = [];
142
+ const replaced = [];
116
143
  const broken = [];
117
144
 
118
145
  let entries;
@@ -138,7 +165,9 @@ export function applyEccRegistryBridge({ awHome, eccHome } = {}) {
138
165
  const targetDir = join(awHome, '.aw_registry/platform/core/skills', name);
139
166
  const target = join(targetDir, 'SKILL.md');
140
167
 
141
- // If target already exists and is a healthy file/symlink, leave it alone.
168
+ // If target already exists and is healthy, preserve regular files. The
169
+ // optional preference mode may only retarget generated symlinks; it must
170
+ // not delete user-owned registry files.
142
171
  let targetExists = false;
143
172
  try {
144
173
  lstatSync(target);
@@ -148,7 +177,29 @@ export function applyEccRegistryBridge({ awHome, eccHome } = {}) {
148
177
  }
149
178
 
150
179
  if (targetExists && !isBrokenSymlink(target)) {
151
- skipped.push(name);
180
+ if (isSymlinkTo(target, source)) {
181
+ skipped.push(name);
182
+ continue;
183
+ }
184
+
185
+ if (!preferEccSkills || !isSymlink(target)) {
186
+ skipped.push(name);
187
+ continue;
188
+ }
189
+
190
+ try {
191
+ unlinkSync(target);
192
+ } catch {
193
+ broken.push(name);
194
+ continue;
195
+ }
196
+
197
+ try {
198
+ symlinkSync(source, target);
199
+ replaced.push(name);
200
+ } catch {
201
+ broken.push(name);
202
+ }
152
203
  continue;
153
204
  }
154
205
 
@@ -169,12 +220,13 @@ export function applyEccRegistryBridge({ awHome, eccHome } = {}) {
169
220
 
170
221
  installRegistryFallbackLink(awHome);
171
222
 
172
- const result = { linked, skipped, broken };
223
+ const result = { linked, skipped, replaced, broken };
173
224
  // Only flag 'already-bridged' when every well-formed skill was already linked
174
225
  // AND no broken skills exist — i.e. this run was a true no-op.
175
226
  if (
176
227
  skillDirCount > 0 &&
177
228
  linked.length === 0 &&
229
+ replaced.length === 0 &&
178
230
  broken.length === 0 &&
179
231
  skipped.length === skillDirCount
180
232
  ) {
package/commands/c4.mjs CHANGED
@@ -10,6 +10,9 @@
10
10
  * --dry-run stops before step 6 (no npm install, no aw init, no harness writes).
11
11
  * --diagnose runs only steps 1, 16, 17, 18 — and ALWAYS exits 0
12
12
  * (G7: report mode, not gate mode).
13
+ * --skip-self-install or AW_C4_SKIP_SELF_INSTALL=1 skips only the
14
+ * `npm install -g @ghl-ai/aw@latest` self-update step and uses
15
+ * the aw binary already provided by the runtime image.
13
16
  *
14
17
  * Every barrel symbol used here is dependency-injected via the second
15
18
  * argument so the test in tests/c4/c4.command.test.mjs can exercise the
@@ -45,7 +48,14 @@ function normalizeArgs(input) {
45
48
  const out = { _positional: [] };
46
49
  for (let i = 0; i < input.length; i++) {
47
50
  const a = input[i];
48
- if (a === '--dry-run' || a === '-v' || a === '--verbose' || a === '--diagnose' || a === '--skip-pull') {
51
+ if (
52
+ a === '--dry-run' ||
53
+ a === '-v' ||
54
+ a === '--verbose' ||
55
+ a === '--diagnose' ||
56
+ a === '--skip-pull' ||
57
+ a === '--skip-self-install'
58
+ ) {
49
59
  out[a] = true;
50
60
  } else if (a === '--harness') {
51
61
  out['--harness'] = input[i + 1];
@@ -109,6 +119,12 @@ function setupGitAuth({ token, c4, home, cwd }) {
109
119
  c4.ensureOriginRemote({ cwd });
110
120
  }
111
121
 
122
+ function resolveCodexRouterSkillPath({ awHome, eccHome, existsSync = fsExistsSync }) {
123
+ const eccRouterSkillPath = join(eccHome, 'skills/using-aw-skills/SKILL.md');
124
+ if (existsSync(eccRouterSkillPath)) return eccRouterSkillPath;
125
+ return join(awHome, '.aw_registry/platform/core/skills/using-aw-skills/SKILL.md');
126
+ }
127
+
112
128
  /* ─────────────────────────────────────────────────────────────────────────
113
129
  * Per-harness branch (step 10).
114
130
  *
@@ -117,7 +133,7 @@ function setupGitAuth({ token, c4, home, cwd }) {
117
133
  * marketplace / slim card) bubble up as thrown Errors.
118
134
  * ───────────────────────────────────────────────────────────────────────── */
119
135
 
120
- function runHarnessBranch({ harness, c4, home, eccHome, cwd, writer }) {
136
+ function runHarnessBranch({ harness, c4, home, eccHome, cwd, writer, existsSync = fsExistsSync }) {
121
137
  if (harness === 'claude-web') {
122
138
  // Marketplace registration is non-fatal — the slim card is the routing
123
139
  // mechanism (state.json::resolved_decisions exit-code item 3 lists slim
@@ -138,7 +154,7 @@ function runHarnessBranch({ harness, c4, home, eccHome, cwd, writer }) {
138
154
  const awHome = join(home, '.aw');
139
155
  c4.applyEccRegistryBridge({ awHome, eccHome });
140
156
  c4.ensureCodexHooksFlag(home);
141
- const routerSkillPath = join(awHome, '.aw_registry/platform/core/skills/using-aw-skills/SKILL.md');
157
+ const routerSkillPath = resolveCodexRouterSkillPath({ awHome, eccHome, existsSync });
142
158
  // Pass hookPath explicitly: defaultHookPath() reads os.homedir() at module
143
159
  // load time, which ignores the orchestrator's `home` (env.HOME). Without
144
160
  // this, installs land in the real ~/.codex even when HOME is overridden
@@ -350,10 +366,15 @@ export async function c4Command(rawArgs, overrides = {}) {
350
366
  return exit(0);
351
367
  }
352
368
 
353
- // Step 6 — npm install -g @ghl-ai/aw@latest.
354
- const npmRes = spawnSync('npm', ['install', '-g', '@ghl-ai/aw@latest'], { stdio: 'pipe' });
355
- if (npmRes && npmRes.status !== 0) {
356
- writer.stderr('[aw-c4] npm install -g @ghl-ai/aw@latest failed (non-fatal); using existing aw if present\n');
369
+ // Step 6 — npm install -g @ghl-ai/aw@latest unless the image owns AW.
370
+ const skipSelfInstall = args['--skip-self-install'] === true || env.AW_C4_SKIP_SELF_INSTALL === '1';
371
+ if (skipSelfInstall) {
372
+ writer.stdout('[aw-c4] self-install skipped; using runtime aw\n');
373
+ } else {
374
+ const npmRes = spawnSync('npm', ['install', '-g', '@ghl-ai/aw@latest'], { stdio: 'pipe' });
375
+ if (npmRes && npmRes.status !== 0) {
376
+ writer.stderr('[aw-c4] npm install -g @ghl-ai/aw@latest failed (non-fatal); using existing aw if present\n');
377
+ }
357
378
  }
358
379
 
359
380
  // Step 7 — aw init --silent.
@@ -374,7 +395,7 @@ export async function c4Command(rawArgs, overrides = {}) {
374
395
  // Step 10 — per-harness branch.
375
396
  let branch;
376
397
  try {
377
- branch = runHarnessBranch({ harness, c4, home, eccHome, cwd, writer });
398
+ branch = runHarnessBranch({ harness, c4, home, eccHome, cwd, writer, existsSync: fs.existsSync });
378
399
  } catch (err) {
379
400
  writer.stderr(`[aw-c4] FATAL: harness branch (${harness}) failed: ${err?.message ?? err}\n`);
380
401
  return exit(1);
package/git.mjs CHANGED
@@ -388,8 +388,17 @@ export async function fetchAndMerge(awHome, { silent = true } = {}) {
388
388
  // We avoid `merge --ff-only` and `merge --no-edit` entirely: both trigger a
389
389
  // git 2.46+ bug on blob:none + no-cone sparse-checkout repos that silently
390
390
  // drops bare-name patterns (e.g. "content", "CODEOWNERS") when HEAD advances.
391
- try {
392
- await exec(`git -C "${awHome}" rebase origin/${REGISTRY_BASE_BRANCH}`);
391
+ //
392
+ // --autostash: AW writes into the registry working tree from external
393
+ // sources (ensureAwRuntimeHook copies ~/.aw-ecc/.../session-start.sh into a
394
+ // tracked path; transformCursorAwRefs rewrites /aw: → /aw- through Cursor
395
+ // skill directory symlinks that resolve into .aw_registry/). When those
396
+ // versions drift, the working tree is dirty at rebase time and rebase
397
+ // refuses to run, silently aborting the entire pull. Autostash stashes the
398
+ // dirty bytes, rebases cleanly, and reapplies them — so the registry stays
399
+ // in sync even when external sources have leaked into the tracked tree.
400
+ try {
401
+ await exec(`git -C "${awHome}" rebase --autostash origin/${REGISTRY_BASE_BRANCH}`);
393
402
  updated = true;
394
403
  // Push branch rebase rewrites commit SHAs — force-push so origin/upload/...
395
404
  // stays in sync with the rebased local branch. Without this, VS Code and
@@ -683,7 +692,10 @@ export function checkoutMain(awHome) {
683
692
  */
684
693
  export async function rebaseOntoOriginMain(awHome) {
685
694
  try {
686
- await exec(`git -C "${awHome}" rebase origin/${REGISTRY_BASE_BRANCH}`);
695
+ // --autostash: see fetchAndMerge() above. Project-local worktrees see the
696
+ // same external-write patterns (runtime hook copy + Cursor symlink writes)
697
+ // and need the same dirty-tree tolerance here.
698
+ await exec(`git -C "${awHome}" rebase --autostash origin/${REGISTRY_BASE_BRANCH}`);
687
699
  } catch (e) {
688
700
  // Surface stderr so callers can show a meaningful message
689
701
  const stderr = e.stderr?.toString().trim() || e.stdout?.toString().trim() || e.message;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghl-ai/aw",
3
- "version": "0.1.52",
3
+ "version": "0.1.54-beta.0",
4
4
  "description": "Agentic Workspace CLI — pull, push & manage agents, skills and commands from the registry",
5
5
  "type": "module",
6
6
  "bin": {