@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/index.mjs
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* c4/index.mjs — public barrel.
|
|
3
|
+
*
|
|
4
|
+
* Re-exports every named symbol from every c4/ module so the orchestrator
|
|
5
|
+
* (and tests, and downstream tools that want to compose c4 primitives)
|
|
6
|
+
* can do:
|
|
7
|
+
*
|
|
8
|
+
* import * as c4 from './c4/index.mjs';
|
|
9
|
+
* c4.detectHarness();
|
|
10
|
+
* c4.runPreflight({...});
|
|
11
|
+
* c4.summarizeAsOneLine({...});
|
|
12
|
+
*
|
|
13
|
+
* Strict re-exports only — no logic, no defaults, no aggregations. Adding
|
|
14
|
+
* a new c4 primitive means adding one line here.
|
|
15
|
+
*
|
|
16
|
+
* Contract: tasks.md::4.3.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export { detectHarness, HARNESSES } from './detect.mjs';
|
|
20
|
+
export { getGithubToken, maskToken } from './secrets.mjs';
|
|
21
|
+
export { ensureGhCli } from './ghCli.mjs';
|
|
22
|
+
export {
|
|
23
|
+
clearAllGitHubAuthState,
|
|
24
|
+
installPatInsteadOf,
|
|
25
|
+
installCredentialHelperStore,
|
|
26
|
+
ghAuthLoginWithToken,
|
|
27
|
+
ghAuthSetupGit,
|
|
28
|
+
ensureOriginRemote,
|
|
29
|
+
verifyAuth,
|
|
30
|
+
preflightPlatformDocs,
|
|
31
|
+
} from './gitAuth.mjs';
|
|
32
|
+
export { applyEccRegistryBridge } from './eccRegistryBridge.mjs';
|
|
33
|
+
export {
|
|
34
|
+
SLIM_CARD_HARD_CEILING_BYTES,
|
|
35
|
+
REQUIRED_ENFORCEMENT_PHRASES,
|
|
36
|
+
REQUIRED_STAGE_MARKERS_CLAUDE,
|
|
37
|
+
REQUIRED_STAGE_MARKERS_CURSOR,
|
|
38
|
+
SLIM_CARD_CLAUDE,
|
|
39
|
+
SLIM_CARD_CURSOR,
|
|
40
|
+
buildSlimRouterCard,
|
|
41
|
+
installSlimRouter,
|
|
42
|
+
} from './slimRouter.mjs';
|
|
43
|
+
export { defaultHookPath, installCodexPromptInjector } from './codexPromptInjector.mjs';
|
|
44
|
+
export {
|
|
45
|
+
MCP_URL_DEFAULT as CODEX_MCP_URL_DEFAULT,
|
|
46
|
+
ensureCodexHooksFlag,
|
|
47
|
+
ensureCodexMcpServer,
|
|
48
|
+
} from './codexConfig.mjs';
|
|
49
|
+
export { MCP_URL_DEFAULT, registerGhlAiMcp } from './mcpServer.mjs';
|
|
50
|
+
export { probeMcpServer } from './mcpSmokeProbe.mjs';
|
|
51
|
+
export { ensureClaudeMarketplace } from './claudePluginRegistry.mjs';
|
|
52
|
+
export { ensureCommandSurface, diagnoseCommandResolution } from './commandSurface.mjs';
|
|
53
|
+
export { ensureRepoLocalClaudeSettings } from './repoLocalClaudeSettings.mjs';
|
|
54
|
+
export { copyRepoRootInstructions } from './repoRootInstructions.mjs';
|
|
55
|
+
export { ensureRepoLocalIgnore } from './repoLocalIgnore.mjs';
|
|
56
|
+
export { jsonMergeWithDedup, claudeHooksMerge } from './jsonMerge.mjs';
|
|
57
|
+
export { runPreflight } from './preflight.mjs';
|
|
58
|
+
export {
|
|
59
|
+
summarizeAsOneLine,
|
|
60
|
+
diagnoseAwRouterView,
|
|
61
|
+
diagnosePromptRouterInjection,
|
|
62
|
+
diagnoseSkillResolution,
|
|
63
|
+
dumpPostInitState,
|
|
64
|
+
} from './diagnostics.mjs';
|
package/c4/jsonMerge.mjs
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* c4/jsonMerge.mjs — JSON-config merge helpers used by harness installers.
|
|
3
|
+
*
|
|
4
|
+
* Two flavors are exported because harness shapes diverge:
|
|
5
|
+
* - jsonMergeWithDedup — flat array-of-entries (Cursor hooks.json, mcp.json,
|
|
6
|
+
* Claude mcpServers, permissions.allow extension).
|
|
7
|
+
* - claudeHooksMerge — Claude's nested hook-groups: each event maps to an
|
|
8
|
+
* array of GROUPS, each group has matcher? + inner
|
|
9
|
+
* hooks[]. Stripping AW entries means filtering inner
|
|
10
|
+
* hooks[] and dropping groups that become empty.
|
|
11
|
+
*
|
|
12
|
+
* Both perform an atomic write (tmp file + rename) and chmod the final file
|
|
13
|
+
* to 0600. Idempotency is achieved by stripping pre-existing entries that
|
|
14
|
+
* match a `commandPatterns` substring list before appending the new entry,
|
|
15
|
+
* then comparing serialized JSON before writing.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync, readFileSync, writeFileSync, renameSync, chmodSync, unlinkSync } from 'node:fs';
|
|
19
|
+
import { dirname, basename, join } from 'node:path';
|
|
20
|
+
|
|
21
|
+
const FILE_MODE = 0o600;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {string} pointer RFC 6901 pointer, e.g. "/hooks/sessionStart"
|
|
25
|
+
* @returns {string[]} Path tokens (empty array for "/")
|
|
26
|
+
*/
|
|
27
|
+
function parseJsonPointer(pointer) {
|
|
28
|
+
if (pointer === '' || pointer === '/') return [];
|
|
29
|
+
if (!pointer.startsWith('/')) {
|
|
30
|
+
throw new Error(`Invalid JSON pointer: ${pointer} (must start with "/")`);
|
|
31
|
+
}
|
|
32
|
+
return pointer
|
|
33
|
+
.slice(1)
|
|
34
|
+
.split('/')
|
|
35
|
+
.map((token) => token.replace(/~1/g, '/').replace(/~0/g, '~'));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Walk a JSON pointer to its parent and key, creating intermediate objects.
|
|
40
|
+
* Returns { parent, key } so the caller can read/write `parent[key]`.
|
|
41
|
+
*/
|
|
42
|
+
function walkAndCreate(root, tokens) {
|
|
43
|
+
if (tokens.length === 0) {
|
|
44
|
+
throw new Error('jsonMergeWithDedup: pointer must target a child key, not the root');
|
|
45
|
+
}
|
|
46
|
+
let cursor = root;
|
|
47
|
+
for (let i = 0; i < tokens.length - 1; i += 1) {
|
|
48
|
+
const t = tokens[i];
|
|
49
|
+
if (cursor[t] == null || typeof cursor[t] !== 'object' || Array.isArray(cursor[t])) {
|
|
50
|
+
cursor[t] = {};
|
|
51
|
+
}
|
|
52
|
+
cursor = cursor[t];
|
|
53
|
+
}
|
|
54
|
+
return { parent: cursor, key: tokens[tokens.length - 1] };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Atomically write JSON to `filePath` with mode 0600.
|
|
59
|
+
* No-op if `serialized` matches the existing file content byte-for-byte.
|
|
60
|
+
*
|
|
61
|
+
* @returns {boolean} true if the file changed, false if no write occurred.
|
|
62
|
+
*/
|
|
63
|
+
function atomicWriteIfChanged(filePath, serialized) {
|
|
64
|
+
if (existsSync(filePath)) {
|
|
65
|
+
const prev = readFileSync(filePath, 'utf8');
|
|
66
|
+
if (prev === serialized) return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const tmp = join(
|
|
70
|
+
dirname(filePath),
|
|
71
|
+
`.${basename(filePath)}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
writeFileSync(tmp, serialized, { mode: FILE_MODE });
|
|
76
|
+
renameSync(tmp, filePath);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
if (existsSync(tmp)) {
|
|
79
|
+
try { unlinkSync(tmp); } catch { /* best-effort */ }
|
|
80
|
+
}
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try { chmodSync(filePath, FILE_MODE); } catch { /* best-effort */ }
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function readJsonOrEmpty(filePath) {
|
|
89
|
+
if (!existsSync(filePath)) return {};
|
|
90
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
91
|
+
if (raw.trim() === '') return {};
|
|
92
|
+
try {
|
|
93
|
+
return JSON.parse(raw);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
throw new Error(`Failed to parse JSON at ${filePath}: ${err.message}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function commandMatchesAnyPattern(command, patterns) {
|
|
100
|
+
if (typeof command !== 'string') return false;
|
|
101
|
+
return patterns.some((p) => typeof p === 'string' && p.length > 0 && command.includes(p));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Merge a flat array-of-entries JSON config with command-pattern dedup.
|
|
106
|
+
*
|
|
107
|
+
* @param {object} opts
|
|
108
|
+
* @param {string} opts.filePath
|
|
109
|
+
* @param {string} opts.jsonPointer RFC 6901 pointer to the array.
|
|
110
|
+
* @param {object} opts.newEntry Entry to append after dedup.
|
|
111
|
+
* @param {string[]} opts.commandPatterns Substrings; existing entries with
|
|
112
|
+
* a matching `command` field are stripped.
|
|
113
|
+
* @returns {{ changed: boolean, prevEntriesRemoved: number }}
|
|
114
|
+
*/
|
|
115
|
+
export function jsonMergeWithDedup({ filePath, jsonPointer, newEntry, commandPatterns }) {
|
|
116
|
+
if (!filePath || typeof filePath !== 'string') throw new Error('filePath is required');
|
|
117
|
+
if (typeof jsonPointer !== 'string') throw new Error('jsonPointer is required');
|
|
118
|
+
if (newEntry == null || typeof newEntry !== 'object') throw new Error('newEntry must be an object');
|
|
119
|
+
const patterns = Array.isArray(commandPatterns) ? commandPatterns : [];
|
|
120
|
+
|
|
121
|
+
const root = readJsonOrEmpty(filePath);
|
|
122
|
+
const tokens = parseJsonPointer(jsonPointer);
|
|
123
|
+
const { parent, key } = walkAndCreate(root, tokens);
|
|
124
|
+
|
|
125
|
+
const existing = parent[key];
|
|
126
|
+
let arr;
|
|
127
|
+
if (existing == null) {
|
|
128
|
+
arr = [];
|
|
129
|
+
} else if (Array.isArray(existing)) {
|
|
130
|
+
arr = existing;
|
|
131
|
+
} else {
|
|
132
|
+
throw new Error(`jsonMergeWithDedup: expected array at pointer ${jsonPointer}, got ${typeof existing}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const before = arr.length;
|
|
136
|
+
const filtered = arr.filter((entry) => {
|
|
137
|
+
if (entry == null || typeof entry !== 'object') return true;
|
|
138
|
+
return !commandMatchesAnyPattern(entry.command, patterns);
|
|
139
|
+
});
|
|
140
|
+
const prevEntriesRemoved = before - filtered.length;
|
|
141
|
+
|
|
142
|
+
filtered.push(newEntry);
|
|
143
|
+
parent[key] = filtered;
|
|
144
|
+
|
|
145
|
+
const serialized = JSON.stringify(root, null, 2) + '\n';
|
|
146
|
+
const changed = atomicWriteIfChanged(filePath, serialized);
|
|
147
|
+
|
|
148
|
+
return { changed, prevEntriesRemoved };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Merge a hook-group entry into Claude's nested settings.json shape.
|
|
153
|
+
*
|
|
154
|
+
* settings.hooks[eventName] = [
|
|
155
|
+
* { matcher?, description?, hooks: [{ type:'command', command, description }, ...] },
|
|
156
|
+
* ...
|
|
157
|
+
* ]
|
|
158
|
+
*
|
|
159
|
+
* Strips inner hooks[] entries by command-pattern, drops groups whose inner
|
|
160
|
+
* hooks[] becomes empty, then appends a fresh group for the new entry.
|
|
161
|
+
*
|
|
162
|
+
* @param {object} opts
|
|
163
|
+
* @param {string} opts.settingsPath
|
|
164
|
+
* @param {string} opts.eventName 'SessionStart' | 'UserPromptSubmit' | ...
|
|
165
|
+
* @param {string} [opts.matcher] Optional matcher string for the new group.
|
|
166
|
+
* @param {string} opts.command Command for the new inner hook entry.
|
|
167
|
+
* @param {string} [opts.description] Description (used at both group + inner levels).
|
|
168
|
+
* @param {string[]} opts.commandPatterns Substrings to strip prior entries.
|
|
169
|
+
* @returns {{ changed: boolean, prevGroupsRemoved: number }}
|
|
170
|
+
*/
|
|
171
|
+
export function claudeHooksMerge({
|
|
172
|
+
settingsPath,
|
|
173
|
+
eventName,
|
|
174
|
+
matcher,
|
|
175
|
+
command,
|
|
176
|
+
description,
|
|
177
|
+
commandPatterns,
|
|
178
|
+
}) {
|
|
179
|
+
if (!settingsPath || typeof settingsPath !== 'string') throw new Error('settingsPath is required');
|
|
180
|
+
if (!eventName || typeof eventName !== 'string') throw new Error('eventName is required');
|
|
181
|
+
if (!command || typeof command !== 'string') throw new Error('command is required');
|
|
182
|
+
const patterns = Array.isArray(commandPatterns) ? commandPatterns : [];
|
|
183
|
+
|
|
184
|
+
const root = readJsonOrEmpty(settingsPath);
|
|
185
|
+
if (root.hooks == null || typeof root.hooks !== 'object' || Array.isArray(root.hooks)) {
|
|
186
|
+
root.hooks = {};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const existingGroups = Array.isArray(root.hooks[eventName]) ? root.hooks[eventName] : [];
|
|
190
|
+
|
|
191
|
+
let prevGroupsRemoved = 0;
|
|
192
|
+
const survivingGroups = [];
|
|
193
|
+
for (const group of existingGroups) {
|
|
194
|
+
if (group == null || typeof group !== 'object') {
|
|
195
|
+
survivingGroups.push(group);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const innerHooks = Array.isArray(group.hooks) ? group.hooks : [];
|
|
199
|
+
const filteredInner = innerHooks.filter((h) => {
|
|
200
|
+
if (h == null || typeof h !== 'object') return true;
|
|
201
|
+
return !commandMatchesAnyPattern(h.command, patterns);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
if (filteredInner.length === 0 && innerHooks.length > 0) {
|
|
205
|
+
prevGroupsRemoved += 1;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
survivingGroups.push({ ...group, hooks: filteredInner });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const newGroup = {
|
|
212
|
+
...(matcher !== undefined ? { matcher } : {}),
|
|
213
|
+
...(description !== undefined ? { description } : {}),
|
|
214
|
+
hooks: [
|
|
215
|
+
{
|
|
216
|
+
type: 'command',
|
|
217
|
+
command,
|
|
218
|
+
...(description !== undefined ? { description } : {}),
|
|
219
|
+
},
|
|
220
|
+
],
|
|
221
|
+
};
|
|
222
|
+
survivingGroups.push(newGroup);
|
|
223
|
+
root.hooks[eventName] = survivingGroups;
|
|
224
|
+
|
|
225
|
+
const serialized = JSON.stringify(root, null, 2) + '\n';
|
|
226
|
+
const changed = atomicWriteIfChanged(settingsPath, serialized);
|
|
227
|
+
|
|
228
|
+
return { changed, prevGroupsRemoved };
|
|
229
|
+
}
|
package/c4/mcpServer.mjs
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* c4/mcpServer.mjs — register the `ghl-ai` MCP server across all three
|
|
3
|
+
* harnesses with the canonical MCP_URL_DEFAULT and Bearer auth.
|
|
4
|
+
*
|
|
5
|
+
* Per harness, the MCP config lives in a different file:
|
|
6
|
+
* - claude-web → ~/.claude/settings.json::mcpServers["ghl-ai"]
|
|
7
|
+
* - cursor-cloud → ~/.cursor/mcp.json::mcpServers["ghl-ai"] (separate file!)
|
|
8
|
+
* - codex-web → ~/.codex/config.toml::[mcp_servers.ghl-ai] (delegates to codexConfig)
|
|
9
|
+
*
|
|
10
|
+
* URL is overridable via env MCP_URL for staging environments. Bearer token
|
|
11
|
+
* is the harness's GitHub PAT (resolved upstream by secrets.mjs). Idempotent
|
|
12
|
+
* via deep-equality byte compare in the underlying writer.
|
|
13
|
+
*
|
|
14
|
+
* Contract: spec.md::§"c4/mcpServer.mjs", tasks.md::3.4.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
existsSync,
|
|
19
|
+
readFileSync,
|
|
20
|
+
writeFileSync,
|
|
21
|
+
renameSync,
|
|
22
|
+
chmodSync,
|
|
23
|
+
mkdirSync,
|
|
24
|
+
unlinkSync,
|
|
25
|
+
} from 'node:fs';
|
|
26
|
+
import { dirname, basename, join } from 'node:path';
|
|
27
|
+
import { ensureCodexMcpServer } from './codexConfig.mjs';
|
|
28
|
+
|
|
29
|
+
export const MCP_URL_DEFAULT =
|
|
30
|
+
'https://services.leadconnectorhq.com/agentic-workspace/mcp';
|
|
31
|
+
|
|
32
|
+
const FILE_MODE = 0o600;
|
|
33
|
+
|
|
34
|
+
function resolveUrl(opts) {
|
|
35
|
+
return opts?.mcpUrl ?? process.env.MCP_URL ?? MCP_URL_DEFAULT;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function ensureDir(d) {
|
|
39
|
+
mkdirSync(d, { recursive: true });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readJsonOrEmpty(filePath) {
|
|
43
|
+
if (!existsSync(filePath)) return {};
|
|
44
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
45
|
+
if (raw.trim() === '') return {};
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(raw);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
throw new Error(`Failed to parse JSON at ${filePath}: ${err.message}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function atomicWriteIfChanged(filePath, serialized) {
|
|
54
|
+
if (existsSync(filePath)) {
|
|
55
|
+
const prev = readFileSync(filePath, 'utf8');
|
|
56
|
+
if (prev === serialized) return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
ensureDir(dirname(filePath));
|
|
60
|
+
|
|
61
|
+
const tmp = join(
|
|
62
|
+
dirname(filePath),
|
|
63
|
+
`.${basename(filePath)}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
writeFileSync(tmp, serialized, { mode: FILE_MODE });
|
|
68
|
+
renameSync(tmp, filePath);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
if (existsSync(tmp)) {
|
|
71
|
+
try { unlinkSync(tmp); } catch { /* best-effort */ }
|
|
72
|
+
}
|
|
73
|
+
throw err;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try { chmodSync(filePath, FILE_MODE); } catch { /* best-effort */ }
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Mutate root.mcpServers["ghl-ai"] for Claude/Cursor JSON-shaped configs.
|
|
82
|
+
* Other entries in mcpServers are preserved verbatim.
|
|
83
|
+
*/
|
|
84
|
+
function setGhlAiInJsonConfig({ filePath, url, token }) {
|
|
85
|
+
const root = readJsonOrEmpty(filePath);
|
|
86
|
+
if (
|
|
87
|
+
root.mcpServers == null ||
|
|
88
|
+
typeof root.mcpServers !== 'object' ||
|
|
89
|
+
Array.isArray(root.mcpServers)
|
|
90
|
+
) {
|
|
91
|
+
root.mcpServers = {};
|
|
92
|
+
}
|
|
93
|
+
const prevHeaders = root.mcpServers['ghl-ai']?.headers;
|
|
94
|
+
root.mcpServers['ghl-ai'] = {
|
|
95
|
+
type: 'http',
|
|
96
|
+
url,
|
|
97
|
+
headers: {
|
|
98
|
+
...(typeof prevHeaders === 'object' && prevHeaders !== null && !Array.isArray(prevHeaders)
|
|
99
|
+
? prevHeaders
|
|
100
|
+
: {}),
|
|
101
|
+
Authorization: `Bearer ${token}`,
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const serialized = JSON.stringify(root, null, 2) + '\n';
|
|
106
|
+
return atomicWriteIfChanged(filePath, serialized);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Register the ghl-ai MCP server in the harness-appropriate config file.
|
|
111
|
+
*
|
|
112
|
+
* @param {'claude-web'|'cursor-cloud'|'codex-web'} harness
|
|
113
|
+
* @param {string} home
|
|
114
|
+
* @param {string} token
|
|
115
|
+
* @param {{ mcpUrl?: string }} [opts]
|
|
116
|
+
* @returns {{ configPath: string, changed: boolean }}
|
|
117
|
+
*/
|
|
118
|
+
export function registerGhlAiMcp(harness, home, token, opts = {}) {
|
|
119
|
+
if (!home || typeof home !== 'string') {
|
|
120
|
+
throw new Error('registerGhlAiMcp: home is required');
|
|
121
|
+
}
|
|
122
|
+
if (!token || typeof token !== 'string') {
|
|
123
|
+
throw new Error('registerGhlAiMcp: token is required');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const url = resolveUrl(opts);
|
|
127
|
+
|
|
128
|
+
if (harness === 'claude-web') {
|
|
129
|
+
const configPath = join(home, '.claude/settings.json');
|
|
130
|
+
const changed = setGhlAiInJsonConfig({ filePath: configPath, url, token });
|
|
131
|
+
return { configPath, changed };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (harness === 'cursor-cloud') {
|
|
135
|
+
const configPath = join(home, '.cursor/mcp.json');
|
|
136
|
+
const changed = setGhlAiInJsonConfig({ filePath: configPath, url, token });
|
|
137
|
+
return { configPath, changed };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (harness === 'codex-web') {
|
|
141
|
+
const configPath = join(home, '.codex/config.toml');
|
|
142
|
+
// codexConfig honors process.env.MCP_URL itself; if a caller passed
|
|
143
|
+
// opts.mcpUrl, propagate via env temporarily to keep the contract simple.
|
|
144
|
+
if (opts.mcpUrl !== undefined) {
|
|
145
|
+
const prev = process.env.MCP_URL;
|
|
146
|
+
process.env.MCP_URL = opts.mcpUrl;
|
|
147
|
+
try {
|
|
148
|
+
const { changed } = ensureCodexMcpServer(home, token);
|
|
149
|
+
return { configPath, changed };
|
|
150
|
+
} finally {
|
|
151
|
+
if (prev === undefined) delete process.env.MCP_URL;
|
|
152
|
+
else process.env.MCP_URL = prev;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const { changed } = ensureCodexMcpServer(home, token);
|
|
156
|
+
return { configPath, changed };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
throw new Error(`registerGhlAiMcp: unsupported harness "${harness}"`);
|
|
160
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* c4/mcpSmokeProbe.mjs — best-effort live MCP Bearer probe (G5).
|
|
3
|
+
*
|
|
4
|
+
* Why: writing the MCP config (mcpServer.mjs) confirms the JSON shape, not
|
|
5
|
+
* that the server validates the Bearer header against this PAT's scopes. A
|
|
6
|
+
* user with a scope-limited PAT sees green install and red MCP calls at
|
|
7
|
+
* runtime. This probe surfaces that gap in the install log.
|
|
8
|
+
*
|
|
9
|
+
* Best-effort: NEVER throws on transport / response failures. Argument
|
|
10
|
+
* validation errors (missing url/token) DO throw because they are caller
|
|
11
|
+
* bugs, not server failures.
|
|
12
|
+
*
|
|
13
|
+
* Returns one of:
|
|
14
|
+
* - 'ok' 200 + valid JSON-RPC tools/list response
|
|
15
|
+
* - 'invalid-token' 401
|
|
16
|
+
* - 'unauthorized' 403 (PAT lacks scope)
|
|
17
|
+
* - 'unreachable' timeout / network error
|
|
18
|
+
* - 'unknown' 200 with unexpected body, 426 Upgrade, SSE handshake,
|
|
19
|
+
* generic non-2xx, or any unrecognized condition
|
|
20
|
+
*
|
|
21
|
+
* Contract: spec.md::§"c4/mcpSmokeProbe.mjs", tasks.md::3.8.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const DEFAULT_TIMEOUT_MS = 5000;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {object} McpSmokeResult
|
|
28
|
+
* @property {string} url
|
|
29
|
+
* @property {boolean} reachable HTTP response was received (any status).
|
|
30
|
+
* @property {number} [toolCount] Number of tools when authStatus === 'ok'.
|
|
31
|
+
* @property {'ok'|'invalid-token'|'unauthorized'|'unreachable'|'unknown'} authStatus
|
|
32
|
+
* @property {string} [error] Protocol-mismatch / status excerpt.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Probe an MCP HTTP endpoint with a JSON-RPC tools/list request.
|
|
37
|
+
*
|
|
38
|
+
* @param {object} opts
|
|
39
|
+
* @param {string} opts.url
|
|
40
|
+
* @param {string} opts.token
|
|
41
|
+
* @param {number} [opts.timeoutMs]
|
|
42
|
+
* @param {(input: string, init?: RequestInit) => Promise<Response>} [opts.fetchImpl]
|
|
43
|
+
* Optional fetch override; defaults to globalThis.fetch.
|
|
44
|
+
* @returns {Promise<McpSmokeResult>}
|
|
45
|
+
*/
|
|
46
|
+
export async function probeMcpServer(opts) {
|
|
47
|
+
if (!opts || typeof opts !== 'object') {
|
|
48
|
+
throw new Error('probeMcpServer: opts object is required');
|
|
49
|
+
}
|
|
50
|
+
const { url, token } = opts;
|
|
51
|
+
if (!url || typeof url !== 'string') {
|
|
52
|
+
throw new Error('probeMcpServer: opts.url is required');
|
|
53
|
+
}
|
|
54
|
+
if (!token || typeof token !== 'string') {
|
|
55
|
+
throw new Error('probeMcpServer: opts.token is required');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const timeoutMs = typeof opts.timeoutMs === 'number' && opts.timeoutMs > 0
|
|
59
|
+
? opts.timeoutMs
|
|
60
|
+
: DEFAULT_TIMEOUT_MS;
|
|
61
|
+
const fetchImpl = typeof opts.fetchImpl === 'function'
|
|
62
|
+
? opts.fetchImpl
|
|
63
|
+
: globalThis.fetch;
|
|
64
|
+
if (typeof fetchImpl !== 'function') {
|
|
65
|
+
return { url, reachable: false, authStatus: 'unreachable', error: 'fetch unavailable' };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const ac = new AbortController();
|
|
69
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
70
|
+
|
|
71
|
+
let response;
|
|
72
|
+
try {
|
|
73
|
+
response = await fetchImpl(url, {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
headers: {
|
|
76
|
+
'Content-Type': 'application/json',
|
|
77
|
+
Authorization: `Bearer ${token}`,
|
|
78
|
+
},
|
|
79
|
+
body: JSON.stringify({
|
|
80
|
+
jsonrpc: '2.0',
|
|
81
|
+
id: 1,
|
|
82
|
+
method: 'tools/list',
|
|
83
|
+
params: {},
|
|
84
|
+
}),
|
|
85
|
+
signal: ac.signal,
|
|
86
|
+
});
|
|
87
|
+
} catch (err) {
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
return classifyTransportError(url, err);
|
|
90
|
+
}
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
|
|
93
|
+
if (response == null || typeof response !== 'object') {
|
|
94
|
+
return { url, reachable: false, authStatus: 'unknown', error: 'no response object' };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return await classifyResponse(url, response);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Map a thrown transport error to a structured result.
|
|
102
|
+
*/
|
|
103
|
+
function classifyTransportError(url, err) {
|
|
104
|
+
const name = err?.name ?? '';
|
|
105
|
+
const msg = err?.message ?? String(err);
|
|
106
|
+
if (name === 'AbortError') {
|
|
107
|
+
return { url, reachable: false, authStatus: 'unreachable', error: `timeout: ${msg}` };
|
|
108
|
+
}
|
|
109
|
+
// Most undici / native fetch network errors throw TypeError("fetch failed").
|
|
110
|
+
return { url, reachable: false, authStatus: 'unreachable', error: msg };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Map an HTTP response to a structured result.
|
|
115
|
+
*/
|
|
116
|
+
async function classifyResponse(url, response) {
|
|
117
|
+
const status = typeof response.status === 'number' ? response.status : 0;
|
|
118
|
+
const contentType = readHeader(response, 'content-type') ?? '';
|
|
119
|
+
|
|
120
|
+
// Streamable-HTTP / SSE: documented fallback per spec §"Streamable-HTTP fallback path".
|
|
121
|
+
if (status === 426 || contentType.includes('text/event-stream')) {
|
|
122
|
+
return {
|
|
123
|
+
url,
|
|
124
|
+
reachable: true,
|
|
125
|
+
authStatus: 'unknown',
|
|
126
|
+
error: `streamable-http transport detected (status=${status}; content-type=${contentType || 'unknown'})`,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (status === 401) return { url, reachable: true, authStatus: 'invalid-token' };
|
|
131
|
+
if (status === 403) return { url, reachable: true, authStatus: 'unauthorized' };
|
|
132
|
+
|
|
133
|
+
if (status >= 200 && status < 300) {
|
|
134
|
+
let bodyText = '';
|
|
135
|
+
try {
|
|
136
|
+
bodyText = await response.text();
|
|
137
|
+
} catch (err) {
|
|
138
|
+
return {
|
|
139
|
+
url,
|
|
140
|
+
reachable: true,
|
|
141
|
+
authStatus: 'unknown',
|
|
142
|
+
error: `body read failed: ${err?.message ?? String(err)}`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
let parsed;
|
|
146
|
+
try {
|
|
147
|
+
parsed = JSON.parse(bodyText);
|
|
148
|
+
} catch {
|
|
149
|
+
return {
|
|
150
|
+
url,
|
|
151
|
+
reachable: true,
|
|
152
|
+
authStatus: 'unknown',
|
|
153
|
+
error: `unexpected body shape (not JSON): ${truncate(bodyText, 80)}`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
const tools = parsed?.result?.tools;
|
|
157
|
+
if (!Array.isArray(tools)) {
|
|
158
|
+
return {
|
|
159
|
+
url,
|
|
160
|
+
reachable: true,
|
|
161
|
+
authStatus: 'unknown',
|
|
162
|
+
error: `unexpected body shape (missing result.tools)`,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
return { url, reachable: true, authStatus: 'ok', toolCount: tools.length };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Any other status is unknown — surface what we have for the operator.
|
|
169
|
+
return {
|
|
170
|
+
url,
|
|
171
|
+
reachable: true,
|
|
172
|
+
authStatus: 'unknown',
|
|
173
|
+
error: `status=${status}; content-type=${contentType || 'unknown'}`,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Read a header from a Response-like object regardless of whether headers is
|
|
179
|
+
* a Headers instance, a Map, or a plain object.
|
|
180
|
+
*/
|
|
181
|
+
function readHeader(response, name) {
|
|
182
|
+
const target = name.toLowerCase();
|
|
183
|
+
const h = response.headers;
|
|
184
|
+
if (!h) return null;
|
|
185
|
+
if (typeof h.get === 'function') {
|
|
186
|
+
const v = h.get(target) ?? h.get(name);
|
|
187
|
+
return v == null ? null : String(v);
|
|
188
|
+
}
|
|
189
|
+
if (h instanceof Map) {
|
|
190
|
+
return h.get(target) ?? h.get(name) ?? null;
|
|
191
|
+
}
|
|
192
|
+
for (const k of Object.keys(h)) {
|
|
193
|
+
if (k.toLowerCase() === target) return String(h[k]);
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function truncate(s, n) {
|
|
199
|
+
if (typeof s !== 'string') return '';
|
|
200
|
+
return s.length > n ? `${s.slice(0, n)}…` : s;
|
|
201
|
+
}
|