@ghl-ai/aw 0.1.80 → 0.1.82

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
@@ -63,6 +63,9 @@ function parseArgs(argv) {
63
63
  } else if (arg === '-y') {
64
64
  args['-y'] = true;
65
65
  i++;
66
+ } else if (arg === '--yes' || arg === '--non-interactive' || arg === '--headless') {
67
+ args[arg] = true;
68
+ i++;
66
69
  } else if (arg === '--version') {
67
70
  args['--version'] = true;
68
71
  i++;
@@ -154,6 +157,7 @@ function printHelp() {
154
157
  sec('Upload'),
155
158
  cmd('aw push', 'Push all modified files (creates one PR)'),
156
159
  cmd('aw push --aw-docs-only --feature <slug>', 'Publish one .aw_docs feature folder and print share links'),
160
+ cmd('aw push --aw-docs-only --yes', 'Publish in CI/headless mode (no TTY spinner; auto when CI=1)'),
157
161
  cmd('aw push --aw-docs-only --all', 'Publish ALL .aw_docs feature folders (explicit opt-in; otherwise scope with --feature)'),
158
162
  cmd('aw push <path>', 'Push file, folder, or namespace to registry'),
159
163
  cmd('aw push-rules [path]', 'Push platform rules to platform-docs'),
package/commands/push.mjs CHANGED
@@ -23,7 +23,7 @@ import { createHash } from 'node:crypto';
23
23
  const exec = promisify(execCb);
24
24
  const execFile = promisify(execFileCb);
25
25
  import * as fmt from '../fmt.mjs';
26
- import { chalk } from '../fmt.mjs';
26
+ import { chalk, configureInteractionMode } from '../fmt.mjs';
27
27
  import {
28
28
  REGISTRY_REPO,
29
29
  REGISTRY_URL,
@@ -158,18 +158,64 @@ function normalizeRelPath(value) {
158
158
  // and full nested folders. Use for shared, repo-keyed doc trees such as PR
159
159
  // reviews (pr-reviews/<owner>/<repo>/pr-<n>/<run>) so they do not land under the
160
160
  // reviewer's workspace namespace as one mangled flat slug.
161
- const AW_DOCS_ROOT_NAMESPACES = ['pr-reviews'];
161
+ const AW_DOCS_ROOT_NAMESPACES = ['pr-reviews', 'security-audits'];
162
+
163
+ // Compound root prefixes (multi-segment). Longest match wins in matchRootPrefix().
164
+ const AW_DOCS_ROOT_PREFIX_RULES = [
165
+ { prefix: 'security/audit', minSegments: 4 },
166
+ { prefix: 'pr-reviews', minSegments: 4 },
167
+ { prefix: 'security-audits', minSegments: 3 },
168
+ ];
169
+
170
+ // Minimum nested depth per root namespace. A root scope's delete-then-copy
171
+ // target is the resolved subtree, so a shallow path (e.g. `pr-reviews` or
172
+ // `security-audits/<repo>`) must be rejected — otherwise it could resolve to a
173
+ // broad shared subtree and wipe unrelated runs on the remote.
174
+ // pr-reviews/<repo>/pr-<number>/<run> → 4 segments
175
+ // security-audits/<repo>/<date> → 3 segments
176
+ // security/audit/<repo>/<run-id> → 4 segments
177
+ const AW_DOCS_ROOT_MIN_SEGMENTS = {
178
+ 'pr-reviews': 4,
179
+ 'security-audits': 3,
180
+ };
181
+
182
+ function normalizeAwDocsRootInput(stripped) {
183
+ if (stripped.startsWith('security/audit/')) {
184
+ return stripped.replace(/^security\/audit\//, 'security-audits/');
185
+ }
186
+ return stripped;
187
+ }
188
+
189
+ function ensureHeadlessGitEnv() {
190
+ if (!fmt.isHeadless()) return;
191
+ if (process.env.GIT_TERMINAL_PROMPT === undefined) process.env.GIT_TERMINAL_PROMPT = '0';
192
+ if (process.env.GCM_INTERACTIVE === undefined) process.env.GCM_INTERACTIVE = 'never';
193
+ }
194
+
195
+ function matchRootPrefix(stripped) {
196
+ const value = normalizeRelPath(stripped);
197
+ const rules = [...AW_DOCS_ROOT_PREFIX_RULES].sort((a, b) => b.prefix.length - a.prefix.length);
198
+ for (const rule of rules) {
199
+ if (value === rule.prefix || value.startsWith(`${rule.prefix}/`)) {
200
+ return rule;
201
+ }
202
+ }
203
+ const first = value.split('/')[0];
204
+ if (AW_DOCS_ROOT_NAMESPACES.includes(first)) {
205
+ return { prefix: first, minSegments: AW_DOCS_ROOT_MIN_SEGMENTS[first] };
206
+ }
207
+ return null;
208
+ }
162
209
 
163
210
  function awDocsRootScope(relPath) {
164
211
  const value = normalizeRelPath(relPath);
165
212
  const segments = value.split('/');
166
- // A root scope's delete-then-copy target is the resolved subtree. Require the
167
- // full pr-reviews/<repo>/pr-<number>/<run> depth so a shallow path (e.g.
168
- // `pr-reviews` or `pr-reviews/<repo>`) can never resolve to a broad shared
169
- // subtree and wipe unrelated review runs on the remote.
170
- if (segments[0] !== 'pr-reviews' || segments.length < 4) {
213
+ const rule = matchRootPrefix(value);
214
+ if (!rule || segments.length < rule.minSegments) {
171
215
  throw new Error(
172
- `Root-scope docs publish must target pr-reviews/<repo>/pr-<number>/<run>, got "${relPath}".`
216
+ 'Root-scope docs publish must target pr-reviews/<repo>/pr-<number>/<run>, ' +
217
+ 'security-audits/<repo>/<date>, or security/audit/<repo>/<run-id>, ' +
218
+ `got "${relPath}".`
173
219
  );
174
220
  }
175
221
  for (const seg of segments) {
@@ -184,14 +230,14 @@ function featureScopeFromInput(input) {
184
230
  const value = normalizeRelPath(input);
185
231
  if (!value) return null;
186
232
 
187
- const stripped = value.replace(/^\.aw_docs\//, '');
188
- if (AW_DOCS_ROOT_NAMESPACES.includes(stripped.split('/')[0])) {
233
+ const stripped = normalizeAwDocsRootInput(value.replace(/^\.aw_docs\//, ''));
234
+ if (matchRootPrefix(stripped)) {
189
235
  return awDocsRootScope(stripped);
190
236
  }
191
237
 
192
238
  const match = value.match(/^(?:\.aw_docs\/)?features\/([^/]+)$/);
193
239
  if (!match) {
194
- throw new Error('Docs-only publish path must be .aw_docs/features/<feature-slug>, .aw_docs/pr-reviews/<...>, or use --feature <feature-slug>.');
240
+ throw new Error('Docs-only publish path must be .aw_docs/features/<feature-slug>, .aw_docs/pr-reviews/<...>, .aw_docs/security-audits/<...> (or legacy .aw_docs/security/audit/<...>), or use --feature <feature-slug>.');
195
241
  }
196
242
  return awDocsFeatureScope(match[1]);
197
243
  }
@@ -526,32 +572,91 @@ async function getProjectSourceRepo(projectRoot) {
526
572
  return `local/${basename(projectRoot)}`;
527
573
  }
528
574
 
529
- async function ensureAwDocsRepoClone(home, publishConfig) {
530
- const cloneDir = process.env.AW_DOCS_WORKTREE || join(home, '.aw-ghl-aw-docs');
575
+ // Network git ops against the docs repo always disable interactive credential
576
+ // prompts (so a missing/incorrect credential fails fast instead of hanging
577
+ // forever) and are time-bounded (so a stalled connection aborts instead of
578
+ // blocking the whole publish). Override the caps with AW_DOCS_GIT_TIMEOUT_MS.
579
+ const AW_DOCS_GIT_ENV = { ...process.env, GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never' };
580
+ const AW_DOCS_CLONE_TIMEOUT_MS = Number(process.env.AW_DOCS_GIT_TIMEOUT_MS) || 180000;
581
+ const AW_DOCS_NET_TIMEOUT_MS = Number(process.env.AW_DOCS_GIT_TIMEOUT_MS) || 120000;
531
582
 
532
- if (!existsSync(join(cloneDir, '.git'))) {
533
- await execFile('git', ['clone', publishConfig.repoUrl, cloneDir], {
583
+ async function awDocsSparseEnabled(cloneDir) {
584
+ try {
585
+ const { stdout } = await execFile('git', ['config', '--get', 'core.sparseCheckout'], {
586
+ cwd: cloneDir,
534
587
  encoding: 'utf8',
535
588
  });
589
+ return stdout.trim() === 'true';
590
+ } catch {
591
+ return false;
536
592
  }
593
+ }
537
594
 
538
- let status = await getGitStatus(cloneDir);
539
- if (status.trim() && await selfHealManagedAwCacheDirtiness(cloneDir, status)) {
540
- status = await getGitStatus(cloneDir);
595
+ async function ensureAwDocsRepoClone(home, publishConfig, sparseIncludePaths = []) {
596
+ const cloneDir = process.env.AW_DOCS_WORKTREE || join(home, '.aw-ghl-aw-docs');
597
+ const wantSparse = sparseIncludePaths.length > 0;
598
+ const freshClone = !existsSync(join(cloneDir, '.git'));
599
+
600
+ if (freshClone) {
601
+ // A full clone of ghl-aw-docs pulls the entire org's published docs
602
+ // (hundreds of MB / ~170k objects) and is the dominant cause of
603
+ // first-publish timeouts. Clone blobless (no file contents up front) and,
604
+ // when we know which subtree this publish touches, defer checkout and
605
+ // sparse-checkout only that path — turning hundreds of MB into a few MB.
606
+ // Degrades gracefully: if the remote does not support partial clone git
607
+ // ignores the filter, and if sparse-checkout is unavailable the branch
608
+ // checkout below still materialises the full tree.
609
+ const cloneArgs = ['clone', '--filter=blob:none'];
610
+ if (wantSparse) cloneArgs.push('--no-checkout');
611
+ cloneArgs.push(publishConfig.repoUrl, cloneDir);
612
+ await execFile('git', cloneArgs, {
613
+ encoding: 'utf8',
614
+ env: AW_DOCS_GIT_ENV,
615
+ timeout: AW_DOCS_CLONE_TIMEOUT_MS,
616
+ });
617
+ if (wantSparse) {
618
+ await execFile('git', ['sparse-checkout', 'set', '--cone', ...sparseIncludePaths], {
619
+ cwd: cloneDir,
620
+ encoding: 'utf8',
621
+ }).catch(() => { /* sparse unsupported → full checkout below */ });
622
+ }
541
623
  }
542
- if (status.trim()) {
543
- throw new Error([
544
- `AW docs repo worktree is dirty: ${cloneDir}`,
545
- status.trim(),
546
- 'Clean the cached docs repo or set AW_DOCS_WORKTREE to a clean clone before publishing.',
547
- ].join('\n'));
624
+
625
+ // Only validate cleanliness for a REUSED clone. A fresh blobless
626
+ // --no-checkout clone intentionally has no working tree yet (it is
627
+ // materialised by the branch checkout below), which would otherwise
628
+ // register as "dirty".
629
+ if (!freshClone) {
630
+ let status = await getGitStatus(cloneDir);
631
+ if (status.trim() && await selfHealManagedAwCacheDirtiness(cloneDir, status)) {
632
+ status = await getGitStatus(cloneDir);
633
+ }
634
+ if (status.trim()) {
635
+ throw new Error([
636
+ `AW docs repo worktree is dirty: ${cloneDir}`,
637
+ status.trim(),
638
+ 'Clean the cached docs repo or set AW_DOCS_WORKTREE to a clean clone before publishing.',
639
+ ].join('\n'));
640
+ }
548
641
  }
549
642
 
550
643
  await execFile('git', ['fetch', 'origin'], {
551
644
  cwd: cloneDir,
552
645
  encoding: 'utf8',
646
+ env: AW_DOCS_GIT_ENV,
647
+ timeout: AW_DOCS_NET_TIMEOUT_MS,
553
648
  });
554
649
 
650
+ // Keep the current publish's subtree inside the sparse cone. Only applies to
651
+ // clones that already use sparse-checkout (i.e. the blobless clones we
652
+ // created) — legacy full clones are left untouched so there is no regression.
653
+ if (wantSparse && await awDocsSparseEnabled(cloneDir)) {
654
+ await execFile('git', ['sparse-checkout', 'add', ...sparseIncludePaths], {
655
+ cwd: cloneDir,
656
+ encoding: 'utf8',
657
+ }).catch(() => { /* best effort — checkout below still works */ });
658
+ }
659
+
555
660
  const hasPublishBranch = await remoteBranchExists(cloneDir, publishConfig.branch);
556
661
  const checkoutStart = hasPublishBranch
557
662
  ? `origin/${publishConfig.branch}`
@@ -562,9 +667,13 @@ async function ensureAwDocsRepoClone(home, publishConfig) {
562
667
  }
563
668
 
564
669
  try {
670
+ // With a blobless clone, checkout lazily fetches the missing blobs for the
671
+ // sparse cone — a network op, so bound it like the other remote calls.
565
672
  await execFile('git', ['checkout', '-B', publishConfig.branch, checkoutStart], {
566
673
  cwd: cloneDir,
567
674
  encoding: 'utf8',
675
+ env: AW_DOCS_GIT_ENV,
676
+ timeout: AW_DOCS_NET_TIMEOUT_MS,
568
677
  });
569
678
  } catch (e) {
570
679
  const healedPath = isCheckoutOverwrittenByAwPathError(e)
@@ -576,6 +685,8 @@ async function ensureAwDocsRepoClone(home, publishConfig) {
576
685
  await execFile('git', ['checkout', '-B', publishConfig.branch, checkoutStart], {
577
686
  cwd: cloneDir,
578
687
  encoding: 'utf8',
688
+ env: AW_DOCS_GIT_ENV,
689
+ timeout: AW_DOCS_NET_TIMEOUT_MS,
579
690
  });
580
691
  }
581
692
 
@@ -583,6 +694,8 @@ async function ensureAwDocsRepoClone(home, publishConfig) {
583
694
  await execFile('git', ['pull', '--ff-only', 'origin', publishConfig.branch], {
584
695
  cwd: cloneDir,
585
696
  encoding: 'utf8',
697
+ env: AW_DOCS_GIT_ENV,
698
+ timeout: AW_DOCS_NET_TIMEOUT_MS,
586
699
  });
587
700
  }
588
701
  await removeTrackedAwSymlink(cloneDir);
@@ -683,6 +796,8 @@ async function commitAndPushAwDocsRepo(docsRepoDir, { message, branch }) {
683
796
  await execFile('git', ['push', '-u', 'origin', `HEAD:${branch}`], {
684
797
  cwd: docsRepoDir,
685
798
  encoding: 'utf8',
799
+ env: AW_DOCS_GIT_ENV,
800
+ timeout: AW_DOCS_NET_TIMEOUT_MS,
686
801
  });
687
802
  }
688
803
 
@@ -733,6 +848,7 @@ function updateAwDocsManifest(docsRepoDir, { repoSlug, sourceRepo, githubUsernam
733
848
  }
734
849
 
735
850
  async function publishProjectAwDocs(cwd, home, dryRun, scope = null) {
851
+ ensureHeadlessGitEnv();
736
852
  const { projectRoot, files } = collectProjectAwDocs(cwd, home, scope);
737
853
  if (files.length === 0) return { hasDocs: false, publishedPaths: [] };
738
854
 
@@ -776,7 +892,12 @@ async function publishProjectAwDocs(cwd, home, dryRun, scope = null) {
776
892
  const s = fmt.spinner();
777
893
  s.start(`Publishing ${files.length} AW doc${files.length > 1 ? 's' : ''} to ${publishConfig.repo}...`);
778
894
  try {
779
- const docsRepoDir = await ensureAwDocsRepoClone(home, publishConfig);
895
+ // Scope the (blobless) docs clone to only the subtree this publish writes,
896
+ // so a fresh clone fetches a few MB instead of the whole org's docs.
897
+ const sparseIncludePaths = isRootScope
898
+ ? [`${publishConfig.dest}/${scope.relPrefix}`]
899
+ : [`${publishConfig.dest}/${repoSlug}/${githubUsername}`];
900
+ const docsRepoDir = await ensureAwDocsRepoClone(home, publishConfig, sparseIncludePaths);
780
901
  const deleteTarget = isRootScope
781
902
  ? join(docsRepoDir, publishConfig.dest, scope.relPrefix)
782
903
  : scope?.relPrefix
@@ -1326,6 +1447,8 @@ async function doPush(files, awHome, dryRun, worktreeFlow = false, preStaged = f
1326
1447
  // ── Main command ─────────────────────────────────────────────────────
1327
1448
 
1328
1449
  export async function pushCommand(args) {
1450
+ configureInteractionMode(args);
1451
+
1329
1452
  const input = args._positional?.[0];
1330
1453
  const dryRun = args['--dry-run'] === true;
1331
1454
  const docsOnly = args['--aw-docs-only'] === true || args['--docs-only'] === true;
@@ -1684,6 +1807,8 @@ export const __test__ = {
1684
1807
  featureScopeFromInput,
1685
1808
  awDocsFeatureScope,
1686
1809
  awDocsRootScope,
1810
+ matchRootPrefix,
1811
+ normalizeAwDocsRootInput,
1687
1812
  resolveAwDocsScope,
1688
1813
  assertDocsOnlyScopeOrAll,
1689
1814
  getGitHubUser,
package/fmt.mjs CHANGED
@@ -13,6 +13,38 @@ let _silent = false;
13
13
  export function setSilent(v) { _silent = !!v; }
14
14
  export function isSilent() { return _silent; }
15
15
 
16
+ // ─── Headless mode ───
17
+ // CI / non-TTY / --yes: skip clack TTY UI (intro, spinner, p.cancel) but keep log output.
18
+ let _headless = false;
19
+ export function setHeadless(v) { _headless = !!v; }
20
+ export function isHeadless() { return _headless; }
21
+
22
+ function envTruthy(name) {
23
+ const value = String(process.env[name] || '').trim().toLowerCase();
24
+ return value === '1' || value === 'true' || value === 'yes';
25
+ }
26
+
27
+ /** Configure silent/headless interaction before command output. */
28
+ export function configureInteractionMode(args = {}) {
29
+ if (args['--silent'] === true) {
30
+ setSilent(true);
31
+ setHeadless(true);
32
+ return { silent: true, headless: true };
33
+ }
34
+
35
+ const forceHeadless = args['--yes'] === true
36
+ || args['-y'] === true
37
+ || args['--non-interactive'] === true
38
+ || args['--headless'] === true;
39
+ const headless = forceHeadless
40
+ || envTruthy('CI')
41
+ || envTruthy('AW_NON_INTERACTIVE')
42
+ || !process.stdout.isTTY;
43
+
44
+ setHeadless(headless);
45
+ return { silent: false, headless };
46
+ }
47
+
16
48
  // ─── Banner ───
17
49
 
18
50
  // Big ASCII art icons — same height as ANSI Shadow font (6 lines)
@@ -73,14 +105,27 @@ export function banner(text, opts = {}) {
73
105
 
74
106
  // ─── Clack wrappers ───
75
107
 
76
- export const intro = (msg) => { if (!_silent) p.intro(chalk.bgHex('#FF6B35').black(` ⟁ ${msg} `)); };
77
- export const outro = (msg) => { if (!_silent) p.outro(chalk.green(msg)); };
108
+ export const intro = (msg) => { if (!_silent && !_headless) p.intro(chalk.bgHex('#FF6B35').black(` ⟁ ${msg} `)); };
109
+ export const outro = (msg) => {
110
+ if (_silent) return;
111
+ if (_headless) { console.log(msg); return; }
112
+ p.outro(chalk.green(msg));
113
+ };
78
114
  export const select = p.select;
79
115
  export const isCancel = p.isCancel;
80
116
 
81
- // Returns a real spinner when not silent, or a no-op stub when silent.
82
- const _noopSpinner = { start() {}, stop() {}, message() {} };
83
- export const spinner = () => _silent ? _noopSpinner : p.spinner();
117
+ // Returns a real spinner when interactive, or a stub in silent/headless mode.
118
+ const _silentSpinner = { start() {}, stop() {}, message() {} };
119
+ const _headlessSpinner = {
120
+ start() {},
121
+ stop(msg) { if (msg) console.log(msg); },
122
+ message() {},
123
+ };
124
+ export const spinner = () => {
125
+ if (_silent) return _silentSpinner;
126
+ if (_headless) return _headlessSpinner;
127
+ return p.spinner();
128
+ };
84
129
 
85
130
  export class CancelError extends Error {
86
131
  constructor(message, { exitCode = 1 } = {}) {
@@ -91,13 +136,15 @@ export class CancelError extends Error {
91
136
  }
92
137
 
93
138
  export function cancel(msg) {
94
- p.cancel(msg);
139
+ if (_headless) console.error(msg);
140
+ else p.cancel(msg);
95
141
  throw new CancelError(msg);
96
142
  }
97
143
 
98
144
  /** Hard exit — for use in process exception handlers where throwing is unsafe */
99
145
  export function cancelAndExit(msg) {
100
- p.cancel(msg);
146
+ if (_headless) console.error(msg);
147
+ else p.cancel(msg);
101
148
  process.exit(1);
102
149
  }
103
150
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghl-ai/aw",
3
- "version": "0.1.80",
3
+ "version": "0.1.82",
4
4
  "description": "Agentic Workspace CLI — pull, push & manage agents, skills and commands from the registry",
5
5
  "type": "module",
6
6
  "bin": {