@geraldmaron/construct 1.3.0 → 1.3.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/lib/certification/artifact-gates.mjs +6 -1
- package/lib/doctor/cli.mjs +12 -2
- package/lib/doctor/watchers/consistency.mjs +62 -25
- package/lib/mcp/server.mjs +1 -1
- package/lib/mcp-manager.mjs +23 -18
- package/lib/mcp-platform-config.mjs +26 -8
- package/package.json +1 -1
- package/scripts/sync-specialists.mjs +12 -5
|
@@ -53,11 +53,16 @@ export function validateAllGoldenArtifactGates({ rootDir } = {}) {
|
|
|
53
53
|
return { pass: errors.length === 0, results, errors, matrix: artifactGateMatrix({ rootDir: root }) };
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
// The committed gate matrix is a deterministic projection of the rootDir; no
|
|
57
|
+
// wall-clock field, so regenerating it (the artifact-gates test writes it on every
|
|
58
|
+
// run) yields a byte-identical file instead of a timestamp-only diff on a tracked
|
|
59
|
+
// artifact. The matrix content is the signal; when it changes, the diff is real.
|
|
60
|
+
|
|
56
61
|
export function writeArtifactGateMatrixDoc({ rootDir } = {}) {
|
|
57
62
|
const root = findConstructRoot(rootDir);
|
|
58
63
|
const out = path.join(root, 'tests', 'certification', 'artifacts', 'gate-matrix.json');
|
|
59
64
|
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
60
|
-
const payload = {
|
|
65
|
+
const payload = { matrix: artifactGateMatrix({ rootDir: root }) };
|
|
61
66
|
fs.writeFileSync(out, `${JSON.stringify(payload, null, 2)}\n`);
|
|
62
67
|
return out;
|
|
63
68
|
}
|
package/lib/doctor/cli.mjs
CHANGED
|
@@ -101,16 +101,26 @@ export async function runCli(args) {
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
if (sub === 'consistency') {
|
|
104
|
+
const strict = args.some((a) => a === '--strict' || a === '--all' || a === '--debug');
|
|
104
105
|
const { runAllChecks } = await import('./watchers/consistency.mjs');
|
|
105
106
|
const result = await runAllChecks();
|
|
106
107
|
const blocking = result.findings.filter((f) => f.severity === 'blocking');
|
|
107
108
|
const warnings = result.findings.filter((f) => f.severity === 'warning');
|
|
108
109
|
|
|
110
|
+
// Default output carries only user-actionable signal; package/maintainer drift
|
|
111
|
+
// (tier 'internal') is gated behind --strict so a clean install reads clean.
|
|
112
|
+
|
|
113
|
+
const visibleWarnings = strict ? warnings : warnings.filter((f) => f.tier !== 'internal');
|
|
114
|
+
const internalWarnings = warnings.filter((f) => f.tier === 'internal');
|
|
115
|
+
|
|
109
116
|
for (const p of result.passed) console.log(` ✓ ${p.category.padEnd(18)} ${p.summary}`);
|
|
110
|
-
for (const w of
|
|
117
|
+
for (const w of visibleWarnings) console.log(` ⚠ ${w.category.padEnd(18)} ${w.summary}`);
|
|
111
118
|
for (const b of blocking) console.log(` ✗ ${b.category.padEnd(18)} ${b.summary}`);
|
|
112
119
|
|
|
113
|
-
console.log(`\n${result.passed.length} category(s) clean, ${
|
|
120
|
+
console.log(`\n${result.passed.length} category(s) clean, ${visibleWarnings.length} warning(s), ${blocking.length} blocking finding(s)`);
|
|
121
|
+
if (!strict && internalWarnings.length > 0) {
|
|
122
|
+
console.log(` (+${internalWarnings.length} package-internal diagnostic(s) — run \`construct doctor consistency --strict\` to view)`);
|
|
123
|
+
}
|
|
114
124
|
return blocking.length > 0 ? 1 : 0;
|
|
115
125
|
}
|
|
116
126
|
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* directory listings, no network.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import { existsSync, readFileSync,
|
|
22
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
23
23
|
import { loadRegistry } from '../../registry/loader.mjs';
|
|
24
24
|
import { join, dirname, resolve } from 'node:path';
|
|
25
25
|
import os from 'node:os';
|
|
@@ -97,15 +97,32 @@ export async function runAllChecks({ repoRoot = REPO_ROOT } = {}) {
|
|
|
97
97
|
return { findings, passed };
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
// Drift categories split into two operator tiers. `actionable` findings are
|
|
101
|
+
// project state a user can fix and surface by default. `internal` findings are
|
|
102
|
+
// package/maintainer diagnostics about Construct's own registry and MCP wiring —
|
|
103
|
+
// never user-actionable in a consumer project (this watcher resolves REPO_ROOT to
|
|
104
|
+
// the installed package), so they stay behind `doctor consistency --strict`.
|
|
105
|
+
|
|
106
|
+
const CATEGORY_TIERS = {
|
|
107
|
+
'mcp-drift': 'internal',
|
|
108
|
+
'roles-drift': 'internal',
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
function tierFor(category) {
|
|
112
|
+
return CATEGORY_TIERS[category] || 'actionable';
|
|
113
|
+
}
|
|
114
|
+
|
|
100
115
|
function collect(result, findings, passed, defaultCategory) {
|
|
101
116
|
if (result.violations.length === 0) {
|
|
102
|
-
passed.push({ category: defaultCategory, summary: result.summary });
|
|
117
|
+
passed.push({ category: defaultCategory, summary: result.summary, tier: tierFor(defaultCategory) });
|
|
103
118
|
return;
|
|
104
119
|
}
|
|
105
120
|
for (const v of result.violations) {
|
|
121
|
+
const category = v.category || defaultCategory;
|
|
106
122
|
findings.push({
|
|
107
|
-
category
|
|
123
|
+
category,
|
|
108
124
|
severity: v.severity || 'warning',
|
|
125
|
+
tier: v.tier || tierFor(category),
|
|
109
126
|
summary: v.summary,
|
|
110
127
|
target: v.target || null,
|
|
111
128
|
details: v.details || null,
|
|
@@ -184,38 +201,51 @@ function checkMcpDrift({ repoRoot }) {
|
|
|
184
201
|
}
|
|
185
202
|
|
|
186
203
|
const serverSource = readFileSync(serverPath, 'utf8');
|
|
187
|
-
const dispatchedTools = new Set();
|
|
188
|
-
for (const m of serverSource.matchAll(/name === ['"]([a-z][a-z0-9_]*)['"]/g)) {
|
|
189
|
-
dispatchedTools.add(m[1]);
|
|
190
|
-
}
|
|
191
204
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
205
|
+
// Candidate tool handlers are exactly the identifiers server.mjs imports from
|
|
206
|
+
// ./tools/*.mjs. Tool modules also export private helpers (exec, readJson) that
|
|
207
|
+
// are never MCP tools; scanning every `export function` swept those into the
|
|
208
|
+
// signal. Scoping to the server's own imports is the real contract surface.
|
|
209
|
+
|
|
210
|
+
const importedHandlers = new Set();
|
|
211
|
+
for (const m of serverSource.matchAll(/import\s*(?:type\s*)?\{([^}]*)\}\s*from\s*['"]\.\/tools\/[^'"]+['"]/g)) {
|
|
212
|
+
for (const ident of m[1].split(',')) {
|
|
213
|
+
const local = ident.trim().split(/\s+as\s+/).pop().trim();
|
|
214
|
+
if (local) importedHandlers.add(local);
|
|
199
215
|
}
|
|
200
216
|
}
|
|
201
217
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
218
|
+
// A handler is wired when the dispatcher invokes it, regardless of the
|
|
219
|
+
// registered tool name. Handlers follow xxxTool→'xxx' and construct_xxx naming
|
|
220
|
+
// conventions, so matching exported names against `name === '<tool>'` strings is
|
|
221
|
+
// unreliable; matching the invoked identifier is convention-agnostic.
|
|
222
|
+
|
|
223
|
+
const invoked = new Set();
|
|
224
|
+
for (const m of serverSource.matchAll(/\b([A-Za-z_$][\w$]*)\s*\(/g)) {
|
|
225
|
+
invoked.add(m[1]);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
for (const handler of importedHandlers) {
|
|
229
|
+
const snake = camelToSnake(handler);
|
|
230
|
+
if (KNOWN_NON_DISPATCH_TOOLS.has(handler) || KNOWN_NON_DISPATCH_TOOLS.has(snake)) continue;
|
|
231
|
+
if (!invoked.has(handler)) {
|
|
205
232
|
violations.push({
|
|
206
233
|
category: 'mcp-drift',
|
|
207
234
|
severity: 'warning',
|
|
208
|
-
|
|
209
|
-
|
|
235
|
+
tier: 'internal',
|
|
236
|
+
target: snake,
|
|
237
|
+
summary: `MCP tool handler imported but never dispatched: ${handler}`,
|
|
210
238
|
});
|
|
211
239
|
}
|
|
212
240
|
}
|
|
213
241
|
|
|
214
|
-
return { summary: `mcp: ${
|
|
242
|
+
return { summary: `mcp: ${importedHandlers.size} handlers, ${violations.length} drift`, violations };
|
|
215
243
|
}
|
|
216
244
|
|
|
245
|
+
// Handlers the server may import for re-export or test surface but intentionally
|
|
246
|
+
// never dispatches. Matched by camelCase identifier or snake_case form.
|
|
247
|
+
|
|
217
248
|
const KNOWN_NON_DISPATCH_TOOLS = new Set([
|
|
218
|
-
// Internal helpers exported for tests but never wired through MCP.
|
|
219
249
|
'workflow_status_bound',
|
|
220
250
|
'create_needs_main_input_packet',
|
|
221
251
|
]);
|
|
@@ -263,21 +293,28 @@ function personaOwnersFromRegistry(registry) {
|
|
|
263
293
|
owners.get(personaId).add(owner);
|
|
264
294
|
}
|
|
265
295
|
|
|
296
|
+
// One registry entity owns a persona once. A specialist's id and its own name
|
|
297
|
+
// normalize to the same persona by design (cx-architect / architect), so they
|
|
298
|
+
// must share a single owner token keyed on the entity — otherwise every
|
|
299
|
+
// specialist self-collides and reports a spurious "ambiguous" drift. Genuine
|
|
300
|
+
// ambiguity (two distinct specialists, or a specialist and the orchestrator,
|
|
301
|
+
// normalizing to the same persona) still yields owners.size > 1.
|
|
302
|
+
|
|
266
303
|
for (const [specId, spec] of Object.entries(registry?.specialists || {})) {
|
|
267
304
|
addOwner(specId, `specialist:${specId}`);
|
|
268
|
-
addOwner(spec?.name, `specialist:${specId}
|
|
305
|
+
addOwner(spec?.name, `specialist:${specId}`);
|
|
269
306
|
}
|
|
270
307
|
|
|
271
308
|
if (Array.isArray(registry?.specialists)) {
|
|
272
309
|
for (const spec of registry.specialists) {
|
|
273
310
|
const owner = `specialist:${spec?.id || spec?.name || 'unknown'}`;
|
|
274
311
|
addOwner(spec?.id, owner);
|
|
275
|
-
addOwner(spec?.name,
|
|
312
|
+
addOwner(spec?.name, owner);
|
|
276
313
|
}
|
|
277
314
|
}
|
|
278
315
|
|
|
279
|
-
addOwner(registry?.orchestrator?.id, 'orchestrator
|
|
280
|
-
addOwner(registry?.orchestrator?.name, 'orchestrator
|
|
316
|
+
addOwner(registry?.orchestrator?.id, 'orchestrator');
|
|
317
|
+
addOwner(registry?.orchestrator?.name, 'orchestrator');
|
|
281
318
|
return owners;
|
|
282
319
|
}
|
|
283
320
|
|
package/lib/mcp/server.mjs
CHANGED
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
} from './tools/storage.mjs';
|
|
39
39
|
import {
|
|
40
40
|
listSkills, getSkill, searchSkills, getTemplate, listTemplates,
|
|
41
|
-
agentContract, brokerCheck, orchestrationPolicy, workerRun, listTeams,
|
|
41
|
+
agentContract, brokerCheck, orchestrationPolicy, workerRun, listTeams, suggestSkillsTool,
|
|
42
42
|
} from './tools/skills.mjs';
|
|
43
43
|
import {
|
|
44
44
|
workflowInit, workflowAddTask, workflowUpdateTask,
|
package/lib/mcp-manager.mjs
CHANGED
|
@@ -339,35 +339,33 @@ export async function cmdMcpAdd(id) {
|
|
|
339
339
|
}
|
|
340
340
|
}
|
|
341
341
|
|
|
342
|
-
// GitHub
|
|
343
|
-
|
|
342
|
+
// GitHub's hosted remote server uses one-click OAuth by default: the host runs
|
|
343
|
+
// the browser flow and stores the token in its own secure store, so we wire the
|
|
344
|
+
// URL only and keep no credential. `--token`/`--pat` opts into the PAT fallback
|
|
345
|
+
// (headless/CI), sourced from gh CLI or env and emitted as an env reference.
|
|
346
|
+
let authMode = id === 'github' ? 'oauth' : 'pat';
|
|
347
|
+
if (id === 'github' && (flags.token || flags.pat)) {
|
|
348
|
+
authMode = 'pat';
|
|
344
349
|
const envToken = process.env.GITHUB_TOKEN || process.env.GITHUB_PERSONAL_ACCESS_TOKEN;
|
|
345
|
-
const forceToken = flags.token;
|
|
346
|
-
|
|
347
350
|
let ghCliToken = '';
|
|
348
351
|
try {
|
|
349
352
|
execSync('gh auth status -h github.com', { stdio: 'ignore' });
|
|
350
353
|
ghCliToken = execSync('gh auth token -h github.com 2>/dev/null', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
351
354
|
} catch { /* gh not installed or not authed */ }
|
|
352
355
|
|
|
353
|
-
|
|
356
|
+
const best = envToken || ghCliToken;
|
|
357
|
+
if (process.stdin.isTTY && typeof flags.token !== 'string') {
|
|
354
358
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
355
|
-
const best = envToken || ghCliToken;
|
|
356
359
|
const hint = best ? ' [press Enter to use detected token]' : '';
|
|
357
360
|
const value = await prompt(rl, `\n > GITHUB_TOKEN${hint}: `);
|
|
358
361
|
rl.close();
|
|
359
362
|
envValues.GITHUB_TOKEN = value.trim() || best || '';
|
|
360
|
-
} else if (ghCliToken) {
|
|
361
|
-
console.log('\n ✓ GitHub token sourced from gh CLI (gh auth login).');
|
|
362
|
-
envValues.GITHUB_TOKEN = ghCliToken;
|
|
363
|
-
} else if (envToken) {
|
|
364
|
-
console.log('\n ✓ GitHub token detected in environment.');
|
|
365
|
-
envValues.GITHUB_TOKEN = envToken;
|
|
366
363
|
} else {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
console.log('
|
|
364
|
+
envValues.GITHUB_TOKEN = (typeof flags.token === 'string' ? flags.token : '') || best || '';
|
|
365
|
+
}
|
|
366
|
+
if (!envValues.GITHUB_TOKEN) {
|
|
367
|
+
console.log('\n ✗ --token requested but no PAT found (gh CLI / env / prompt).');
|
|
368
|
+
console.log(' Run `gh auth login -h github.com`, or omit --token to use OAuth.');
|
|
371
369
|
process.exit(1);
|
|
372
370
|
}
|
|
373
371
|
}
|
|
@@ -418,7 +416,7 @@ export async function cmdMcpAdd(id) {
|
|
|
418
416
|
const settings = loadSettings();
|
|
419
417
|
if (!settings.mcpServers) settings.mcpServers = {};
|
|
420
418
|
if (isManagedOnHost(mcp, 'claude')) {
|
|
421
|
-
settings.mcpServers[id] = buildClaudeMcpEntry(id, mcp, resolvedValues);
|
|
419
|
+
settings.mcpServers[id] = buildClaudeMcpEntry(id, mcp, resolvedValues, { auth: authMode });
|
|
422
420
|
} else {
|
|
423
421
|
delete settings.mcpServers[id];
|
|
424
422
|
}
|
|
@@ -431,7 +429,7 @@ export async function cmdMcpAdd(id) {
|
|
|
431
429
|
delete oc.mcp[id];
|
|
432
430
|
if (id === 'memory') delete oc.mcp.cass;
|
|
433
431
|
if (isManagedOnHost(mcp, 'opencode')) {
|
|
434
|
-
oc.mcp[openCodeId] = buildOpenCodeMcpEntry(id, mcp, resolvedValues).entry;
|
|
432
|
+
oc.mcp[openCodeId] = buildOpenCodeMcpEntry(id, mcp, resolvedValues, { auth: authMode }).entry;
|
|
435
433
|
} else {
|
|
436
434
|
delete oc.mcp[openCodeId];
|
|
437
435
|
}
|
|
@@ -451,8 +449,15 @@ export async function cmdMcpAdd(id) {
|
|
|
451
449
|
|
|
452
450
|
console.log(`\n✓ ${mcp.name} successfully wired into Claude Code, OpenCode, and Codex.`);
|
|
453
451
|
console.log(`✓ Setup mode: ${setupMode}`);
|
|
452
|
+
console.log(`✓ Auth: ${id === 'github' ? authMode : 'managed'}`);
|
|
454
453
|
console.log(`✓ Services synchronized.`);
|
|
455
454
|
console.log(`\nNext: Restart OpenCode, Claude Code, or Codex to activate the new tools.`);
|
|
455
|
+
if (id === 'github' && authMode === 'oauth') {
|
|
456
|
+
console.log(`\nAuthenticate (one-click OAuth, no token stored on disk):`);
|
|
457
|
+
console.log(` Claude Code: \`claude mcp login github\` · OpenCode: \`opencode mcp auth github\``);
|
|
458
|
+
console.log(` VS Code / Cursor: approve the sign-in prompt on first use.`);
|
|
459
|
+
console.log(` PAT fallback (headless/CI): \`construct mcp add github --token\``);
|
|
460
|
+
}
|
|
456
461
|
for (const host of ['claude', 'opencode', 'codex']) {
|
|
457
462
|
const support = getHostSupport(mcp, host);
|
|
458
463
|
const hostLabel = host === 'claude' ? 'Claude Code' : host === 'opencode' ? 'OpenCode' : 'Codex';
|
|
@@ -40,14 +40,24 @@ function resolveArgs(args, resolvedValues) {
|
|
|
40
40
|
);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// Remote MCP header values carry secrets (e.g. `Authorization: Bearer __GITHUB_TOKEN__`).
|
|
44
|
+
// Emit a host-resolved env reference rather than the literal token value, so a live
|
|
45
|
+
// credential never lands in a config file on disk. Each host expands a different
|
|
46
|
+
// reference syntax; the secret stays in one place (config.env / the shell env) and the
|
|
47
|
+
// host resolves it at launch.
|
|
48
|
+
|
|
49
|
+
const SECRET_REF = {
|
|
50
|
+
claude: (name) => `\${${name}}`,
|
|
51
|
+
vscode: (name) => `\${env:${name}}`,
|
|
52
|
+
opencode: (name) => `{env:${name}}`,
|
|
53
|
+
};
|
|
54
|
+
|
|
43
55
|
function buildLocalEnvironment(mcpDef, resolvedValues) {
|
|
44
56
|
return stripUnresolvedValues(resolveTemplateObject(mcpDef.env ?? {}, resolvedValues));
|
|
45
57
|
}
|
|
46
58
|
|
|
47
|
-
function buildRemoteHeaders(mcpDef,
|
|
48
|
-
return
|
|
49
|
-
resolveTemplateObject(mcpDef.headers ?? {}, resolvedValues, (name) => `{env:${name}}`),
|
|
50
|
-
);
|
|
59
|
+
function buildRemoteHeaders(mcpDef, secretRef) {
|
|
60
|
+
return resolveTemplateObject(mcpDef.headers ?? {}, {}, secretRef);
|
|
51
61
|
}
|
|
52
62
|
|
|
53
63
|
function withMemoryDefaults(id, values) {
|
|
@@ -56,11 +66,18 @@ function withMemoryDefaults(id, values) {
|
|
|
56
66
|
return { ...values, MEMORY_PORT: "8765" };
|
|
57
67
|
}
|
|
58
68
|
|
|
59
|
-
|
|
69
|
+
// auth defaults to "oauth": remote MCP servers (e.g. GitHub's hosted server) are
|
|
70
|
+
// configured by URL only, and the host runs the browser OAuth flow and stores the
|
|
71
|
+
// token in its own secure store — nothing secret touches the config file. "pat"
|
|
72
|
+
// is the opt-in fallback for non-OAuth contexts (headless/CI), emitting an env
|
|
73
|
+
// reference for the credential, never the literal value.
|
|
74
|
+
|
|
75
|
+
export function buildClaudeMcpEntry(id, mcpDef, resolvedValues = {}, { host = "claude", auth = "oauth" } = {}) {
|
|
60
76
|
const values = withMemoryDefaults(id, { CX_TOOLKIT_DIR: ROOT_DIR, ...resolvedValues });
|
|
77
|
+
const secretRef = SECRET_REF[host] ?? SECRET_REF.claude;
|
|
61
78
|
|
|
62
79
|
if (mcpDef.type === "url") {
|
|
63
|
-
const headers = buildRemoteHeaders(mcpDef,
|
|
80
|
+
const headers = auth === "pat" ? buildRemoteHeaders(mcpDef, secretRef) : {};
|
|
64
81
|
const url =
|
|
65
82
|
id === "memory" && values.MEMORY_PORT
|
|
66
83
|
? `http://127.0.0.1:${values.MEMORY_PORT}/`
|
|
@@ -80,7 +97,7 @@ export function buildClaudeMcpEntry(id, mcpDef, resolvedValues = {}) {
|
|
|
80
97
|
};
|
|
81
98
|
}
|
|
82
99
|
|
|
83
|
-
export function buildOpenCodeMcpEntry(id, mcpDef, resolvedValues = {}) {
|
|
100
|
+
export function buildOpenCodeMcpEntry(id, mcpDef, resolvedValues = {}, { auth = "oauth" } = {}) {
|
|
84
101
|
const runtimeValues = withMemoryDefaults(id, {
|
|
85
102
|
CX_TOOLKIT_DIR: ROOT_DIR,
|
|
86
103
|
...resolvedValues,
|
|
@@ -92,12 +109,13 @@ export function buildOpenCodeMcpEntry(id, mcpDef, resolvedValues = {}) {
|
|
|
92
109
|
id === "memory" && runtimeValues.MEMORY_PORT
|
|
93
110
|
? `http://127.0.0.1:${runtimeValues.MEMORY_PORT}/`
|
|
94
111
|
: resolveTemplateString(mcpDef.url, runtimeValues);
|
|
112
|
+
const headers = auth === "pat" ? buildRemoteHeaders(mcpDef, SECRET_REF.opencode) : {};
|
|
95
113
|
return {
|
|
96
114
|
id: openCodeId,
|
|
97
115
|
entry: {
|
|
98
116
|
type: "remote",
|
|
99
117
|
url,
|
|
100
|
-
...(Object.keys(
|
|
118
|
+
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
|
101
119
|
},
|
|
102
120
|
};
|
|
103
121
|
}
|
package/package.json
CHANGED
|
@@ -1002,13 +1002,20 @@ ${buildPrompt(entry, allEntries, "claude")}
|
|
|
1002
1002
|
* Rewrite the home-mode hook command pattern
|
|
1003
1003
|
* node "$HOME/.config/construct/lib/hooks/<name>.mjs"
|
|
1004
1004
|
* into the project-portable form
|
|
1005
|
-
* node
|
|
1005
|
+
* node "${CLAUDE_PROJECT_DIR:-.}/.construct/run.mjs" hook <name>
|
|
1006
1006
|
* so the resulting settings.json works on any clone where the project ships
|
|
1007
1007
|
* the .construct/ launcher (committed by `npm install`'s postinstall or by
|
|
1008
1008
|
* `construct init`). The launcher resolves Construct via node_modules → npx
|
|
1009
1009
|
* → globally-installed CLI → cached binary → docker, in that order, so it
|
|
1010
1010
|
* works for non-Node ecosystems too. Other commands (inline node -e
|
|
1011
1011
|
* snippets, npx block-no-verify@…) are left untouched.
|
|
1012
|
+
*
|
|
1013
|
+
* The `${CLAUDE_PROJECT_DIR:-.}` anchor matters: Claude Code invokes hooks with
|
|
1014
|
+
* a working directory that is not guaranteed to be the project root (observed:
|
|
1015
|
+
* $HOME), and a bare relative `.construct/run.mjs` then fails with MODULE_NOT_FOUND
|
|
1016
|
+
* at node:internal/modules/cjs/loader. CLAUDE_PROJECT_DIR is the project root Claude
|
|
1017
|
+
* Code exports to every hook (same var lib/hooks/*.mjs already read); the `:-.`
|
|
1018
|
+
* fallback preserves the prior relative behavior wherever the var is unset.
|
|
1012
1019
|
*/
|
|
1013
1020
|
function makeHooksPortable(hooksJson) {
|
|
1014
1021
|
// Operate on the in-memory object so we don't fight JSON string escaping.
|
|
@@ -1017,7 +1024,7 @@ function makeHooksPortable(hooksJson) {
|
|
|
1017
1024
|
const m = cmd.match(/^node\s+"?\$HOME\/\.config\/construct\/lib\/hooks\/([a-z0-9-]+)\.mjs"?\s*(.*)$/);
|
|
1018
1025
|
if (!m) return cmd;
|
|
1019
1026
|
const [, name, rest] = m;
|
|
1020
|
-
return `node
|
|
1027
|
+
return `node "\${CLAUDE_PROJECT_DIR:-.}/.construct/run.mjs" hook ${name}${rest ? ' ' + rest.trim() : ''}`;
|
|
1021
1028
|
};
|
|
1022
1029
|
|
|
1023
1030
|
const walk = (node) => {
|
|
@@ -1474,7 +1481,7 @@ function syncVSCode(targetDir = null, wants = true) {
|
|
|
1474
1481
|
const existingIsRemote = existingEntry && (existingEntry.type === 'http' || existingEntry.type === 'remote');
|
|
1475
1482
|
const transportMismatch = registryWantsCommand && existingIsRemote;
|
|
1476
1483
|
if (existingEntry && !hasPlaceholder && !transportMismatch) continue;
|
|
1477
|
-
config.servers[id] = buildClaudeMcpEntry(id, mcpDef, process.env);
|
|
1484
|
+
config.servers[id] = buildClaudeMcpEntry(id, mcpDef, process.env, { host: "vscode" });
|
|
1478
1485
|
}
|
|
1479
1486
|
if (!DRY_RUN) {
|
|
1480
1487
|
mkdirp(path.dirname(mcpPath));
|
|
@@ -1498,7 +1505,7 @@ function syncVSCode(targetDir = null, wants = true) {
|
|
|
1498
1505
|
const existingIsRemote = existingEntry && (existingEntry.type === 'http' || existingEntry.type === 'remote');
|
|
1499
1506
|
const transportMismatch = registryWantsCommand && existingIsRemote;
|
|
1500
1507
|
if (existingEntry && !hasPlaceholder && !transportMismatch) continue;
|
|
1501
|
-
config.servers[id] = buildClaudeMcpEntry(id, mcpDef, process.env);
|
|
1508
|
+
config.servers[id] = buildClaudeMcpEntry(id, mcpDef, process.env, { host: "vscode" });
|
|
1502
1509
|
}
|
|
1503
1510
|
if (!DRY_RUN) fs.writeFileSync(mcpPath, JSON.stringify(config, null, 2) + "\n");
|
|
1504
1511
|
synced = true;
|
|
@@ -1568,7 +1575,7 @@ function syncCursor(targetDir = null, wants = true) {
|
|
|
1568
1575
|
const existingIsRemote = existingEntry && (existingEntry.type === 'http' || existingEntry.type === 'remote');
|
|
1569
1576
|
const transportMismatch = registryWantsCommand && existingIsRemote;
|
|
1570
1577
|
if (existingEntry && !hasPlaceholder && !transportMismatch) continue;
|
|
1571
|
-
config.mcpServers[id] = buildClaudeMcpEntry(id, mcpDef, process.env);
|
|
1578
|
+
config.mcpServers[id] = buildClaudeMcpEntry(id, mcpDef, process.env, { host: "vscode" });
|
|
1572
1579
|
}
|
|
1573
1580
|
if (!DRY_RUN) {
|
|
1574
1581
|
mkdirp(path.dirname(cursorMcpPath));
|