@ghl-ai/aw 0.1.44-beta.10 → 0.1.44-beta.12

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/initRepo.mjs CHANGED
@@ -23,9 +23,127 @@ import {
23
23
  chmodSync,
24
24
  statSync,
25
25
  } from 'node:fs';
26
- import { join, dirname } from 'node:path';
26
+ import { join, dirname, basename, resolve } from 'node:path';
27
27
  import { fileURLToPath } from 'node:url';
28
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
+ }
29
147
 
30
148
  /** Default fs surface — node:fs. Tests inject mocks for failure paths. */
31
149
  const DEFAULT_FS = {
@@ -88,19 +206,40 @@ export function loadManifest(templatesDir) {
88
206
  }
89
207
 
90
208
  /**
91
- * Read the bundled template content for a single manifest entry.
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).
92
218
  *
93
- * Pure read — no FS writes. Pulled out of planActions so tests can
94
- * exercise it directly and so the action object can carry both
95
- * expected and current content for downstream diff/overwrite decisions.
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.
96
222
  *
97
- * @param {{src: string}} template manifest entry
98
- * @param {{templatesDir: string}} opts engine options
99
- * @returns {string} bundled template content (UTF-8)
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)
100
226
  */
101
227
  export function expectedContent(template, opts) {
102
- const srcPath = join(opts.templatesDir, template.src);
103
- return readFileSync(srcPath, 'utf8');
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
+ );
104
243
  }
105
244
 
106
245
  /**
@@ -159,7 +298,12 @@ export function planActions(opts, manifest) {
159
298
  }
160
299
 
161
300
  let action;
162
- if (opts.diff) {
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) {
163
307
  action = 'diff';
164
308
  } else if (opts.force) {
165
309
  action = 'overwrite';
@@ -206,17 +350,75 @@ export function applyActions(opts, actions) {
206
350
  fs.mkdirSync(dirname(action.dest), { recursive: true });
207
351
  fs.writeFileSync(action.dest, action.expectedContent);
208
352
 
209
- try {
210
- fs.chmodSync(action.dest, parseInt(action.template.mode, 8));
211
- } catch (err) {
212
- writer.stderr(
213
- `init-repo: WARN: failed to chmod ${action.dest}: ${err.code ?? err.message}. ` +
214
- `File written but executable bit may be missing — set manually with chmod +x ${action.dest}.\n`,
215
- );
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
+ }
216
362
  }
217
363
  }
218
364
  }
219
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
+
220
422
  /**
221
423
  * Format a single action line for the summary block.
222
424
  *
@@ -275,6 +477,8 @@ function buildSummary({ repoRoot, dryRun, actions, blockedCount, exitCode }) {
275
477
  * force?: boolean,
276
478
  * diff?: boolean,
277
479
  * dryRun?: boolean,
480
+ * noGitignore?: boolean,
481
+ * checkIgnore?: (root: string, p: string) => boolean,
278
482
  * fs?: object,
279
483
  * writer?: object,
280
484
  * }} opts
@@ -315,9 +519,16 @@ export function initRepo(opts) {
315
519
  return { exitCode: 2, summary: err.message, actions: [] };
316
520
  }
317
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
+
318
529
  const actions = planActions(
319
530
  { repoRoot, templatesDir, force: opts.force, diff: opts.diff, fs },
320
- manifest,
531
+ filteredManifest,
321
532
  );
322
533
 
323
534
  applyActions(
@@ -338,5 +549,19 @@ export function initRepo(opts) {
338
549
 
339
550
  writer.stdout(summary + '\n');
340
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
+
341
566
  return { exitCode, summary, actions };
342
567
  }
@@ -0,0 +1,5 @@
1
+ [features]
2
+ codex_hooks = true
3
+
4
+ [projects."/workspace/{{REPO_BASENAME}}"]
5
+ trust_level = "trusted"
@@ -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
@@ -24,6 +24,18 @@
24
24
  "src": "cursor/environment.json",
25
25
  "dest": ".cursor/environment.json",
26
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"
27
39
  }
28
40
  ]
29
41
  }
@@ -16,16 +16,19 @@ const HELP = `
16
16
  aw init-repo — scaffold cloud-bootstrap files into the current repo
17
17
 
18
18
  Usage:
19
- aw init-repo Scaffold 4 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
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)
23
24
 
24
25
  Files written:
25
- scripts/aw-c4-bootstrap.sh (4.4 KB, executable)
26
+ scripts/aw-c4-bootstrap.sh (unified bootstrap, executable)
26
27
  .codex/scripts/codex-web-bootstrap.sh (executable)
28
+ .codex/config.toml (codex hooks + per-repo trust)
27
29
  .claude/scripts/claude-web-bootstrap.sh (executable)
28
30
  .cursor/environment.json (cursor cloud config)
31
+ .gitignore (managed block — re-include of the 5 above)
29
32
 
30
33
  Exit codes:
31
34
  0 All files created or already in sync
@@ -35,8 +38,15 @@ Exit codes:
35
38
  Notes:
36
39
  - Idempotent. SHA-256 byte-compare per file. Re-running on a clean repo is a no-op.
37
40
  - --namespace is NOT supported (use 'aw init --namespace <team>' for namespace pulls).
38
- - .cursor/environment.json is treated as a literal template; manually-added terminals
39
- or run-on-build entries will be overwritten by --force. Use --diff first.
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.
40
50
  `.trim();
41
51
 
42
52
  function printHelp() {
@@ -75,6 +85,7 @@ export async function initRepoCommand(args, deps = {}) {
75
85
  dryRun: !!args['--dry-run'],
76
86
  force: !!args['--force'],
77
87
  diff: !!args['--diff'],
88
+ noGitignore: !!args['--no-gitignore'],
78
89
  });
79
90
 
80
91
  if (result.exitCode !== 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghl-ai/aw",
3
- "version": "0.1.44-beta.10",
3
+ "version": "0.1.44-beta.12",
4
4
  "description": "Agentic Workspace CLI — pull, push & manage agents, skills and commands from the registry",
5
5
  "type": "module",
6
6
  "bin": {