@ghl-ai/aw 0.1.44-beta.8 → 0.1.44

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/c4/index.mjs CHANGED
@@ -64,3 +64,11 @@ export {
64
64
  diagnoseSkillResolution,
65
65
  dumpPostInitState,
66
66
  } from './diagnostics.mjs';
67
+ export {
68
+ DEFAULT_TEMPLATES_DIR,
69
+ loadManifest,
70
+ expectedContent,
71
+ planActions,
72
+ applyActions,
73
+ initRepo,
74
+ } from './initRepo.mjs';
@@ -0,0 +1,567 @@
1
+ /**
2
+ * c4/initRepo.mjs — engine for `aw init-repo`.
3
+ *
4
+ * Scaffolds the 4 cloud-bootstrap files into a target git repo from
5
+ * bundled literal templates (under ./templates/). Idempotent:
6
+ * SHA-256 byte compare per file decides create / skip-equal /
7
+ * blocked-edited / overwrite. `--dry-run` and `--diff` are read-only.
8
+ *
9
+ * Public surface (composed in this order):
10
+ * loadManifest(templatesDir) — read & parse templates/manifest.json
11
+ * planActions(opts, manifest) — pure: decide per-file action
12
+ * applyActions(opts, actions) — FS effects (gated by dryRun)
13
+ * initRepo(opts) — orchestrator + exitCode summary
14
+ *
15
+ * Contract: spec.md::"New: libs/aw/c4/initRepo.mjs (engine)"
16
+ */
17
+
18
+ import {
19
+ existsSync,
20
+ readFileSync,
21
+ writeFileSync,
22
+ mkdirSync,
23
+ chmodSync,
24
+ statSync,
25
+ } from 'node:fs';
26
+ import { join, dirname, basename, resolve } from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+ import { createHash } from 'node:crypto';
29
+ import { spawnSync } from 'node:child_process';
30
+
31
+ /**
32
+ * Default `git check-ignore` runner. Returns true when `relpath` is ignored
33
+ * by the user's gitignore rules, false otherwise (or when git is unavailable).
34
+ *
35
+ * Exit codes per `git check-ignore --quiet`:
36
+ * 0 — file IS ignored
37
+ * 1 — file is NOT ignored
38
+ * 128 — error (no git repo, etc.) — treat as not-ignored to avoid noisy
39
+ * false-positive warnings on broken installs
40
+ */
41
+ const DEFAULT_CHECK_IGNORE = (repoRoot, relpath) => {
42
+ try {
43
+ const result = spawnSync(
44
+ 'git',
45
+ ['-C', repoRoot, 'check-ignore', '--quiet', relpath],
46
+ { stdio: 'pipe' },
47
+ );
48
+ return result.status === 0;
49
+ } catch {
50
+ return false;
51
+ }
52
+ };
53
+
54
+ /**
55
+ * Variable substitution table for `kind: "interpolated"` templates.
56
+ *
57
+ * Each value is computed lazily from `opts` (the engine call site provides
58
+ * `repoRoot`). Adding a new variable here means it becomes referenceable as
59
+ * `{{NAME}}` inside any interpolated template.
60
+ *
61
+ * REPO_BASENAME is the leaf directory name of `repoRoot` after `path.resolve`
62
+ * — exactly what Codex Cloud uses as the project key (`/workspace/<basename>`).
63
+ */
64
+ const SUBSTITUTIONS = {
65
+ REPO_BASENAME: (opts) => basename(resolve(opts.repoRoot)),
66
+ };
67
+
68
+ /**
69
+ * Render `{{VAR}}` placeholders in `content` using the SUBSTITUTIONS table.
70
+ *
71
+ * Throws on any `{{...}}` reference whose key isn't in the table — fail loud
72
+ * over silently shipping `{{REPO_BASENAME}}` in a config file.
73
+ *
74
+ * @param {string} content
75
+ * @param {{repoRoot: string}} opts
76
+ * @returns {string} content with all known placeholders substituted
77
+ */
78
+ function renderInterpolated(content, opts) {
79
+ return content.replace(/\{\{(\w+)\}\}/g, (_, name) => {
80
+ if (!Object.prototype.hasOwnProperty.call(SUBSTITUTIONS, name)) {
81
+ throw new Error(
82
+ `init-repo: unknown variable {{${name}}} in interpolated template. ` +
83
+ `Known: ${Object.keys(SUBSTITUTIONS).join(', ')}.`,
84
+ );
85
+ }
86
+ return SUBSTITUTIONS[name](opts);
87
+ });
88
+ }
89
+
90
+ /**
91
+ * Render the full expected file content for a `kind: "managed-block"` template.
92
+ *
93
+ * Reads the bundled block source (just the inner lines), strips any existing
94
+ * managed block bearing the same `marker` from the dest file (so re-runs
95
+ * cleanly replace stale content), and appends a fresh marker-wrapped block.
96
+ *
97
+ * Format on disk (matches existing AW convention from integrate.mjs and
98
+ * repoRootInstructions.mjs — colon-no-space, marker name on both ends):
99
+ * <pre-existing user content (managed block stripped)>
100
+ *
101
+ * # aw-managed:start <marker> (DO NOT EDIT — managed by `aw init-repo`)
102
+ * <block source content>
103
+ * # aw-managed:end <marker>
104
+ *
105
+ * The single blank line separator between user content and our block is only
106
+ * emitted when prior content exists. The file always ends with one trailing
107
+ * newline.
108
+ *
109
+ * @param {{src: string, marker: string}} template
110
+ * @param {{templatesDir: string, repoRoot: string}} opts
111
+ * @returns {string} full expected file content
112
+ */
113
+ function renderManagedBlock(template, opts) {
114
+ const blockSrcPath = join(opts.templatesDir, template.src);
115
+ const blockBody = readFileSync(blockSrcPath, 'utf8').replace(/\n+$/, '');
116
+
117
+ const destPath = join(opts.repoRoot, template.dest);
118
+ const existing = existsSync(destPath) ? readFileSync(destPath, 'utf8') : '';
119
+ const preserved = stripManagedBlock(existing, template.marker).replace(/\n+$/, '');
120
+
121
+ const wrapped =
122
+ `# aw-managed:start ${template.marker} (DO NOT EDIT — managed by \`aw init-repo\`)\n` +
123
+ blockBody +
124
+ '\n' +
125
+ `# aw-managed:end ${template.marker}\n`;
126
+
127
+ return preserved.length > 0 ? `${preserved}\n\n${wrapped}` : wrapped;
128
+ }
129
+
130
+ /**
131
+ * Remove every `# aw-managed:start <marker> ... # aw-managed:end <marker>`
132
+ * block from `content`. Idempotent on content with no such block.
133
+ *
134
+ * Defensive against multiple stale blocks with the same marker (all collapse
135
+ * to none — fresh block is appended afterward by the caller).
136
+ */
137
+ function stripManagedBlock(content, marker) {
138
+ const safeMarker = marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
139
+ const re = new RegExp(
140
+ `(?:^|\\n)# aw-managed:start ${safeMarker}(?:[^\\n]*)\\n` +
141
+ `[\\s\\S]*?` +
142
+ `# aw-managed:end ${safeMarker}[ \\t]*\\n?`,
143
+ 'g',
144
+ );
145
+ return content.replace(re, '\n');
146
+ }
147
+
148
+ /** Default fs surface — node:fs. Tests inject mocks for failure paths. */
149
+ const DEFAULT_FS = {
150
+ existsSync,
151
+ readFileSync,
152
+ writeFileSync,
153
+ mkdirSync,
154
+ chmodSync,
155
+ statSync,
156
+ };
157
+
158
+ /** Default writer — process.stdout / process.stderr. */
159
+ const DEFAULT_WRITER = {
160
+ stdout: (s) => process.stdout.write(s),
161
+ stderr: (s) => process.stderr.write(s),
162
+ };
163
+
164
+ const __dirname = dirname(fileURLToPath(import.meta.url));
165
+
166
+ /** Default location of bundled templates inside the npm package. */
167
+ export const DEFAULT_TEMPLATES_DIR = join(__dirname, 'templates');
168
+
169
+ /**
170
+ * Read & parse the templates manifest from the given directory.
171
+ *
172
+ * Throws a structured Error with a `code: 'TEMPLATES_NOT_FOUND'` marker
173
+ * when manifest.json is missing or unreadable — consumers (the
174
+ * orchestrator) translate this to user-facing exit-2 output.
175
+ *
176
+ * @param {string} templatesDir absolute path to the bundled templates dir
177
+ * @returns {{ version: 1, templates: Array<{kind:'literal',src:string,dest:string,mode:string}> }}
178
+ */
179
+ export function loadManifest(templatesDir) {
180
+ const manifestPath = join(templatesDir, 'manifest.json');
181
+ let raw;
182
+ try {
183
+ raw = readFileSync(manifestPath, 'utf8');
184
+ } catch (err) {
185
+ const e = new Error(
186
+ `init-repo: bundled templates not found at ${manifestPath}. Reinstall @ghl-ai/aw.`,
187
+ );
188
+ e.code = 'TEMPLATES_NOT_FOUND';
189
+ e.cause = err;
190
+ throw e;
191
+ }
192
+
193
+ let parsed;
194
+ try {
195
+ parsed = JSON.parse(raw);
196
+ } catch (err) {
197
+ const e = new Error(
198
+ `init-repo: bundled templates manifest.json is malformed at ${manifestPath}: ${err.message}`,
199
+ );
200
+ e.code = 'TEMPLATES_MALFORMED';
201
+ e.cause = err;
202
+ throw e;
203
+ }
204
+
205
+ return parsed;
206
+ }
207
+
208
+ /**
209
+ * Render the expected file content for a single manifest entry.
210
+ *
211
+ * Dispatch by `template.kind`:
212
+ * - 'literal' → readFileSync of the bundled src verbatim
213
+ * - 'interpolated' → readFileSync + {{VAR}} substitution via SUBSTITUTIONS
214
+ * - 'managed-block' → strip stale managed block from dest, append fresh
215
+ * marker-wrapped block built from src body. Returns the
216
+ * FULL rendered dest content (preserves user content
217
+ * outside the markers).
218
+ *
219
+ * Pure read — no FS writes. Tests can exercise this directly. The action
220
+ * object carries both expected and current content so applyActions and the
221
+ * diff renderer don't have to re-read.
222
+ *
223
+ * @param {{kind?: string, src: string, dest?: string, marker?: string}} template
224
+ * @param {{templatesDir: string, repoRoot?: string}} opts
225
+ * @returns {string} rendered template content (UTF-8)
226
+ */
227
+ export function expectedContent(template, opts) {
228
+ const kind = template.kind ?? 'literal';
229
+
230
+ if (kind === 'literal') {
231
+ return readFileSync(join(opts.templatesDir, template.src), 'utf8');
232
+ }
233
+ if (kind === 'interpolated') {
234
+ const raw = readFileSync(join(opts.templatesDir, template.src), 'utf8');
235
+ return renderInterpolated(raw, opts);
236
+ }
237
+ if (kind === 'managed-block') {
238
+ return renderManagedBlock(template, opts);
239
+ }
240
+ throw new Error(
241
+ `init-repo: unknown template kind "${kind}" for ${template.src ?? '?'}.`,
242
+ );
243
+ }
244
+
245
+ /**
246
+ * sha256(content) hex — byte-exact, no normalization.
247
+ *
248
+ * Note: this is sensitive to line endings and BOM. On Windows with
249
+ * core.autocrlf=true a CRLF-rewritten clone will hash-mismatch and surface
250
+ * as `blocked-edited` even on a clean checkout. spec.md::"Cross-platform
251
+ * line endings" recommends a repo-local .gitattributes with `* eol=lf`.
252
+ */
253
+ function sha256(content) {
254
+ return createHash('sha256').update(content, 'utf8').digest('hex');
255
+ }
256
+
257
+ /**
258
+ * Decide per-file action for every template — pure, no FS writes.
259
+ *
260
+ * Decision matrix:
261
+ * file missing → 'create'
262
+ * file exists, hash matches → 'skip-equal'
263
+ * file exists, hash differs:
264
+ * opts.diff → 'diff' (read-only inspection)
265
+ * opts.force → 'overwrite' (clobber edits)
266
+ * else → 'blocked-edited' (exit 1, no write)
267
+ *
268
+ * @param {{repoRoot: string, templatesDir: string, force?: boolean, diff?: boolean}} opts
269
+ * @param {{templates: Array}} manifest
270
+ * @returns {Array<{template: object, dest: string, action: string, expectedContent: string, currentContent: string|null}>}
271
+ */
272
+ export function planActions(opts, manifest) {
273
+ return manifest.templates.map((template) => {
274
+ const dest = join(opts.repoRoot, template.dest);
275
+ const expected = expectedContent(template, opts);
276
+
277
+ if (!existsSync(dest)) {
278
+ return {
279
+ template,
280
+ dest,
281
+ action: 'create',
282
+ expectedContent: expected,
283
+ currentContent: null,
284
+ };
285
+ }
286
+
287
+ const current = readFileSync(dest, 'utf8');
288
+ const isEqual = sha256(current) === sha256(expected);
289
+
290
+ if (isEqual) {
291
+ return {
292
+ template,
293
+ dest,
294
+ action: 'skip-equal',
295
+ expectedContent: expected,
296
+ currentContent: current,
297
+ };
298
+ }
299
+
300
+ let action;
301
+ if (template.kind === 'managed-block') {
302
+ // We own the marker-bracketed area; user edits OUTSIDE the markers are
303
+ // preserved by renderManagedBlock. Mismatch always means "our block is
304
+ // stale or missing" → safe to overwrite.
305
+ action = 'overwrite';
306
+ } else if (opts.diff) {
307
+ action = 'diff';
308
+ } else if (opts.force) {
309
+ action = 'overwrite';
310
+ } else {
311
+ action = 'blocked-edited';
312
+ }
313
+
314
+ return {
315
+ template,
316
+ dest,
317
+ action,
318
+ expectedContent: expected,
319
+ currentContent: current,
320
+ };
321
+ });
322
+ }
323
+
324
+ /**
325
+ * Apply planned actions to the filesystem.
326
+ *
327
+ * - 'create' / 'overwrite' → mkdirSync (recursive) + writeFileSync + chmodSync
328
+ * - 'skip-equal' / 'blocked-edited' / 'diff' → no-op (no FS write)
329
+ *
330
+ * No-op entirely when opts.dryRun is true. Non-atomic — partial-write
331
+ * failures self-heal on the next run (see spec.md "Atomicity note").
332
+ *
333
+ * chmod is best-effort: wrapped in try/catch for EPERM/ENOTSUP/ENOSYS.
334
+ * On chmod failure we emit a warn line via opts.writer.stderr and
335
+ * continue with exit 0 — the file write is the primary outcome and the
336
+ * x-bit is recoverable manually with `chmod +x <path>`.
337
+ *
338
+ * @param {{repoRoot:string, templatesDir:string, dryRun?:boolean, fs?:object, writer?:object}} opts
339
+ * @param {Array} actions output of planActions
340
+ */
341
+ export function applyActions(opts, actions) {
342
+ if (opts.dryRun) return;
343
+
344
+ const fs = opts.fs ?? DEFAULT_FS;
345
+ const writer = opts.writer ?? DEFAULT_WRITER;
346
+
347
+ for (const action of actions) {
348
+ if (action.action !== 'create' && action.action !== 'overwrite') continue;
349
+
350
+ fs.mkdirSync(dirname(action.dest), { recursive: true });
351
+ fs.writeFileSync(action.dest, action.expectedContent);
352
+
353
+ if (action.template.mode) {
354
+ try {
355
+ fs.chmodSync(action.dest, parseInt(action.template.mode, 8));
356
+ } catch (err) {
357
+ writer.stderr(
358
+ `init-repo: WARN: failed to chmod ${action.dest}: ${err.code ?? err.message}. ` +
359
+ `File written but executable bit may be missing — set manually with chmod +x ${action.dest}.\n`,
360
+ );
361
+ }
362
+ }
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Run `git check-ignore` against every scaffolded file and return the list
368
+ * of paths still ignored after applyActions wrote the managed `.gitignore`
369
+ * block. Surfaces pathological gitignore patterns we can't override (most
370
+ * notably dir-only rules like `.codex/` — git refuses to look inside an
371
+ * excluded directory regardless of subsequent `!` rules).
372
+ *
373
+ * Skips:
374
+ * - managed-block kind (`.gitignore` itself isn't normally tracked)
375
+ * - blocked-edited / diff actions (file content is user's, not ours)
376
+ *
377
+ * @param {{repoRoot: string, checkIgnore?: (root: string, p: string) => boolean}} opts
378
+ * @param {Array} actions
379
+ * @returns {string[]} list of dest paths still ignored
380
+ */
381
+ function verifyIgnoreStatus(opts, actions) {
382
+ const checkIgnore = opts.checkIgnore ?? DEFAULT_CHECK_IGNORE;
383
+ const stillIgnored = [];
384
+
385
+ for (const action of actions) {
386
+ if (action.template.kind === 'managed-block') continue;
387
+ if (action.action === 'blocked-edited' || action.action === 'diff') continue;
388
+
389
+ if (checkIgnore(opts.repoRoot, action.template.dest)) {
390
+ stillIgnored.push(action.template.dest);
391
+ }
392
+ }
393
+ return stillIgnored;
394
+ }
395
+
396
+ /**
397
+ * Build a stderr WARNING block for paths still ignored after applyActions.
398
+ *
399
+ * Most common cause: dir-only patterns (`.codex/`, `.claude/`) — git docs
400
+ * explicitly forbid re-include of files under an excluded directory. The
401
+ * fix is user-side, so we surface the exact paths and the change they need.
402
+ */
403
+ function buildIgnoreWarning(paths) {
404
+ return [
405
+ '',
406
+ `init-repo: WARN: ${paths.length} scaffolded file${paths.length === 1 ? '' : 's'} still git-ignored:`,
407
+ ...paths.map((p) => ` - ${p}`),
408
+ '',
409
+ 'Your .gitignore has rules that block our managed re-include — most likely',
410
+ 'dir-only patterns (e.g. `.codex/`, `.claude/`, `.cursor/`). Per gitignore',
411
+ 'docs, files cannot be re-included once a parent directory is excluded.',
412
+ '',
413
+ 'Fix: change those rules to use a trailing `*` so they ignore CONTENTS rather',
414
+ 'than the directory itself, then re-run `aw init-repo`. Examples:',
415
+ ' .codex/ → .codex/*',
416
+ ' .claude/ → .claude/*',
417
+ ' .cursor/ → .cursor/*',
418
+ '',
419
+ ].join('\n');
420
+ }
421
+
422
+ /**
423
+ * Format a single action line for the summary block.
424
+ *
425
+ * The 18-char tag width keeps `[blocked-edited]` aligned with shorter tags.
426
+ */
427
+ function formatActionLine(action) {
428
+ const tag = `[${action.action}]`.padEnd(18, ' ');
429
+ return ` ${tag} ${action.template.dest}`;
430
+ }
431
+
432
+ /**
433
+ * Build the multi-line summary printed to stdout.
434
+ *
435
+ * Distinguishes dry-run vs apply, reports counts per action kind, and
436
+ * surfaces blocked-edited remediation hints inline so users don't have
437
+ * to dig through docs.
438
+ */
439
+ function buildSummary({ repoRoot, dryRun, actions, blockedCount, exitCode }) {
440
+ const head = dryRun
441
+ ? `dry-run — would scaffold ${actions.length} files into ${repoRoot}`
442
+ : `Scaffolding ${actions.length} files into ${repoRoot}`;
443
+
444
+ const lines = [`init-repo: ${head}`, ...actions.map(formatActionLine), ''];
445
+
446
+ if (blockedCount > 0 && exitCode === 1) {
447
+ lines.push(
448
+ `${blockedCount} file${blockedCount === 1 ? '' : 's'} blocked ` +
449
+ `(locally edited; pass --force to overwrite or --diff to inspect).`,
450
+ );
451
+ }
452
+
453
+ if (dryRun) {
454
+ lines.push(`Run without --dry-run to write. exit ${exitCode}.`);
455
+ } else if (exitCode === 0) {
456
+ lines.push(`Done. exit 0.`);
457
+ } else {
458
+ lines.push(`exit ${exitCode}.`);
459
+ }
460
+
461
+ return lines.join('\n');
462
+ }
463
+
464
+ /**
465
+ * Public orchestrator. Returns a {exitCode, summary, actions} record;
466
+ * the command-handler translates exitCode !== 0 to a CancelError.
467
+ *
468
+ * Validates inputs (git repo guard, --namespace rejection), composes
469
+ * loadManifest → planActions → applyActions, prints the summary via
470
+ * opts.writer, and never throws on user errors — every failure is
471
+ * encoded in exitCode + summary.
472
+ *
473
+ * @param {{
474
+ * repoRoot: string,
475
+ * templatesDir?: string,
476
+ * namespace?: string,
477
+ * force?: boolean,
478
+ * diff?: boolean,
479
+ * dryRun?: boolean,
480
+ * noGitignore?: boolean,
481
+ * checkIgnore?: (root: string, p: string) => boolean,
482
+ * fs?: object,
483
+ * writer?: object,
484
+ * }} opts
485
+ * @returns {{ exitCode: number, summary: string, actions: Array }}
486
+ */
487
+ export function initRepo(opts) {
488
+ const writer = opts.writer ?? DEFAULT_WRITER;
489
+ const fs = opts.fs ?? DEFAULT_FS;
490
+ const templatesDir = opts.templatesDir ?? DEFAULT_TEMPLATES_DIR;
491
+ const repoRoot = opts.repoRoot;
492
+
493
+ // Guard: --namespace not supported in v1.
494
+ if (opts.namespace) {
495
+ const summary =
496
+ `init-repo: --namespace is not supported in v1. ` +
497
+ `init-repo only scaffolds the 4 cloud-bootstrap files. ` +
498
+ `Use 'aw init --namespace <team>' for namespace pulls.`;
499
+ writer.stderr(summary + '\n');
500
+ return { exitCode: 2, summary, actions: [] };
501
+ }
502
+
503
+ // Guard: repoRoot must be a git repo (.git can be a directory or a file
504
+ // — git worktrees use a file at .git that points to the worktree dir).
505
+ if (!fs.existsSync(join(repoRoot, '.git'))) {
506
+ const summary =
507
+ `init-repo: ${repoRoot} is not a git repository (no .git found). ` +
508
+ `Run 'git init' first or pass a path to an existing repo.`;
509
+ writer.stderr(summary + '\n');
510
+ return { exitCode: 2, summary, actions: [] };
511
+ }
512
+
513
+ // Load + plan + apply.
514
+ let manifest;
515
+ try {
516
+ manifest = loadManifest(templatesDir);
517
+ } catch (err) {
518
+ writer.stderr(err.message + '\n');
519
+ return { exitCode: 2, summary: err.message, actions: [] };
520
+ }
521
+
522
+ // --no-gitignore opt-out: drop every managed-block template before planning.
523
+ // Users who manage their .gitignore by hand can pass this flag to keep
524
+ // init-repo from injecting the cloud-bootstrap re-include block.
525
+ const filteredManifest = opts.noGitignore
526
+ ? { ...manifest, templates: manifest.templates.filter(t => t.kind !== 'managed-block') }
527
+ : manifest;
528
+
529
+ const actions = planActions(
530
+ { repoRoot, templatesDir, force: opts.force, diff: opts.diff, fs },
531
+ filteredManifest,
532
+ );
533
+
534
+ applyActions(
535
+ { repoRoot, templatesDir, dryRun: opts.dryRun, fs, writer },
536
+ actions,
537
+ );
538
+
539
+ const blockedCount = actions.filter(a => a.action === 'blocked-edited').length;
540
+ const exitCode = blockedCount > 0 ? 1 : 0;
541
+
542
+ const summary = buildSummary({
543
+ repoRoot,
544
+ dryRun: !!opts.dryRun,
545
+ actions,
546
+ blockedCount,
547
+ exitCode,
548
+ });
549
+
550
+ writer.stdout(summary + '\n');
551
+
552
+ // Post-apply: verify that scaffolded paths are actually trackable by git.
553
+ // Emitted AFTER the summary so the action list always renders first
554
+ // regardless of stdout/stderr buffering. Skip on dryRun — nothing was
555
+ // written, so check-ignore would probe stale state and emit noise.
556
+ if (!opts.dryRun) {
557
+ const stillIgnored = verifyIgnoreStatus(
558
+ { repoRoot, checkIgnore: opts.checkIgnore },
559
+ actions,
560
+ );
561
+ if (stillIgnored.length > 0) {
562
+ writer.stderr(buildIgnoreWarning(stillIgnored) + '\n');
563
+ }
564
+ }
565
+
566
+ return { exitCode, summary, actions };
567
+ }
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ # .claude/scripts/claude-web-bootstrap.sh — Claude Code Web entry shim.
3
+ #
4
+ # Claude Code Web's external Startup Command is configured to run this file.
5
+ # Path is preserved so existing CCW UI configs keep working; logic is
6
+ # delegated to scripts/aw-c4-bootstrap.sh.
7
+ set -Eeuo pipefail
8
+ exec bash "$(dirname "$0")/../../scripts/aw-c4-bootstrap.sh" --harness claude-web "$@"
@@ -0,0 +1,5 @@
1
+ [features]
2
+ codex_hooks = true
3
+
4
+ [projects."/workspace/{{REPO_BASENAME}}"]
5
+ trust_level = "trusted"
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ # .codex/scripts/codex-web-bootstrap.sh — Codex Web entry shim.
3
+ #
4
+ # Codex Web's external setup script is configured to run this file. Path is
5
+ # preserved so existing Codex UI configs keep working; logic is delegated to
6
+ # scripts/aw-c4-bootstrap.sh.
7
+ set -Eeuo pipefail
8
+ exec bash "$(dirname "$0")/../../scripts/aw-c4-bootstrap.sh" --harness codex-web "$@"
@@ -0,0 +1,5 @@
1
+ {
2
+ "$schema": "https://cursor.com/schemas/environment.json",
3
+ "install": "bash scripts/aw-c4-bootstrap.sh --harness cursor-cloud",
4
+ "terminals": []
5
+ }
@@ -0,0 +1,14 @@
1
+ # Cloud bootstrap files — re-include for tracking even when parent dirs are
2
+ # ignored (e.g. .codex/*, .claude/*, .cursor/*). Order matters: a file under
3
+ # an excluded directory cannot be un-ignored without first un-ignoring the
4
+ # directory. We only un-ignore parent paths and whitelist AW-managed files —
5
+ # we do NOT re-ignore other content inside these dirs, so users' own scripts
6
+ # stay trackable. Hook-logs and other ephemeral state are managed elsewhere
7
+ # (see `aw init`'s repoLocalIgnore manager).
8
+ !scripts/aw-c4-bootstrap.sh
9
+ !.codex/config.toml
10
+ !.cursor/environment.json
11
+ !.codex/scripts/
12
+ !.codex/scripts/codex-web-bootstrap.sh
13
+ !.claude/scripts/
14
+ !.claude/scripts/claude-web-bootstrap.sh
@@ -0,0 +1,41 @@
1
+ {
2
+ "version": 1,
3
+ "templates": [
4
+ {
5
+ "kind": "literal",
6
+ "src": "scripts/aw-c4-bootstrap.sh",
7
+ "dest": "scripts/aw-c4-bootstrap.sh",
8
+ "mode": "0755"
9
+ },
10
+ {
11
+ "kind": "literal",
12
+ "src": "codex/scripts/codex-web-bootstrap.sh",
13
+ "dest": ".codex/scripts/codex-web-bootstrap.sh",
14
+ "mode": "0755"
15
+ },
16
+ {
17
+ "kind": "literal",
18
+ "src": "claude/scripts/claude-web-bootstrap.sh",
19
+ "dest": ".claude/scripts/claude-web-bootstrap.sh",
20
+ "mode": "0755"
21
+ },
22
+ {
23
+ "kind": "literal",
24
+ "src": "cursor/environment.json",
25
+ "dest": ".cursor/environment.json",
26
+ "mode": "0644"
27
+ },
28
+ {
29
+ "kind": "interpolated",
30
+ "src": "codex/config.toml",
31
+ "dest": ".codex/config.toml",
32
+ "mode": "0644"
33
+ },
34
+ {
35
+ "kind": "managed-block",
36
+ "src": "gitignore-block.txt",
37
+ "dest": ".gitignore",
38
+ "marker": "cloud-bootstrap"
39
+ }
40
+ ]
41
+ }
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env bash
2
+ # scripts/aw-c4-bootstrap.sh — single-entry cloud bootstrap for AW.
3
+ #
4
+ # Installs @ghl-ai/aw and runs `aw c4`, which handles ALL harness-specific
5
+ # wiring (slim router card, hooks, MCP server, repo-root context, ECC bridge,
6
+ # Codex prompt-injector, GitHub auth). Forward any extra args to `aw c4` so
7
+ # `--dry-run` and `--diagnose` flow through untouched.
8
+ #
9
+ # Invoked from:
10
+ # - .cursor/environment.json::install (passes --harness cursor-cloud)
11
+ # - .claude/scripts/claude-web-bootstrap.sh (passes --harness claude-web)
12
+ # - .codex/scripts/codex-web-bootstrap.sh (passes --harness codex-web)
13
+ #
14
+ # Override knobs (env):
15
+ # AW_PACKAGE npm spec to install. Defaults to @ghl-ai/aw@latest. Override
16
+ # with @ghl-ai/aw@beta to opt into pre-release builds, or pin to
17
+ # @ghl-ai/aw@0.1.x for reproducible CI runs.
18
+ set -Eeuo pipefail
19
+
20
+ : "${GITHUB_PAT:?ERROR: Set GITHUB_PAT in your harness secrets UI before running aw c4}"
21
+
22
+ # Ensure npm is on PATH. Cursor Cloud's install shell is non-interactive — nvm
23
+ # is not auto-sourced, and Node may not be pre-installed at all. Walk common
24
+ # locations first, then fall back to a NodeSource apt install (Ubuntu base).
25
+ #
26
+ # Hardening (vs the original):
27
+ # - Search nvm in multiple home dirs (current user, root, ubuntu).
28
+ # - Probe pre-installed Node binaries off the default PATH before falling
29
+ # through to apt — much faster when the agent snapshot ships Node in
30
+ # /usr/local/ or /opt/.
31
+ # - DEBIAN_FRONTEND=noninteractive prevents debconf prompts from hanging
32
+ # the apt configure step on a non-TTY shell.
33
+ # - Drop the >/dev/null on apt + NodeSource setup so progress is visible.
34
+ # The previous "silent + interactive" combination produced the canonical
35
+ # "looks frozen" symptom in pilot use after Cursor Cloud stopped shipping
36
+ # Node in their base image.
37
+ # - timeout 180 is the hard ceiling — fail fast with a clear error instead
38
+ # of hanging the whole agent boot.
39
+ ensure_npm() {
40
+ command -v npm >/dev/null 2>&1 && return 0
41
+
42
+ for nvm_dir in "${NVM_DIR:-}" "$HOME/.nvm" /root/.nvm /home/ubuntu/.nvm; do
43
+ [ -z "$nvm_dir" ] && continue
44
+ if [ -s "$nvm_dir/nvm.sh" ]; then
45
+ export NVM_DIR="$nvm_dir"
46
+ # shellcheck source=/dev/null
47
+ . "$NVM_DIR/nvm.sh"
48
+ nvm use --lts >/dev/null 2>&1 || nvm install --lts >/dev/null 2>&1 || true
49
+ command -v npm >/dev/null 2>&1 && return 0
50
+ fi
51
+ done
52
+
53
+ for node_bin in /usr/local/bin/node /opt/node/bin/node; do
54
+ if [ -x "$node_bin" ]; then
55
+ export PATH="$(dirname "$node_bin"):$PATH"
56
+ command -v npm >/dev/null 2>&1 && return 0
57
+ fi
58
+ done
59
+
60
+ if command -v sudo >/dev/null 2>&1 && command -v apt-get >/dev/null 2>&1; then
61
+ echo "[aw-c4-bootstrap] npm not found; installing Node 20 via NodeSource (1-2 min over corporate proxy)"
62
+ export DEBIAN_FRONTEND=noninteractive
63
+ if ! timeout 180 bash -c 'curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -'; then
64
+ echo "[aw-c4-bootstrap] FATAL: NodeSource setup script timed out or failed after 180s" >&2
65
+ echo "[aw-c4-bootstrap] Preinstall Node 20+ in the agent snapshot, or check proxy reachability." >&2
66
+ exit 1
67
+ fi
68
+ if ! timeout 180 sudo -E apt-get install -y nodejs; then
69
+ echo "[aw-c4-bootstrap] FATAL: apt-get install nodejs timed out or failed after 180s" >&2
70
+ exit 1
71
+ fi
72
+ fi
73
+
74
+ command -v npm >/dev/null 2>&1 || {
75
+ echo "[aw-c4-bootstrap] FATAL: npm not available; preinstall Node 20+ in the harness snapshot" >&2
76
+ exit 1
77
+ }
78
+ }
79
+
80
+ ensure_npm
81
+
82
+ # Codex Cloud (and other corporate-MITM cloud agents) intercept HTTPS via an
83
+ # Envoy-style proxy with a private CA at /usr/local/share/ca-certificates/.
84
+ # curl/git/npm read the system CA bundle and Just Work, but Node uses its
85
+ # own bundle. Bridging requires NODE_EXTRA_CA_CERTS pointed at the MITM CA;
86
+ # without it, `aw c4`'s preflight `fetch()` calls fail with TLS errors even
87
+ # when curl + git ls-remote succeed against the same hosts.
88
+ ENVOY_CA="/usr/local/share/ca-certificates/envoy-mitmproxy-ca-cert.crt"
89
+ if [ -f "$ENVOY_CA" ] && [ -z "${NODE_EXTRA_CA_CERTS:-}" ]; then
90
+ export NODE_EXTRA_CA_CERTS="$ENVOY_CA"
91
+ echo "[aw-c4-bootstrap] enabled NODE_EXTRA_CA_CERTS=$ENVOY_CA"
92
+ fi
93
+
94
+ AW_PACKAGE="${AW_PACKAGE:-@ghl-ai/aw@latest}"
95
+
96
+ echo "[aw-c4-bootstrap] installing ${AW_PACKAGE}"
97
+ npm install -g "${AW_PACKAGE}"
98
+
99
+ echo "[aw-c4-bootstrap] running: aw c4 $*"
100
+ exec aw c4 "$@"
package/cli.mjs CHANGED
@@ -28,6 +28,7 @@ const COMMANDS = {
28
28
  telemetry: () => import('./commands/telemetry.mjs').then(m => m.telemetryCommand),
29
29
  'slack-sim': () => import('./commands/slack-sim.mjs').then(m => m.slackSimCommand),
30
30
  c4: () => import('./commands/c4.mjs').then(m => m.c4Command),
31
+ 'init-repo': () => import('./commands/init-repo.mjs').then(m => m.initRepoCommand),
31
32
  };
32
33
 
33
34
  function parseArgs(argv) {
@@ -85,6 +86,7 @@ function printHelp() {
85
86
  cmd('aw init --namespace <team/sub-team>', 'Add a team namespace (optional)'),
86
87
  ` ${chalk.dim('Teams: platform, revex, mobile, commerce, leadgen, crm, marketplace, ai')}`,
87
88
  ` ${chalk.dim('Example: aw init --namespace revex/courses')}`,
89
+ cmd('aw init-repo', 'Scaffold cloud-bootstrap files (idempotent, --dry-run/--force/--diff)'),
88
90
 
89
91
  sec('Download'),
90
92
  cmd('aw pull', 'Re-pull all synced paths (like git pull)'),
@@ -0,0 +1,94 @@
1
+ /**
2
+ * commands/init-repo.mjs — thin command-handler for `aw init-repo`.
3
+ *
4
+ * Translates the cli.mjs-parsed args object into engine options, calls
5
+ * c4/initRepo.mjs::initRepo(), and converts non-zero exit codes into
6
+ * CancelError throws so cli.mjs::run() preserves the telemetry-end and
7
+ * update-notify lifecycle (see G36 in spec.md::"Failure modes").
8
+ *
9
+ * Contract: spec.md::"libs/aw/commands/init-repo.mjs (command handler)"
10
+ */
11
+
12
+ import { initRepo as defaultInitRepo } from '../c4/initRepo.mjs';
13
+ import { CancelError } from '../fmt.mjs';
14
+
15
+ const HELP = `
16
+ aw init-repo — scaffold cloud-bootstrap files into the current repo
17
+
18
+ Usage:
19
+ aw init-repo Scaffold 6 files (idempotent: skip-equal on byte match)
20
+ aw init-repo --dry-run Preview actions without writing
21
+ aw init-repo --diff Show diffs for locally-edited files (no write)
22
+ aw init-repo --force Overwrite locally-edited files
23
+ aw init-repo --no-gitignore Skip the .gitignore managed block (manage it by hand)
24
+
25
+ Files written:
26
+ scripts/aw-c4-bootstrap.sh (unified bootstrap, executable)
27
+ .codex/scripts/codex-web-bootstrap.sh (executable)
28
+ .codex/config.toml (codex hooks + per-repo trust)
29
+ .claude/scripts/claude-web-bootstrap.sh (executable)
30
+ .cursor/environment.json (cursor cloud config)
31
+ .gitignore (managed block — re-include of the 5 above)
32
+
33
+ Exit codes:
34
+ 0 All files created or already in sync
35
+ 1 At least one file was locally edited (no write — pass --force or --diff)
36
+ 2 Bad input (not a git repo, --namespace passed, missing templates)
37
+
38
+ Notes:
39
+ - Idempotent. SHA-256 byte-compare per file. Re-running on a clean repo is a no-op.
40
+ - --namespace is NOT supported (use 'aw init --namespace <team>' for namespace pulls).
41
+ - .cursor/environment.json is a literal template; manually-added terminals or
42
+ run-on-build entries will be overwritten by --force. Use --diff first.
43
+ - .codex/config.toml has {{REPO_BASENAME}} substituted at scaffold time so the
44
+ [projects."/workspace/<repo>"] trust block matches Codex Cloud's checkout path.
45
+ - .gitignore: a marker-bracketed block re-includes the 5 cloud-bootstrap files
46
+ even when parent dirs are ignored. User content outside the markers is preserved.
47
+ A WARN with paste-ready fix is printed if pathological dir-only rules
48
+ (e.g. \`.codex/\`, \`.claude/\`) prevent re-include — change those to
49
+ \`.codex/*\` / \`.claude/*\` and re-run.
50
+ `.trim();
51
+
52
+ function printHelp() {
53
+ console.log(HELP);
54
+ }
55
+
56
+ /**
57
+ * Command-handler entry. Resolves on success, throws CancelError on
58
+ * non-zero engine exit. Never calls process.exit — that is cli.mjs's job.
59
+ *
60
+ * @param {object} args parsed flags from cli.mjs::parseArgs
61
+ * @param {{ initRepo?: Function, cwd?: () => string }} [deps] testability injection
62
+ */
63
+ export async function initRepoCommand(args, deps = {}) {
64
+ const _initRepo = deps.initRepo ?? defaultInitRepo;
65
+ const _cwd = deps.cwd ?? (() => process.cwd());
66
+
67
+ if (args['--help']) {
68
+ printHelp();
69
+ return;
70
+ }
71
+
72
+ // v1: --namespace is not part of init-repo. Reject early so users don't
73
+ // assume init-repo will fan out a registry pull on top of scaffolding.
74
+ if (args['--namespace']) {
75
+ throw new CancelError(
76
+ `aw init-repo: --namespace is not supported. ` +
77
+ `init-repo only scaffolds the 4 cloud-bootstrap files. ` +
78
+ `Use 'aw init --namespace <team>' for namespace pulls.`,
79
+ { exitCode: 2 },
80
+ );
81
+ }
82
+
83
+ const result = _initRepo({
84
+ repoRoot: _cwd(),
85
+ dryRun: !!args['--dry-run'],
86
+ force: !!args['--force'],
87
+ diff: !!args['--diff'],
88
+ noGitignore: !!args['--no-gitignore'],
89
+ });
90
+
91
+ if (result.exitCode !== 0) {
92
+ throw new CancelError(result.summary, { exitCode: result.exitCode });
93
+ }
94
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghl-ai/aw",
3
- "version": "0.1.44-beta.8",
3
+ "version": "0.1.44",
4
4
  "description": "Agentic Workspace CLI — pull, push & manage agents, skills and commands from the registry",
5
5
  "type": "module",
6
6
  "bin": {