@ghl-ai/aw 0.1.44-beta.1 → 0.1.44-beta.2
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/claudePluginRegistry.mjs +142 -0
- package/c4/codexConfig.mjs +148 -0
- package/c4/codexPromptInjector.mjs +187 -0
- package/c4/commandSurface.mjs +286 -0
- package/c4/detect.mjs +62 -0
- package/c4/diagnostics.mjs +536 -0
- package/c4/eccRegistryBridge.mjs +184 -0
- package/c4/ghCli.mjs +94 -0
- package/c4/gitAuth.mjs +384 -0
- package/c4/index.mjs +64 -0
- package/c4/jsonMerge.mjs +229 -0
- package/c4/mcpServer.mjs +160 -0
- package/c4/mcpSmokeProbe.mjs +201 -0
- package/c4/preflight.mjs +254 -0
- package/c4/repoLocalClaudeSettings.mjs +54 -0
- package/c4/repoLocalIgnore.mjs +157 -0
- package/c4/repoRootInstructions.mjs +166 -0
- package/c4/secrets.mjs +55 -0
- package/c4/slimRouter.mjs +472 -0
- package/cli.mjs +7 -0
- package/commands/c4.mjs +387 -0
- package/ecc.mjs +7 -72
- package/integrate.mjs +6 -6
- package/mcp.mjs +0 -1
- package/package.json +5 -3
package/c4/preflight.mjs
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* c4/preflight.mjs — preflight aggregator for `aw c4`.
|
|
3
|
+
*
|
|
4
|
+
* Composes harness, token resolution, REST-API auth verification, gh CLI
|
|
5
|
+
* presence, ECC bridge-status probe, and disk-free reporting into a single
|
|
6
|
+
* PreflightResult shape with per-failure recommendation strings. Pure
|
|
7
|
+
* orchestration over already-tested c4 primitives — no new I/O patterns.
|
|
8
|
+
*
|
|
9
|
+
* The orchestrator (commands/c4.mjs) consumes runPreflight() up front so:
|
|
10
|
+
* (a) `aw c4 --dry-run` can short-circuit with the full report before any
|
|
11
|
+
* harness write, and
|
|
12
|
+
* (b) install-mode runs print recommendations[] alongside the summary
|
|
13
|
+
* line so the user sees actionable guidance for every degraded
|
|
14
|
+
* outcome (no token → set GITHUB_PAT, 401 → refresh PAT, etc.).
|
|
15
|
+
*
|
|
16
|
+
* Every external signal is dependency-injected via opts so this module is
|
|
17
|
+
* exhaustively testable without touching env, network, the real shell, or
|
|
18
|
+
* the real filesystem disk-stat surface.
|
|
19
|
+
*
|
|
20
|
+
* Contract: spec.md::§"c4/preflight.mjs", tasks.md::4.1.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { existsSync } from 'node:fs';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
import { spawnSync } from 'node:child_process';
|
|
26
|
+
import { getGithubToken } from './secrets.mjs';
|
|
27
|
+
import { verifyAuth } from './gitAuth.mjs';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {'cursor-cloud' | 'codex-web' | 'claude-web' | 'unknown'} Harness
|
|
31
|
+
* @typedef {'GITHUB_PAT' | 'GITHUB_TOKEN' | null} TokenSource
|
|
32
|
+
* @typedef {'ok' | 'will-install' | 'failed'} BridgeStatus
|
|
33
|
+
*
|
|
34
|
+
* @typedef {object} PreflightResult
|
|
35
|
+
* @property {Harness} harness
|
|
36
|
+
* @property {boolean} hasToken
|
|
37
|
+
* @property {TokenSource} tokenSource
|
|
38
|
+
* @property {boolean} authOk
|
|
39
|
+
* @property {string} [authError] Short failure tag (e.g. 'http-401', 'network').
|
|
40
|
+
* @property {boolean} ghCliPresent
|
|
41
|
+
* @property {BridgeStatus} bridgeStatus
|
|
42
|
+
* @property {number} diskFreeMb
|
|
43
|
+
* @property {string[]} recommendations Actionable guidance strings.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/* ─────────────────────────────────────────────────────────────────────────
|
|
47
|
+
* Constants — thresholds and message templates.
|
|
48
|
+
* ───────────────────────────────────────────────────────────────────────── */
|
|
49
|
+
|
|
50
|
+
const DISK_FREE_THRESHOLD_MB = 200;
|
|
51
|
+
|
|
52
|
+
const REC = Object.freeze({
|
|
53
|
+
noToken:
|
|
54
|
+
'Set GITHUB_PAT in your harness secrets UI before running aw c4 ' +
|
|
55
|
+
'(GITHUB_TOKEN is also accepted as a fallback).',
|
|
56
|
+
authInvalid:
|
|
57
|
+
'PAT is invalid, expired, or SAML not authorized — refresh the token ' +
|
|
58
|
+
'and re-authorize the GoHighLevel org if your harness uses SSO.',
|
|
59
|
+
authForbidden:
|
|
60
|
+
'PAT lacks platform-docs repo access — grant the `repo` scope (or ' +
|
|
61
|
+
'add the user to GoHighLevel/platform-docs) and retry.',
|
|
62
|
+
authNetwork:
|
|
63
|
+
'Network connectivity to api.github.com is broken — verify the harness ' +
|
|
64
|
+
'has outbound HTTPS and DNS reachability before running aw c4.',
|
|
65
|
+
authOther: (status) =>
|
|
66
|
+
`Auth verification returned an unexpected response (${status ?? 'no-status'}); aw c4 will continue but ghl-ai MCP and registry sync may fail.`,
|
|
67
|
+
ghMissing:
|
|
68
|
+
'gh CLI is not installed — aw c4 will install via apt or fall back to ' +
|
|
69
|
+
'the insteadOf rewrite alone (degraded but still functional).',
|
|
70
|
+
lowDisk: (mb) =>
|
|
71
|
+
`Insufficient disk: ${mb}MB free (aw c4 needs at least ${DISK_FREE_THRESHOLD_MB}MB ` +
|
|
72
|
+
'for ECC clone + registry pull).',
|
|
73
|
+
bridgeWillInstall:
|
|
74
|
+
'ECC registry bridge will be installed during this run (Codex only — ' +
|
|
75
|
+
'Bug A workaround that symlinks ECC stage skills into ~/.aw/.aw_registry).',
|
|
76
|
+
bridgeFailed:
|
|
77
|
+
'ECC and/or AW registry not yet provisioned — aw init will create them ' +
|
|
78
|
+
'on the first c4 run; re-run preflight afterwards.',
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/* ─────────────────────────────────────────────────────────────────────────
|
|
82
|
+
* Default adapters — overridden in tests.
|
|
83
|
+
* ───────────────────────────────────────────────────────────────────────── */
|
|
84
|
+
|
|
85
|
+
function defaultShell(cmd) {
|
|
86
|
+
const result = spawnSync(cmd, [], {
|
|
87
|
+
shell: true,
|
|
88
|
+
encoding: 'utf8',
|
|
89
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
90
|
+
});
|
|
91
|
+
return {
|
|
92
|
+
ok: result.status === 0,
|
|
93
|
+
stdout: result.stdout ?? '',
|
|
94
|
+
stderr: result.stderr ?? '',
|
|
95
|
+
exitCode: result.status ?? -1,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* ─────────────────────────────────────────────────────────────────────────
|
|
100
|
+
* Helpers — small pure functions per signal.
|
|
101
|
+
* ───────────────────────────────────────────────────────────────────────── */
|
|
102
|
+
|
|
103
|
+
function runShell(shell, cmd) {
|
|
104
|
+
try {
|
|
105
|
+
return shell(cmd) ?? { ok: false, stdout: '', stderr: '', exitCode: -1 };
|
|
106
|
+
} catch (err) {
|
|
107
|
+
return { ok: false, stdout: '', stderr: String(err?.message ?? err), exitCode: -1 };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function classifyAuthError(verifyResult) {
|
|
112
|
+
if (verifyResult.ok) return undefined;
|
|
113
|
+
if (typeof verifyResult.status === 'number') {
|
|
114
|
+
if (verifyResult.status === 401) return 'http-401';
|
|
115
|
+
if (verifyResult.status === 403) return 'http-403';
|
|
116
|
+
if (verifyResult.status === 404) return 'http-404';
|
|
117
|
+
return `http-${verifyResult.status}`;
|
|
118
|
+
}
|
|
119
|
+
if (verifyResult.error) return 'network';
|
|
120
|
+
return 'unknown';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function probeBridgeStatus(harness, awHome, eccHome) {
|
|
124
|
+
if (harness !== 'codex-web') return 'ok';
|
|
125
|
+
|
|
126
|
+
// Bridge installs ~/.aw/.aw_registry/platform/core/skills/<aw-*>/SKILL.md
|
|
127
|
+
// symlinks pointing at ~/.aw-ecc/skills/<aw-*>/SKILL.md. If aw-plan is
|
|
128
|
+
// already present in the registry path, the bridge is a no-op (ok).
|
|
129
|
+
const bridgedSkillPath = join(
|
|
130
|
+
awHome,
|
|
131
|
+
'.aw_registry/platform/core/skills/aw-plan/SKILL.md',
|
|
132
|
+
);
|
|
133
|
+
if (existsSync(bridgedSkillPath)) return 'ok';
|
|
134
|
+
|
|
135
|
+
// Bridge can install when both ECC home and the registry skeleton exist.
|
|
136
|
+
// (The aw-plan skill source must exist somewhere — the bridge follows
|
|
137
|
+
// applyEccRegistryBridge's contract; preflight only certifies feasibility.)
|
|
138
|
+
if (existsSync(eccHome) && existsSync(awHome)) return 'will-install';
|
|
139
|
+
|
|
140
|
+
return 'failed';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function buildRecommendations({
|
|
144
|
+
hasToken,
|
|
145
|
+
authOk,
|
|
146
|
+
authError,
|
|
147
|
+
authStatus,
|
|
148
|
+
ghCliPresent,
|
|
149
|
+
bridgeStatus,
|
|
150
|
+
diskFreeMb,
|
|
151
|
+
}) {
|
|
152
|
+
const out = [];
|
|
153
|
+
if (!hasToken) out.push(REC.noToken);
|
|
154
|
+
if (hasToken && !authOk) {
|
|
155
|
+
if (authError === 'http-401') out.push(REC.authInvalid);
|
|
156
|
+
else if (authError === 'http-403' || authError === 'http-404') out.push(REC.authForbidden);
|
|
157
|
+
else if (authError === 'network') out.push(REC.authNetwork);
|
|
158
|
+
else out.push(REC.authOther(authStatus ?? authError));
|
|
159
|
+
}
|
|
160
|
+
if (!ghCliPresent) out.push(REC.ghMissing);
|
|
161
|
+
if (diskFreeMb < DISK_FREE_THRESHOLD_MB) out.push(REC.lowDisk(diskFreeMb));
|
|
162
|
+
if (bridgeStatus === 'will-install') out.push(REC.bridgeWillInstall);
|
|
163
|
+
else if (bridgeStatus === 'failed') out.push(REC.bridgeFailed);
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* ─────────────────────────────────────────────────────────────────────────
|
|
168
|
+
* runPreflight — public API.
|
|
169
|
+
* ───────────────────────────────────────────────────────────────────────── */
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Aggregate every preflight signal the orchestrator needs before deciding
|
|
173
|
+
* whether to skip, dry-run, or proceed with a full c4 install.
|
|
174
|
+
*
|
|
175
|
+
* @param {object} opts
|
|
176
|
+
* @param {Harness} opts.harness Required — pre-detected by detect.mjs.
|
|
177
|
+
* @param {string} opts.cwd Required — repo working directory.
|
|
178
|
+
* @param {NodeJS.ProcessEnv} [opts.env]
|
|
179
|
+
* @param {(cmd: string) => { ok: boolean, stdout: string, stderr: string, exitCode: number }} [opts.shell]
|
|
180
|
+
* @param {typeof globalThis.fetch} [opts.fetchImpl]
|
|
181
|
+
* @param {string} [opts.home] Defaults to env.HOME.
|
|
182
|
+
* @param {string} [opts.awHome] Defaults to <home>/.aw.
|
|
183
|
+
* @param {string} [opts.eccHome] Defaults to <home>/.aw-ecc.
|
|
184
|
+
* @param {number} [opts.diskFreeMb] Inject for tests; production defaults to a real statfs probe.
|
|
185
|
+
* @returns {Promise<PreflightResult>}
|
|
186
|
+
*/
|
|
187
|
+
export async function runPreflight(opts = {}) {
|
|
188
|
+
if (!opts.harness) throw new Error('runPreflight: harness is required');
|
|
189
|
+
if (!opts.cwd) throw new Error('runPreflight: cwd is required');
|
|
190
|
+
|
|
191
|
+
const {
|
|
192
|
+
harness,
|
|
193
|
+
env = process.env,
|
|
194
|
+
shell = defaultShell,
|
|
195
|
+
fetchImpl = globalThis.fetch,
|
|
196
|
+
diskFreeMb,
|
|
197
|
+
} = opts;
|
|
198
|
+
// home: prefer explicit opts.home, then env.HOME (already DI'd), only
|
|
199
|
+
// last-resort to '~'. Removed the redundant process.env.HOME fallback —
|
|
200
|
+
// when env is the injected `{}`, opts.home should have been passed instead.
|
|
201
|
+
const home = opts.home ?? env.HOME ?? '~';
|
|
202
|
+
const awHome = opts.awHome ?? join(home, '.aw');
|
|
203
|
+
const eccHome = opts.eccHome ?? join(home, '.aw-ecc');
|
|
204
|
+
|
|
205
|
+
// 1. Token resolution.
|
|
206
|
+
const { token, source: tokenSource } = getGithubToken(env);
|
|
207
|
+
const hasToken = token != null;
|
|
208
|
+
|
|
209
|
+
// 2. Auth verification — only when we have a token to verify.
|
|
210
|
+
let authOk = false;
|
|
211
|
+
let authError;
|
|
212
|
+
let authStatus;
|
|
213
|
+
if (hasToken) {
|
|
214
|
+
const verify = await verifyAuth('rest-api', token, { shell, fetchImpl });
|
|
215
|
+
authOk = verify.ok;
|
|
216
|
+
authStatus = verify.status;
|
|
217
|
+
if (!authOk) authError = classifyAuthError(verify);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// 3. gh CLI presence.
|
|
221
|
+
const ghCliPresent = runShell(shell, 'command -v gh').ok;
|
|
222
|
+
|
|
223
|
+
// 4. Bridge status (codex-web only; other harnesses always 'ok').
|
|
224
|
+
const bridgeStatus = probeBridgeStatus(harness, awHome, eccHome);
|
|
225
|
+
|
|
226
|
+
// 5. Disk free MB. For tests, callers inject the value directly; in
|
|
227
|
+
// production the orchestrator passes a measured number. Defaulting to
|
|
228
|
+
// Infinity here keeps the preflight from spuriously flagging low disk
|
|
229
|
+
// when the orchestrator forgets to measure.
|
|
230
|
+
const diskFreeMbResolved = typeof diskFreeMb === 'number' ? diskFreeMb : Number.POSITIVE_INFINITY;
|
|
231
|
+
|
|
232
|
+
// 6. Compose recommendations.
|
|
233
|
+
const recommendations = buildRecommendations({
|
|
234
|
+
hasToken,
|
|
235
|
+
authOk,
|
|
236
|
+
authError,
|
|
237
|
+
authStatus,
|
|
238
|
+
ghCliPresent,
|
|
239
|
+
bridgeStatus,
|
|
240
|
+
diskFreeMb: diskFreeMbResolved,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
harness,
|
|
245
|
+
hasToken,
|
|
246
|
+
tokenSource,
|
|
247
|
+
authOk,
|
|
248
|
+
...(authError ? { authError } : {}),
|
|
249
|
+
ghCliPresent,
|
|
250
|
+
bridgeStatus,
|
|
251
|
+
diskFreeMb: diskFreeMbResolved,
|
|
252
|
+
recommendations,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* c4/repoLocalClaudeSettings.mjs — per-repo Claude settings.local.json with
|
|
3
|
+
* Skill permission scoped to the current repo.
|
|
4
|
+
*
|
|
5
|
+
* Why per-repo: home-level settings.json governs the entire user. A repo
|
|
6
|
+
* may want Skill dispatch enabled without inheriting that permission across
|
|
7
|
+
* every other workspace. Pilot script writes this file so the repo-scoped
|
|
8
|
+
* session can dispatch the Skill tool independently.
|
|
9
|
+
*
|
|
10
|
+
* Never overwrites: if the file exists (user-authored or AW-authored from a
|
|
11
|
+
* prior run), we return { alreadyPresent: true, written: false } and leave
|
|
12
|
+
* it untouched. Idempotency is achieved by NOT being a "merge" — it's a
|
|
13
|
+
* "create if absent" semantic.
|
|
14
|
+
*
|
|
15
|
+
* Contract: spec.md::§"c4/repoLocalClaudeSettings.mjs", tasks.md::3.4c.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
existsSync,
|
|
20
|
+
writeFileSync,
|
|
21
|
+
chmodSync,
|
|
22
|
+
mkdirSync,
|
|
23
|
+
} from 'node:fs';
|
|
24
|
+
import { dirname, join } from 'node:path';
|
|
25
|
+
|
|
26
|
+
const FILE_MODE = 0o600;
|
|
27
|
+
|
|
28
|
+
const CANONICAL_BODY = {
|
|
29
|
+
$schema: 'https://json.schemastore.org/claude-code-settings.json',
|
|
30
|
+
permissions: { allow: ['Skill'] },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Write `<cwd>/.claude/settings.local.json` if absent.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} cwd Repo root.
|
|
37
|
+
* @returns {{ written: boolean, alreadyPresent: boolean }}
|
|
38
|
+
*/
|
|
39
|
+
export function ensureRepoLocalClaudeSettings(cwd) {
|
|
40
|
+
if (!cwd || typeof cwd !== 'string') {
|
|
41
|
+
throw new Error('ensureRepoLocalClaudeSettings: cwd is required');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const filePath = join(cwd, '.claude/settings.local.json');
|
|
45
|
+
if (existsSync(filePath)) {
|
|
46
|
+
return { written: false, alreadyPresent: true };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
50
|
+
writeFileSync(filePath, JSON.stringify(CANONICAL_BODY, null, 2) + '\n', { mode: FILE_MODE });
|
|
51
|
+
try { chmodSync(filePath, FILE_MODE); } catch { /* best-effort */ }
|
|
52
|
+
|
|
53
|
+
return { written: true, alreadyPresent: false };
|
|
54
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* c4/repoLocalIgnore.mjs — append entries to `<cwd>/.git/info/exclude` so
|
|
3
|
+
* AW C4 cloud bootstrap pollution doesn't show up in `git status`.
|
|
4
|
+
*
|
|
5
|
+
* Contract: spec.md::§"c4/repoLocalIgnore.mjs", tasks.md::3.6.
|
|
6
|
+
*
|
|
7
|
+
* Notes:
|
|
8
|
+
* - Repo-local only. Never touches `.gitignore` (committed file).
|
|
9
|
+
* - No-op if `<cwd>/.git` is absent.
|
|
10
|
+
* - Idempotent: lines are added only when `grep -qxF`-style identity match
|
|
11
|
+
* is missing (whole-line, fixed-string).
|
|
12
|
+
* - MD entries (CLAUDE.md / AGENTS.md) are conditional: we only add them if
|
|
13
|
+
* the file is NOT already tracked in git. This preserves user repos that
|
|
14
|
+
* intentionally commit their own root-level instruction files.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
existsSync,
|
|
19
|
+
mkdirSync,
|
|
20
|
+
readFileSync,
|
|
21
|
+
writeFileSync,
|
|
22
|
+
} from 'node:fs';
|
|
23
|
+
import { join, dirname } from 'node:path';
|
|
24
|
+
import { spawnSync } from 'node:child_process';
|
|
25
|
+
|
|
26
|
+
const ALWAYS_ENTRIES = ['.aw_registry', '.aw/', '.aw-ecc/'];
|
|
27
|
+
|
|
28
|
+
const PER_HARNESS_ENTRIES = {
|
|
29
|
+
'claude-web': ['.claude/hook-logs/', '.claude/settings.local.json'],
|
|
30
|
+
'cursor-cloud': [
|
|
31
|
+
'.cursor/hook-logs/',
|
|
32
|
+
'.cursor/settings.local.json',
|
|
33
|
+
'.cursor/mcp.json',
|
|
34
|
+
],
|
|
35
|
+
'codex-web': ['.codex/hook-logs/', '.codex/config.local.toml'],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const CONDITIONAL_MD_BY_HARNESS = {
|
|
39
|
+
'claude-web': ['CLAUDE.md'],
|
|
40
|
+
'cursor-cloud': ['AGENTS.md'],
|
|
41
|
+
'codex-web': ['AGENTS.md'],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Default shell adapter — runs `git -C <cwd> ls-files --error-unmatch <path>`
|
|
46
|
+
* via `spawnSync` and reports a normalized result. Production callers can
|
|
47
|
+
* inject their own (e.g. tests) via `opts.shell`.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} cmd
|
|
50
|
+
* @returns {{ ok: boolean, exitCode: number }}
|
|
51
|
+
*/
|
|
52
|
+
function defaultShell(cmd) {
|
|
53
|
+
const result = spawnSync('bash', ['-lc', cmd], { encoding: 'utf8' });
|
|
54
|
+
return {
|
|
55
|
+
ok: result.status === 0,
|
|
56
|
+
exitCode: typeof result.status === 'number' ? result.status : 1,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Whether a repo-relative path is currently tracked in git (committed or
|
|
62
|
+
* staged). Mirrors the pilot's `git ls-files --error-unmatch <file>` check.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} cwd
|
|
65
|
+
* @param {string} relativePath
|
|
66
|
+
* @param {(cmd: string) => { ok: boolean, exitCode: number }} shell
|
|
67
|
+
* @returns {boolean}
|
|
68
|
+
*/
|
|
69
|
+
function isTrackedInGit(cwd, relativePath, shell) {
|
|
70
|
+
const cmd = `git -C ${shellQuote(cwd)} ls-files --error-unmatch ${shellQuote(relativePath)}`;
|
|
71
|
+
const { ok } = shell(cmd);
|
|
72
|
+
return ok === true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function shellQuote(s) {
|
|
76
|
+
return `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function readExclude(filePath) {
|
|
80
|
+
if (!existsSync(filePath)) return '';
|
|
81
|
+
return readFileSync(filePath, 'utf8');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Whether `entry` already appears as a whole line in the file body.
|
|
86
|
+
* Mirrors `grep -qxF` semantics (fixed string, full-line match).
|
|
87
|
+
*/
|
|
88
|
+
function hasExactLine(body, entry) {
|
|
89
|
+
if (body === '') return false;
|
|
90
|
+
const lines = body.split('\n');
|
|
91
|
+
return lines.includes(entry);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Append the given entries to the file body and return the new body.
|
|
96
|
+
* Ensures a single trailing newline. Skips entries already present.
|
|
97
|
+
*/
|
|
98
|
+
function appendMissing(body, entries) {
|
|
99
|
+
let next = body;
|
|
100
|
+
if (next.length > 0 && !next.endsWith('\n')) next += '\n';
|
|
101
|
+
for (const entry of entries) {
|
|
102
|
+
if (hasExactLine(next, entry)) continue;
|
|
103
|
+
next += `${entry}\n`;
|
|
104
|
+
}
|
|
105
|
+
return next;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Append AW C4 entries to `<cwd>/.git/info/exclude`.
|
|
110
|
+
*
|
|
111
|
+
* @param {object} opts
|
|
112
|
+
* @param {string} opts.cwd Repo root.
|
|
113
|
+
* @param {'claude-web'|'cursor-cloud'|'codex-web'} opts.harness
|
|
114
|
+
* @param {(cmd: string) => { ok: boolean, exitCode: number }} [opts.shell]
|
|
115
|
+
* Optional shell adapter (defaults to spawnSync-backed). Tests inject this
|
|
116
|
+
* to simulate `git ls-files --error-unmatch` outcomes.
|
|
117
|
+
* @returns {{ added: string[] }}
|
|
118
|
+
*/
|
|
119
|
+
export function ensureRepoLocalIgnore(opts) {
|
|
120
|
+
if (!opts || typeof opts !== 'object') {
|
|
121
|
+
throw new Error('ensureRepoLocalIgnore: opts object is required');
|
|
122
|
+
}
|
|
123
|
+
const { cwd, harness } = opts;
|
|
124
|
+
const shell = typeof opts.shell === 'function' ? opts.shell : defaultShell;
|
|
125
|
+
|
|
126
|
+
if (!cwd || typeof cwd !== 'string') {
|
|
127
|
+
throw new Error('ensureRepoLocalIgnore: opts.cwd is required');
|
|
128
|
+
}
|
|
129
|
+
if (!PER_HARNESS_ENTRIES[harness]) {
|
|
130
|
+
throw new Error(`ensureRepoLocalIgnore: unsupported harness "${harness}"`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// No-op if the repo isn't a git repo at all.
|
|
134
|
+
if (!existsSync(join(cwd, '.git'))) {
|
|
135
|
+
return { added: [] };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const excludePath = join(cwd, '.git/info/exclude');
|
|
139
|
+
const currentBody = readExclude(excludePath);
|
|
140
|
+
|
|
141
|
+
// Compose desired entries.
|
|
142
|
+
const desired = [...ALWAYS_ENTRIES, ...PER_HARNESS_ENTRIES[harness]];
|
|
143
|
+
for (const md of CONDITIONAL_MD_BY_HARNESS[harness]) {
|
|
144
|
+
if (!isTrackedInGit(cwd, md, shell)) desired.push(md);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Compute which entries are actually new (so we can report `added`).
|
|
148
|
+
const added = desired.filter((e) => !hasExactLine(currentBody, e));
|
|
149
|
+
if (added.length === 0) return { added: [] };
|
|
150
|
+
|
|
151
|
+
// Ensure parent dir, then atomically rewrite with new content.
|
|
152
|
+
mkdirSync(dirname(excludePath), { recursive: true });
|
|
153
|
+
const nextBody = appendMissing(currentBody, desired);
|
|
154
|
+
writeFileSync(excludePath, nextBody);
|
|
155
|
+
|
|
156
|
+
return { added };
|
|
157
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* c4/repoRootInstructions.mjs — copy ECC's persistent context file
|
|
3
|
+
* (CLAUDE.md / AGENTS.md) into the repo root, with codex-source precedence
|
|
4
|
+
* (L2) and user-authored file preservation.
|
|
5
|
+
*
|
|
6
|
+
* Per harness:
|
|
7
|
+
* - claude-web → ~/.aw-ecc/CLAUDE.md → <cwd>/CLAUDE.md
|
|
8
|
+
* - cursor-cloud → ~/.aw-ecc/AGENTS.md → <cwd>/AGENTS.md
|
|
9
|
+
* - codex-web → ~/.codex/AGENTS.md (preferred) OR ~/.aw-ecc/AGENTS.md (fallback)
|
|
10
|
+
*
|
|
11
|
+
* (L2) Codex source precedence: ~/.codex/AGENTS.md is what `aw init`'s
|
|
12
|
+
* codex provider personalized for this user (rules-bridge + harness
|
|
13
|
+
* tweaks). Always prefer it over the raw ECC fallback when present, even
|
|
14
|
+
* if it lacks the managed-block marker — the user customization wins.
|
|
15
|
+
*
|
|
16
|
+
* Behavior matrix:
|
|
17
|
+
* - target absent + source present → copy verbatim, source: 'aw-ecc-home' or 'codex-home'.
|
|
18
|
+
* - target absent + source absent → no-op, source: 'none'.
|
|
19
|
+
* - target user-authored + source present → preserve user content, replace
|
|
20
|
+
* managed block in place,
|
|
21
|
+
* source: 'preserved-user-file'.
|
|
22
|
+
* - target managed-only (rare) → managed block updated in place.
|
|
23
|
+
*
|
|
24
|
+
* Managed-block markers (this module):
|
|
25
|
+
* <!-- aw-managed:start repo-root-instructions -->
|
|
26
|
+
* ...content...
|
|
27
|
+
* <!-- aw-managed:end repo-root-instructions -->
|
|
28
|
+
*
|
|
29
|
+
* Contract: spec.md::§"c4/repoRootInstructions.mjs", tasks.md::3.5.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
existsSync,
|
|
34
|
+
readFileSync,
|
|
35
|
+
writeFileSync,
|
|
36
|
+
} from 'node:fs';
|
|
37
|
+
import { join } from 'node:path';
|
|
38
|
+
import { homedir } from 'node:os';
|
|
39
|
+
|
|
40
|
+
const MANAGED_START = '<!-- aw-managed:start repo-root-instructions -->';
|
|
41
|
+
const MANAGED_END = '<!-- aw-managed:end repo-root-instructions -->';
|
|
42
|
+
|
|
43
|
+
const HARNESS_TARGETS = {
|
|
44
|
+
'claude-web': 'CLAUDE.md',
|
|
45
|
+
'cursor-cloud': 'AGENTS.md',
|
|
46
|
+
'codex-web': 'AGENTS.md',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Replace any existing managed block with `bodyToInject`. Appends a fresh
|
|
51
|
+
* managed block if none exists. Preserves user content outside the block.
|
|
52
|
+
*/
|
|
53
|
+
function upsertManagedBlock(existing, bodyToInject) {
|
|
54
|
+
const startIdx = existing.indexOf(MANAGED_START);
|
|
55
|
+
const endIdx =
|
|
56
|
+
startIdx === -1
|
|
57
|
+
? -1
|
|
58
|
+
: existing.indexOf(MANAGED_END, startIdx + MANAGED_START.length);
|
|
59
|
+
|
|
60
|
+
const block = `${MANAGED_START}\n${bodyToInject.trimEnd()}\n${MANAGED_END}\n`;
|
|
61
|
+
|
|
62
|
+
if (startIdx !== -1 && endIdx !== -1) {
|
|
63
|
+
// Replace existing block in place.
|
|
64
|
+
return (
|
|
65
|
+
existing.slice(0, startIdx) +
|
|
66
|
+
block +
|
|
67
|
+
existing.slice(endIdx + MANAGED_END.length).replace(/^\n+/, '')
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Append a managed block (with a separating blank line).
|
|
72
|
+
const sep = existing.endsWith('\n') ? '\n' : '\n\n';
|
|
73
|
+
return existing + sep + block;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Pick the source file for a harness, applying codex-source precedence.
|
|
78
|
+
*
|
|
79
|
+
* Codex-web precedence (per execution.md::Phase 0 item 8 L2):
|
|
80
|
+
* 1. <cwd>/.codex/AGENTS.md — repo-author intent (highest priority)
|
|
81
|
+
* 2. ~/.codex/AGENTS.md — aw-init's harness-personalized output
|
|
82
|
+
* 3. ~/.aw-ecc/AGENTS.md — raw ECC fallback
|
|
83
|
+
*
|
|
84
|
+
* Both repo-local and home-level codex variants resolve to type
|
|
85
|
+
* `'codex-home'` because the orchestrator does not need to distinguish
|
|
86
|
+
* which one won — only that we used the codex-personalized form rather
|
|
87
|
+
* than the raw ECC fallback.
|
|
88
|
+
*/
|
|
89
|
+
function resolveSource({ harness, cwd, eccHome, home }) {
|
|
90
|
+
if (harness === 'claude-web') {
|
|
91
|
+
const eccPath = join(eccHome, 'CLAUDE.md');
|
|
92
|
+
if (existsSync(eccPath)) return { type: 'aw-ecc-home', path: eccPath };
|
|
93
|
+
return { type: 'none', path: null };
|
|
94
|
+
}
|
|
95
|
+
if (harness === 'cursor-cloud') {
|
|
96
|
+
const eccPath = join(eccHome, 'AGENTS.md');
|
|
97
|
+
if (existsSync(eccPath)) return { type: 'aw-ecc-home', path: eccPath };
|
|
98
|
+
return { type: 'none', path: null };
|
|
99
|
+
}
|
|
100
|
+
if (harness === 'codex-web') {
|
|
101
|
+
const cwdCodexPath = join(cwd, '.codex/AGENTS.md');
|
|
102
|
+
if (existsSync(cwdCodexPath)) return { type: 'codex-home', path: cwdCodexPath };
|
|
103
|
+
const homeCodexPath = join(home, '.codex/AGENTS.md');
|
|
104
|
+
if (existsSync(homeCodexPath)) return { type: 'codex-home', path: homeCodexPath };
|
|
105
|
+
const eccPath = join(eccHome, 'AGENTS.md');
|
|
106
|
+
if (existsSync(eccPath)) return { type: 'aw-ecc-home', path: eccPath };
|
|
107
|
+
return { type: 'none', path: null };
|
|
108
|
+
}
|
|
109
|
+
throw new Error(`copyRepoRootInstructions: unsupported harness "${harness}"`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Copy a repo-root persistent-context file into the repo, preserving any
|
|
114
|
+
* user-authored content via managed-block semantics.
|
|
115
|
+
*
|
|
116
|
+
* @param {'claude-web'|'cursor-cloud'|'codex-web'} harness
|
|
117
|
+
* @param {string} cwd Repo root.
|
|
118
|
+
* @param {string} eccHome ~/.aw-ecc.
|
|
119
|
+
* @param {{ home?: string }} [opts] User home (default os.homedir()).
|
|
120
|
+
* @returns {{ copied: string[], sources: Record<string, 'codex-home'|'aw-ecc-home'|'preserved-user-file'|'none'> }}
|
|
121
|
+
*/
|
|
122
|
+
export function copyRepoRootInstructions(harness, cwd, eccHome, opts = {}) {
|
|
123
|
+
const fileName = HARNESS_TARGETS[harness];
|
|
124
|
+
if (!fileName) {
|
|
125
|
+
throw new Error(`copyRepoRootInstructions: unsupported harness "${harness}"`);
|
|
126
|
+
}
|
|
127
|
+
if (!cwd || typeof cwd !== 'string') {
|
|
128
|
+
throw new Error('copyRepoRootInstructions: cwd is required');
|
|
129
|
+
}
|
|
130
|
+
if (!eccHome || typeof eccHome !== 'string') {
|
|
131
|
+
throw new Error('copyRepoRootInstructions: eccHome is required');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const home = opts.home ?? homedir();
|
|
135
|
+
const targetPath = join(cwd, fileName);
|
|
136
|
+
const sources = {};
|
|
137
|
+
const copied = [];
|
|
138
|
+
|
|
139
|
+
const userFileExists = existsSync(targetPath);
|
|
140
|
+
const source = resolveSource({ harness, cwd, eccHome, home });
|
|
141
|
+
|
|
142
|
+
if (source.type === 'none') {
|
|
143
|
+
sources[fileName] = userFileExists ? 'preserved-user-file' : 'none';
|
|
144
|
+
return { copied, sources };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const sourceBody = readFileSync(source.path, 'utf8');
|
|
148
|
+
|
|
149
|
+
if (!userFileExists) {
|
|
150
|
+
// Fresh copy — write source verbatim.
|
|
151
|
+
writeFileSync(targetPath, sourceBody);
|
|
152
|
+
sources[fileName] = source.type;
|
|
153
|
+
copied.push(fileName);
|
|
154
|
+
return { copied, sources };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// User file exists — never replace it; only update managed block.
|
|
158
|
+
const userContent = readFileSync(targetPath, 'utf8');
|
|
159
|
+
const next = upsertManagedBlock(userContent, sourceBody);
|
|
160
|
+
if (next !== userContent) {
|
|
161
|
+
writeFileSync(targetPath, next);
|
|
162
|
+
copied.push(fileName);
|
|
163
|
+
}
|
|
164
|
+
sources[fileName] = 'preserved-user-file';
|
|
165
|
+
return { copied, sources };
|
|
166
|
+
}
|
package/c4/secrets.mjs
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* c4/secrets.mjs — GitHub token resolution and masking helpers.
|
|
3
|
+
*
|
|
4
|
+
* The same token is reused for two purposes (user-confirmed):
|
|
5
|
+
* 1. Git authentication (insteadOf rewrite + credential.helper store + gh login)
|
|
6
|
+
* 2. ghl-ai MCP Bearer auth (Authorization header on JSON-RPC requests)
|
|
7
|
+
*
|
|
8
|
+
* Resolution rules:
|
|
9
|
+
* - Prefer GITHUB_PAT (canonical name in pilot bootstraps).
|
|
10
|
+
* - Fall back to GITHUB_TOKEN (some harness UIs only expose this name).
|
|
11
|
+
* - Treat empty/whitespace-only values as absent (defensive — env UIs
|
|
12
|
+
* sometimes echo trailing newlines or leave the field literally blank).
|
|
13
|
+
* - Both empty ⇒ { token: null, source: null }; orchestrator emits a
|
|
14
|
+
* one-line skip and lets the agent continue (exit 0).
|
|
15
|
+
*
|
|
16
|
+
* @typedef {'GITHUB_PAT' | 'GITHUB_TOKEN' | null} TokenSource
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const ELLIPSIS = '\u2026';
|
|
20
|
+
const MASK_PREFIX_LEN = 8;
|
|
21
|
+
|
|
22
|
+
function readEnvVar(env, name) {
|
|
23
|
+
const raw = env?.[name];
|
|
24
|
+
if (typeof raw !== 'string') return '';
|
|
25
|
+
return raw.trim();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
30
|
+
* @returns {{ token: string | null, source: TokenSource }}
|
|
31
|
+
*/
|
|
32
|
+
export function getGithubToken(env = process.env) {
|
|
33
|
+
const pat = readEnvVar(env, 'GITHUB_PAT');
|
|
34
|
+
if (pat) return { token: pat, source: 'GITHUB_PAT' };
|
|
35
|
+
|
|
36
|
+
const token = readEnvVar(env, 'GITHUB_TOKEN');
|
|
37
|
+
if (token) return { token, source: 'GITHUB_TOKEN' };
|
|
38
|
+
|
|
39
|
+
return { token: null, source: null };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Mask a token for log/diagnostic display. Reveals the first 8 chars (enough
|
|
44
|
+
* to identify the prefix family — `ghp_`, `ghs_`, `gho_`, etc.) followed by
|
|
45
|
+
* a single Unicode ellipsis. Never returns the full token, even for short
|
|
46
|
+
* inputs (we still append `…` so the value is visibly redacted).
|
|
47
|
+
*
|
|
48
|
+
* @param {unknown} token
|
|
49
|
+
* @returns {string}
|
|
50
|
+
*/
|
|
51
|
+
export function maskToken(token) {
|
|
52
|
+
if (typeof token !== 'string' || token.length === 0) return '';
|
|
53
|
+
if (token.length <= MASK_PREFIX_LEN) return token + ELLIPSIS;
|
|
54
|
+
return token.slice(0, MASK_PREFIX_LEN) + ELLIPSIS;
|
|
55
|
+
}
|