create-academic-research 0.1.13 → 0.1.15
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/README.md +71 -11
- package/dist/bin/academic-research.js +0 -0
- package/dist/bin/create-academic-research.js +0 -0
- package/dist/src/capabilities.d.ts +132 -3
- package/dist/src/capabilities.js +993 -48
- package/dist/src/cli.js +448 -33
- package/dist/src/mcp-env.d.ts +4 -0
- package/dist/src/mcp-env.js +2 -2
- package/dist/src/mcp-probe.d.ts +3 -2
- package/dist/src/mcp-probe.js +87 -30
- package/dist/src/project.d.ts +18 -0
- package/dist/src/project.js +654 -22
- package/dist/src/stack.d.ts +38 -0
- package/dist/src/stack.js +260 -14
- package/package.json +2 -2
- package/template/README.md +37 -4
- package/template/_gitignore +1 -0
- package/template/docs/agent/mcp-client-setup.md +43 -3
- package/template/docs/agent/mcp-setup.md +60 -0
- package/template/docs/getting-started.md +17 -5
- package/template/package.json +7 -1
package/dist/src/capabilities.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { appendFile, chmod, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
3
5
|
import YAML from "yaml";
|
|
4
6
|
import { assertKnownAgentTarget, AUTO_AGENT, DEFAULT_AGENT, normalizeAgentTarget, SUPPORTED_SKILL_AGENT_TARGETS } from "./agents.js";
|
|
5
7
|
import { defaultRunner } from "./runner.js";
|
|
6
|
-
import { AGENT_STACK, presetMcpServers } from "./stack.js";
|
|
8
|
+
import { AGENT_STACK, mcpModeLabel, mcpRecommendedMode, mcpServerModeKeys, mcpSupportedModeLabels, normalizeMcpMode, presetMcpServers, resolveMcpServer } from "./stack.js";
|
|
7
9
|
import { formatMcpDotenv, listMcpEnvironmentEntries } from "./mcp-env.js";
|
|
8
10
|
import { probeMcpServerList } from "./mcp-probe.js";
|
|
11
|
+
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
9
12
|
export { formatMcpDotenv, listMcpEnvironmentEntries };
|
|
10
13
|
export { mergeMcpEnvironment, readMcpEnvironmentFile } from "./mcp-env.js";
|
|
11
14
|
export { DEFAULT_AGENT, SUPPORTED_SKILL_AGENT_TARGETS };
|
|
@@ -21,13 +24,16 @@ export async function readCapabilities(root) {
|
|
|
21
24
|
}
|
|
22
25
|
}
|
|
23
26
|
export async function writeCapabilities(root, state) {
|
|
27
|
+
const mcpServers = [...(state.mcp_servers ?? [])];
|
|
24
28
|
const next = {
|
|
25
29
|
agent: assertKnownAgentTarget(state.agent),
|
|
26
30
|
preset: state.preset ?? "default",
|
|
27
31
|
scope: "project-local",
|
|
28
|
-
mcp_servers:
|
|
32
|
+
mcp_servers: mcpServers,
|
|
33
|
+
mcp_server_modes: normalizeMcpServerModeMap(state.mcp_server_modes ?? {}, mcpServers),
|
|
34
|
+
mcp_server_remote: normalizeMcpServerRemoteMap(state.mcp_server_remote ?? {}, mcpServers, state.mcp_server_modes ?? {})
|
|
29
35
|
};
|
|
30
|
-
await writeFile(join(root, "configs/capabilities.yaml"), YAML.stringify(next), "utf8");
|
|
36
|
+
await writeFile(join(root, "configs/capabilities.yaml"), YAML.stringify(serializeCapabilityState(next)), "utf8");
|
|
31
37
|
await writeCapabilityProfile(root, next);
|
|
32
38
|
await writeMcpSetup(root, next);
|
|
33
39
|
await writeMcpSnippet(root, next);
|
|
@@ -42,7 +48,9 @@ export async function initializeCapabilities(root, options = {}) {
|
|
|
42
48
|
await writeCapabilities(root, {
|
|
43
49
|
agent: options.agent,
|
|
44
50
|
preset,
|
|
45
|
-
mcp_servers: mcpServers
|
|
51
|
+
mcp_servers: mcpServers,
|
|
52
|
+
mcp_server_modes: {},
|
|
53
|
+
mcp_server_remote: {}
|
|
46
54
|
});
|
|
47
55
|
}
|
|
48
56
|
export async function buildSkillInstallCommands(root, preset = "default", options = {}) {
|
|
@@ -96,6 +104,7 @@ export async function installSkills(root, preset = "default", options = {}, runn
|
|
|
96
104
|
preset
|
|
97
105
|
});
|
|
98
106
|
}
|
|
107
|
+
await recordSkillPresetLock(root, preset, agent, "install");
|
|
99
108
|
return { ok: true, count: commands.length };
|
|
100
109
|
}
|
|
101
110
|
export async function installSkillIds(root, skills, options = {}, runner = defaultRunner) {
|
|
@@ -112,6 +121,7 @@ export async function installSkillIds(root, skills, options = {}, runner = defau
|
|
|
112
121
|
agent
|
|
113
122
|
});
|
|
114
123
|
}
|
|
124
|
+
await recordExplicitSkillLock(root, selectedSkills, agent, "install");
|
|
115
125
|
return { ok: true, count: commands.length, skills: selectedSkills };
|
|
116
126
|
}
|
|
117
127
|
export async function listInstalledSkills(root) {
|
|
@@ -162,20 +172,37 @@ export async function removeSkills(root, skills, runner = defaultRunner) {
|
|
|
162
172
|
"-y"
|
|
163
173
|
], { cwd: root });
|
|
164
174
|
await removeSkillsFromLock(root, skills);
|
|
175
|
+
await recordRemovedSkillLock(root, skills);
|
|
165
176
|
return { ok: true, count: skills.length };
|
|
166
177
|
}
|
|
167
178
|
export async function updateSkills(root, runner = defaultRunner) {
|
|
168
179
|
await runner.run(["npm", "exec", "--yes", "--package", "skills", "--", "skills", "update", "--project", "-y"], { cwd: root });
|
|
180
|
+
await recordSkillUpdateLock(root);
|
|
169
181
|
return { ok: true };
|
|
170
182
|
}
|
|
171
183
|
export async function enableMcpServers(root, servers, options = {}) {
|
|
172
184
|
assertKnownMcpServers(servers);
|
|
173
185
|
const state = await readCapabilities(root);
|
|
174
186
|
const selected = dedupe([...(state.mcp_servers ?? []), ...servers]);
|
|
187
|
+
const mcpServerModes = { ...(state.mcp_server_modes ?? {}) };
|
|
188
|
+
const mcpServerRemote = { ...(state.mcp_server_remote ?? {}) };
|
|
189
|
+
for (const server of servers) {
|
|
190
|
+
const mode = normalizeMcpMode(server, options.mode ?? state.mcp_server_modes?.[server]);
|
|
191
|
+
if (options.mode)
|
|
192
|
+
mcpServerModes[server] = mode;
|
|
193
|
+
if (mode === "remote-custom") {
|
|
194
|
+
mcpServerRemote[server] = normalizeRemoteConfig(server, options.remote ?? state.mcp_server_remote?.[server]);
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
delete mcpServerRemote[server];
|
|
198
|
+
}
|
|
199
|
+
}
|
|
175
200
|
await writeCapabilities(root, {
|
|
176
201
|
...state,
|
|
177
202
|
agent: assertKnownAgentTarget(options.agent ?? state.agent),
|
|
178
|
-
mcp_servers: selected
|
|
203
|
+
mcp_servers: selected,
|
|
204
|
+
mcp_server_modes: mcpServerModes,
|
|
205
|
+
mcp_server_remote: mcpServerRemote
|
|
179
206
|
});
|
|
180
207
|
return { ok: true, servers: selected };
|
|
181
208
|
}
|
|
@@ -184,50 +211,74 @@ export async function disableMcpServers(root, servers, options = {}) {
|
|
|
184
211
|
const state = await readCapabilities(root);
|
|
185
212
|
const blocked = new Set(servers);
|
|
186
213
|
const selected = (state.mcp_servers ?? []).filter((server) => !blocked.has(server));
|
|
214
|
+
const mcpServerModes = { ...(state.mcp_server_modes ?? {}) };
|
|
215
|
+
const mcpServerRemote = { ...(state.mcp_server_remote ?? {}) };
|
|
216
|
+
for (const server of servers)
|
|
217
|
+
delete mcpServerModes[server];
|
|
218
|
+
for (const server of servers)
|
|
219
|
+
delete mcpServerRemote[server];
|
|
187
220
|
await writeCapabilities(root, {
|
|
188
221
|
...state,
|
|
189
222
|
agent: assertKnownAgentTarget(options.agent ?? state.agent),
|
|
190
|
-
mcp_servers: selected
|
|
223
|
+
mcp_servers: selected,
|
|
224
|
+
mcp_server_modes: mcpServerModes,
|
|
225
|
+
mcp_server_remote: mcpServerRemote
|
|
191
226
|
});
|
|
192
227
|
return { ok: true, servers: selected };
|
|
193
228
|
}
|
|
194
|
-
export function mcpToolCommands(servers, key = "install_command") {
|
|
229
|
+
export function mcpToolCommands(servers, key = "install_command", modes = {}) {
|
|
195
230
|
assertKnownMcpServers(servers);
|
|
196
231
|
const commands = [];
|
|
197
232
|
for (const server of servers) {
|
|
198
|
-
const rawCommand =
|
|
233
|
+
const rawCommand = resolveMcpServer(server, modes[server])?.[key];
|
|
199
234
|
if (rawCommand)
|
|
200
235
|
commands.push(splitCommand(rawCommand));
|
|
201
236
|
}
|
|
202
237
|
return commands;
|
|
203
238
|
}
|
|
204
|
-
export function mcpToolCommandTexts(servers, key = "install_command") {
|
|
239
|
+
export function mcpToolCommandTexts(servers, key = "install_command", modes = {}) {
|
|
205
240
|
assertKnownMcpServers(servers);
|
|
206
241
|
const commands = [];
|
|
207
242
|
for (const server of servers) {
|
|
208
|
-
const rawCommand =
|
|
243
|
+
const rawCommand = resolveMcpServer(server, modes[server])?.[key];
|
|
209
244
|
if (rawCommand)
|
|
210
245
|
commands.push(rawCommand);
|
|
211
246
|
}
|
|
212
247
|
return commands;
|
|
213
248
|
}
|
|
214
249
|
export async function installMcpTools(root, servers, runner = defaultRunner) {
|
|
215
|
-
const
|
|
250
|
+
const state = await readCapabilities(root);
|
|
251
|
+
const selected = servers.length > 0 ? servers : state.mcp_servers ?? [];
|
|
216
252
|
assertKnownMcpServers(selected);
|
|
217
|
-
const
|
|
253
|
+
const modes = selectedMcpServerModes(state, selected);
|
|
254
|
+
const skipped = [];
|
|
255
|
+
const commands = mcpToolCommands(selected, "install_command", modes);
|
|
218
256
|
for (const command of commands) {
|
|
219
257
|
await runner.run(command, { cwd: root });
|
|
220
258
|
}
|
|
221
|
-
|
|
259
|
+
for (const serverName of selected) {
|
|
260
|
+
const server = resolveMcpServer(serverName, modes[serverName]);
|
|
261
|
+
if (!server.install_command)
|
|
262
|
+
skipped.push(mcpInstallSkip(serverName, server));
|
|
263
|
+
}
|
|
264
|
+
return { ok: true, count: commands.length, skipped };
|
|
222
265
|
}
|
|
223
266
|
export async function uninstallMcpTools(root, servers, runner = defaultRunner) {
|
|
224
|
-
const
|
|
267
|
+
const state = await readCapabilities(root);
|
|
268
|
+
const selected = servers.length > 0 ? servers : state.mcp_servers ?? [];
|
|
225
269
|
assertKnownMcpServers(selected);
|
|
226
|
-
const
|
|
270
|
+
const modes = selectedMcpServerModes(state, selected);
|
|
271
|
+
const skipped = [];
|
|
272
|
+
const commands = mcpToolCommands(selected, "uninstall_command", modes);
|
|
227
273
|
for (const command of commands) {
|
|
228
274
|
await runner.run(command, { cwd: root });
|
|
229
275
|
}
|
|
230
|
-
|
|
276
|
+
for (const serverName of selected) {
|
|
277
|
+
const server = resolveMcpServer(serverName, modes[serverName]);
|
|
278
|
+
if (!server.uninstall_command)
|
|
279
|
+
skipped.push(mcpUninstallSkip(serverName, server));
|
|
280
|
+
}
|
|
281
|
+
return { ok: true, count: commands.length, skipped };
|
|
231
282
|
}
|
|
232
283
|
export async function doctorMcpServers(root, options = {}) {
|
|
233
284
|
const errors = [];
|
|
@@ -246,6 +297,7 @@ export async function doctorMcpServers(root, options = {}) {
|
|
|
246
297
|
};
|
|
247
298
|
}
|
|
248
299
|
const enabled = state.mcp_servers ?? [];
|
|
300
|
+
const modes = selectedMcpServerModes(state, enabled);
|
|
249
301
|
const unknown = enabled.filter((server) => !AGENT_STACK.mcp_servers[server]);
|
|
250
302
|
if (unknown.length > 0) {
|
|
251
303
|
errors.push(`unknown MCP server in capabilities: ${unknown.join(", ")}`);
|
|
@@ -260,7 +312,7 @@ export async function doctorMcpServers(root, options = {}) {
|
|
|
260
312
|
}
|
|
261
313
|
}
|
|
262
314
|
catch (error) {
|
|
263
|
-
const hasGeneratedServer = enabled.some((server) =>
|
|
315
|
+
const hasGeneratedServer = enabled.some((server) => isSnippetCapable(resolveMcpServerForState(state, server, modes[server])));
|
|
264
316
|
if (hasGeneratedServer && isMissingFileError(error)) {
|
|
265
317
|
errors.push(`missing generated MCP snippet: ${snippetPath}`);
|
|
266
318
|
}
|
|
@@ -270,7 +322,7 @@ export async function doctorMcpServers(root, options = {}) {
|
|
|
270
322
|
}
|
|
271
323
|
}
|
|
272
324
|
for (const name of enabled) {
|
|
273
|
-
const server =
|
|
325
|
+
const server = resolveMcpServerForState(state, name, modes[name]);
|
|
274
326
|
if (!server)
|
|
275
327
|
continue;
|
|
276
328
|
for (const envName of server.required_env) {
|
|
@@ -286,7 +338,13 @@ export async function doctorMcpServers(root, options = {}) {
|
|
|
286
338
|
if (server.local_service) {
|
|
287
339
|
warnings.push(`${name}: requires local service: ${server.local_service}`);
|
|
288
340
|
}
|
|
289
|
-
if (
|
|
341
|
+
if (server.connection_mode === "manual-local") {
|
|
342
|
+
const lock = await readCapabilityLock(root);
|
|
343
|
+
if (lock.mcp[name]?.setup?.status !== "ready") {
|
|
344
|
+
warnings.push(`${name}: local setup not complete; run npm run mcp:setup -- ${name} --mode local --env-file .env.local`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (!isSnippetCapable(server)) {
|
|
290
348
|
warnings.push(`${name}: manual setup only; no generated client command`);
|
|
291
349
|
continue;
|
|
292
350
|
}
|
|
@@ -300,28 +358,64 @@ export async function probeMcpServers(root, servers, options = {}) {
|
|
|
300
358
|
const selected = servers.length > 0 ? servers : (await readCapabilities(root)).mcp_servers ?? [];
|
|
301
359
|
const env = options.env ?? process.env;
|
|
302
360
|
const timeoutMs = options.timeoutMs ?? 5000;
|
|
303
|
-
|
|
361
|
+
const state = await readCapabilities(root);
|
|
362
|
+
const modes = Object.fromEntries(selected.map((serverName) => [
|
|
363
|
+
serverName,
|
|
364
|
+
normalizeMcpMode(serverName, options.mode ?? state.mcp_server_modes?.[serverName])
|
|
365
|
+
]));
|
|
366
|
+
const resolvedServers = Object.fromEntries(selected.map((serverName) => [serverName, resolveMcpServerForState(state, serverName, modes[serverName])]));
|
|
367
|
+
const result = await probeMcpServerList(root, selected, env, timeoutMs, options.clientVersion, modes, resolvedServers);
|
|
368
|
+
await updateCapabilityLock(root, (lock) => {
|
|
369
|
+
for (const item of result.results) {
|
|
370
|
+
const server = resolveMcpServerForState(state, item.server, modes[item.server]);
|
|
371
|
+
const entry = ensureLockMcpEntry(lock, item.server, server);
|
|
372
|
+
entry.probe = { status: item.status, detail: item.detail, updated_at: nowIso() };
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
return result;
|
|
304
376
|
}
|
|
305
377
|
async function writeMcpSnippet(root, state) {
|
|
378
|
+
const snippet = renderMcpSnippet(state);
|
|
379
|
+
const outputDir = join(root, "docs/agent/generated");
|
|
380
|
+
await mkdir(outputDir, { recursive: true });
|
|
381
|
+
await removeInactiveMcpSnippets(outputDir, snippet.fileName);
|
|
382
|
+
await writeFile(join(outputDir, snippet.fileName), snippet.content, "utf8");
|
|
383
|
+
}
|
|
384
|
+
export function renderMcpSnippet(state) {
|
|
306
385
|
const servers = {};
|
|
386
|
+
const modes = selectedMcpServerModes(state, state.mcp_servers ?? []);
|
|
307
387
|
for (const name of state.mcp_servers ?? []) {
|
|
308
|
-
const server =
|
|
309
|
-
if (
|
|
388
|
+
const server = resolveMcpServerForState(state, name, modes[name]);
|
|
389
|
+
if (server.connection_mode === "remote-curated" && server.hosted_url) {
|
|
390
|
+
servers[name] = { url: server.hosted_url, type: "streamable-http" };
|
|
310
391
|
continue;
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
392
|
+
}
|
|
393
|
+
if (server.connection_mode === "remote-custom") {
|
|
394
|
+
const remote = state.mcp_server_remote?.[name];
|
|
395
|
+
if (remote?.url)
|
|
396
|
+
servers[name] = { url: remote.url, type: remote.transport };
|
|
397
|
+
if (remote?.url_env)
|
|
398
|
+
servers[name] = { urlEnv: remote.url_env, type: remote.transport };
|
|
399
|
+
if (remote?.bearer_token_env_var && servers[name]) {
|
|
400
|
+
servers[name].bearerTokenEnvVar = remote.bearer_token_env_var;
|
|
401
|
+
}
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
if (!server.command)
|
|
405
|
+
continue;
|
|
406
|
+
servers[name] = { command: server.command, args: server.args };
|
|
315
407
|
if (Object.keys(server.env).length > 0)
|
|
316
408
|
servers[name].env = server.env;
|
|
317
409
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
await writeFile(join(outputDir, outputFile), `${JSON.stringify({ mcpServers: servers }, null, 2)}\n`, "utf8");
|
|
410
|
+
return {
|
|
411
|
+
fileName: mcpSnippetFileName(state.agent),
|
|
412
|
+
content: `${JSON.stringify({ mcpServers: servers }, null, 2)}\n`
|
|
413
|
+
};
|
|
323
414
|
}
|
|
324
415
|
async function writeCapabilityProfile(root, state) {
|
|
416
|
+
await writeFile(join(root, "docs/agent/capability-profile.md"), renderCapabilityProfile(state), "utf8");
|
|
417
|
+
}
|
|
418
|
+
export function renderCapabilityProfile(state) {
|
|
325
419
|
const lines = [
|
|
326
420
|
"# Agent Capability Profile",
|
|
327
421
|
"",
|
|
@@ -345,22 +439,29 @@ async function writeCapabilityProfile(root, state) {
|
|
|
345
439
|
lines.push("- No MCP servers enabled.");
|
|
346
440
|
}
|
|
347
441
|
else {
|
|
442
|
+
const modes = selectedMcpServerModes(state, state.mcp_servers);
|
|
348
443
|
for (const name of state.mcp_servers) {
|
|
349
|
-
const server =
|
|
350
|
-
const status =
|
|
444
|
+
const server = resolveMcpServerForState(state, name, modes[name]);
|
|
445
|
+
const status = mcpModeLabel(name, modes[name]);
|
|
351
446
|
lines.push(`- \`${name}\` (${status}): ${server?.smoke_test ?? "Smoke-test before use."}`);
|
|
352
447
|
}
|
|
353
448
|
}
|
|
354
|
-
lines.push("", "## Rules", "", "- Skill installation is project-local by default.", "- Agent target `universal` installs one shared project-local `.agents/skills` copy.", "- MCP enable/disable changes project records; install/
|
|
355
|
-
|
|
449
|
+
lines.push("", "## Rules", "", "- Skill installation is project-local by default.", "- Agent target `universal` installs one shared project-local `.agents/skills` copy.", "- MCP enable/disable changes selected project records; setup/install/client/probe commands change operational state.", "- Keep API keys, tokens, cookies, and browser sessions out of git.", "- Cite repository source records, not raw MCP output alone.", "");
|
|
450
|
+
return lines.join("\n");
|
|
356
451
|
}
|
|
357
452
|
async function writeMcpSetup(root, state) {
|
|
453
|
+
await mkdir(join(root, "docs/agent"), { recursive: true });
|
|
454
|
+
await writeFile(join(root, "docs/agent/mcp-setup.md"), renderMcpSetup(state), "utf8");
|
|
455
|
+
}
|
|
456
|
+
export function renderMcpSetup(state) {
|
|
358
457
|
const enabled = new Set(state.mcp_servers ?? []);
|
|
458
|
+
const modes = selectedMcpServerModes(state, state.mcp_servers ?? []);
|
|
359
459
|
const lines = [
|
|
360
460
|
"# MCP Setup",
|
|
361
461
|
"",
|
|
362
462
|
"This file is generated from the project-local academic research capability stack.",
|
|
363
463
|
"MCP records are configuration snippets and setup guidance; the active MCP client must load the generated snippet before these servers become live tools.",
|
|
464
|
+
"Default CLI output uses plain mode labels: local, remote, custom remote, requires local app, and manual setup. Use `npm run mcp:status -- --verbose` for technical transport details.",
|
|
364
465
|
"",
|
|
365
466
|
"## Enabled MCP Servers",
|
|
366
467
|
""
|
|
@@ -370,22 +471,22 @@ async function writeMcpSetup(root, state) {
|
|
|
370
471
|
}
|
|
371
472
|
else {
|
|
372
473
|
for (const name of state.mcp_servers) {
|
|
373
|
-
const server =
|
|
474
|
+
const server = resolveMcpServerForState(state, name, modes[name]);
|
|
374
475
|
if (!server)
|
|
375
476
|
continue;
|
|
376
|
-
lines.push(`- \`${name}\` (${server.readiness}, ${server.priority}): ${server.source_need}`, ` - Source: \`${server.source}\``, ` - Execution mode: \`${server.execution_mode}\``, ...(server.hosted_url ? [` - Hosted endpoint: <${server.hosted_url}>`] : []), ` - Runtime: ${formatRuntime(server.command, server.args)}`, ` - Install command: ${server.install_command ? `\`${server.install_command}\`` : "none; runtime-only or manual setup"}`, ...server.setup_commands.map((command) => ` - Setup command: \`${command}\``), ` - Smoke test: ${server.smoke_test}`, ` - Risks: ${server.risks}`);
|
|
477
|
+
lines.push(`- \`${name}\` (${server.readiness}, ${server.priority}): ${server.source_need}`, ` - Source: \`${server.source}\``, ` - Selected mode: \`${mcpModeLabel(name, modes[name])}\``, ` - Technical mode: \`${server.connection_mode}\``, ` - Execution mode: \`${server.execution_mode}\``, ...(server.hosted_url ? [` - Hosted endpoint: <${server.hosted_url}>`] : []), ` - Runtime: ${formatRuntime(server.command, server.args)}`, ` - Install command: ${server.install_command ? `\`${server.install_command}\`` : "none; runtime-only or manual setup"}`, ...server.setup_commands.map((command) => ` - Setup command: \`${command}\``), ` - Smoke test: ${server.smoke_test}`, ` - Risks: ${server.risks}`);
|
|
377
478
|
appendMcpPrerequisiteLines(lines, server.required_env, server.recommended_env, server.local_service);
|
|
378
479
|
}
|
|
379
480
|
}
|
|
380
481
|
lines.push("", "## Available MCP Catalog", "");
|
|
381
482
|
for (const [name, server] of Object.entries(AGENT_STACK.mcp_servers)) {
|
|
382
483
|
const status = enabled.has(name) ? "enabled" : "available";
|
|
383
|
-
|
|
384
|
-
|
|
484
|
+
const resolved = enabled.has(name) ? resolveMcpServerForState(state, name, modes[name]) : resolveMcpServer(name, mcpRecommendedMode(name));
|
|
485
|
+
lines.push(`- \`${name}\` (${status}, ${resolved.readiness}, ${resolved.priority}): ${resolved.source_need}`, ` - Source: \`${resolved.source}\``, ` - Recommended mode: \`${mcpModeLabel(name, mcpRecommendedMode(name))}\``, ` - Default mode: \`${mcpModeLabel(name)}\``, ...(server.modes ? [` - Supported modes: ${mcpSupportedModeLabels(name).join(", ")}`] : []), ` - Execution mode: \`${resolved.execution_mode}\``, ...(resolved.hosted_url ? [` - Hosted endpoint: <${resolved.hosted_url}>`] : []), ...mcpModeEndpointLines(name), ...resolved.setup_commands.map((command) => ` - Setup command: \`${command}\``));
|
|
486
|
+
appendMcpPrerequisiteLines(lines, resolved.required_env, resolved.recommended_env, resolved.local_service);
|
|
385
487
|
}
|
|
386
|
-
lines.push("", "## Operating Rules", "", "- Use `.env.example` as a committed reference and put filled secrets in `.env.local`, your shell, or your MCP client secret store.", "- Print a dotenv-style reference with `npm run mcp:env -- --dotenv --all`.", "- Regenerate a dotenv-style reference with `npm run mcp:dotenv`.", "- Pass `--env-file .env.local` to `mcp doctor`, `mcp smoke`, or `mcp probe` when you want the CLI to read explicit local secrets.", "- Keep secrets in your shell, MCP client secret store, or local untracked files; do not commit tokens or API keys.", "- Prefer the smallest enabled MCP set that covers the current research question.", "- Treat MCP output as retrieval metadata. Promote claims into repository source records only after source ingestion and citation audit.", "- Run `npm run mcp:doctor` after changing MCP records or environment variables.", "- Run `npm run mcp:probe -- <server>` only when you intentionally want to start selected MCP server processes.", "");
|
|
387
|
-
|
|
388
|
-
await writeFile(join(root, "docs/agent/mcp-setup.md"), lines.join("\n"), "utf8");
|
|
488
|
+
lines.push("", "## Operating Rules", "", "- Use `.env.example` as a committed reference and put filled secrets in `.env.local`, your shell, or your MCP client secret store.", "- Print a dotenv-style reference with `npm run mcp:env -- --dotenv --all`.", "- Regenerate a dotenv-style reference with `npm run mcp:dotenv`.", "- Discover supported modes with `npm run mcp:modes` or `npm run mcp:modes -- openalex`.", "- Inspect selected-vs-ready lifecycle state with `npm run mcp:status`.", "- Select explicit modes with `npm run mcp:enable -- <server> --mode local` or `--mode remote` where supported.", "- Select a custom remote endpoint with `npm run mcp:enable -- <server> --mode remote-custom --url https://example.com/mcp`; use `--url-env <NAME>` for private URLs.", "- Codex automatic registration supports custom remote `--url`; if you use `--url-env`, register manually because Codex CLI has no `--url-env` option.", "- Explicit `--mode remote-custom` still needs `--url` or `--url-env`; otherwise smoke/probe report `missing-remote-url`.", "- Run manual setup with `npm run mcp:setup -- <server> --mode local --env-file .env.local`.", "- Register supported clients with `npm run mcp:client:add -- <server> --agent codex`; use `--dry-run` to print the command first.", "- Pass `--env-file .env.local` to `mcp doctor`, `mcp smoke`, or `mcp probe` when you want the CLI to read explicit local secrets.", "- Keep secrets in your shell, MCP client secret store, or local untracked files; do not commit tokens or API keys.", "- Non-secret observed setup facts are recorded in `docs/agent/capability-lock.json` when setup, client registration, or probes run.", "- Prefer the smallest enabled MCP set that covers the current research question.", "- Treat MCP output as retrieval metadata. Promote claims into repository source records only after source ingestion and citation audit.", "- Run `npm run mcp:doctor` after changing MCP records or environment variables.", "- Run `npm run mcp:probe -- <server>` only when you intentionally want to start selected local stdio MCP server processes; remote endpoints report configured status without a network probe.", "");
|
|
489
|
+
return lines.join("\n");
|
|
389
490
|
}
|
|
390
491
|
async function appendCapabilityLog(root, state) {
|
|
391
492
|
const logPath = join(root, "wiki/log.md");
|
|
@@ -399,6 +500,41 @@ function dedupe(values) {
|
|
|
399
500
|
function envHasValue(env, name) {
|
|
400
501
|
return typeof env[name] === "string" && env[name] !== "";
|
|
401
502
|
}
|
|
503
|
+
function normalizeRemoteConfig(server, config) {
|
|
504
|
+
if (!config || (!config.url && !config.url_env)) {
|
|
505
|
+
throw new Error(`${server}: remote-custom requires --url or --url-env`);
|
|
506
|
+
}
|
|
507
|
+
if (config.url && config.url_env) {
|
|
508
|
+
throw new Error(`${server}: remote-custom accepts either --url or --url-env, not both`);
|
|
509
|
+
}
|
|
510
|
+
const result = { transport: "streamable-http" };
|
|
511
|
+
if (config.url) {
|
|
512
|
+
try {
|
|
513
|
+
const parsed = new URL(config.url);
|
|
514
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
515
|
+
throw new Error("expected http or https URL");
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
catch (error) {
|
|
519
|
+
throw new Error(`${server}: invalid remote MCP URL: ${error instanceof Error ? error.message : String(error)}`);
|
|
520
|
+
}
|
|
521
|
+
result.url = config.url;
|
|
522
|
+
}
|
|
523
|
+
if (config.url_env) {
|
|
524
|
+
assertEnvVarName(config.url_env, `${server}: invalid URL env var name`);
|
|
525
|
+
result.url_env = config.url_env;
|
|
526
|
+
}
|
|
527
|
+
if (config.bearer_token_env_var) {
|
|
528
|
+
assertEnvVarName(config.bearer_token_env_var, `${server}: invalid bearer token env var name`);
|
|
529
|
+
result.bearer_token_env_var = config.bearer_token_env_var;
|
|
530
|
+
}
|
|
531
|
+
return result;
|
|
532
|
+
}
|
|
533
|
+
function assertEnvVarName(name, message) {
|
|
534
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
535
|
+
throw new Error(`${message}: ${name}`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
402
538
|
function renderSkillCommand(command, agent) {
|
|
403
539
|
const normalized = assertKnownAgentTarget(agent);
|
|
404
540
|
const agentFlag = normalized === AUTO_AGENT ? "" : `--agent '${normalized}'`;
|
|
@@ -455,11 +591,788 @@ function appendMcpPrerequisiteLines(lines, requiredEnv, recommendedEnv, localSer
|
|
|
455
591
|
if (localService)
|
|
456
592
|
lines.push(` - Local prerequisite: ${localService}`);
|
|
457
593
|
}
|
|
594
|
+
function mcpModeEndpointLines(serverName) {
|
|
595
|
+
const lines = [];
|
|
596
|
+
for (const mode of mcpServerModeKeys(serverName)) {
|
|
597
|
+
const server = resolveMcpServer(serverName, mode);
|
|
598
|
+
if (server.hosted_url)
|
|
599
|
+
lines.push(` - Hosted endpoint: <${server.hosted_url}>`);
|
|
600
|
+
}
|
|
601
|
+
return lines;
|
|
602
|
+
}
|
|
458
603
|
function formatRuntime(command, args) {
|
|
459
604
|
if (!command)
|
|
460
605
|
return "manual setup";
|
|
461
606
|
return `\`${[command, ...args].join(" ")}\``;
|
|
462
607
|
}
|
|
608
|
+
export async function getMcpLifecycleStatus(root, options = {}) {
|
|
609
|
+
const state = await readCapabilities(root);
|
|
610
|
+
const lock = await readCapabilityLock(root);
|
|
611
|
+
const env = options.env ?? process.env;
|
|
612
|
+
const generatedServers = await readGeneratedMcpServers(root, state);
|
|
613
|
+
const selected = new Set(state.mcp_servers ?? []);
|
|
614
|
+
const rows = [];
|
|
615
|
+
for (const id of Object.keys(AGENT_STACK.mcp_servers)) {
|
|
616
|
+
const modeKey = selected.has(id) ? selectedMcpServerModes(state, [id])[id] : mcpRecommendedMode(id);
|
|
617
|
+
const server = selected.has(id) ? resolveMcpServerForState(state, id, modeKey) : resolveMcpServer(id, modeKey);
|
|
618
|
+
const envStatus = selected.has(id) ? lifecycleEnvStatus(server, env) : "n/a";
|
|
619
|
+
const install = selected.has(id) ? lifecycleInstallStatus(root, id, server, lock) : "n/a";
|
|
620
|
+
const snippet = selected.has(id)
|
|
621
|
+
? generatedServers.has(id)
|
|
622
|
+
? "available"
|
|
623
|
+
: isSnippetCapable(server)
|
|
624
|
+
? "missing"
|
|
625
|
+
: "none"
|
|
626
|
+
: "none";
|
|
627
|
+
const client = lifecycleClientStatus(id, server, selected.has(id), state.agent, lock);
|
|
628
|
+
const probe = selected.has(id) ? lock.mcp[id]?.probe?.status ?? "unknown" : "n/a";
|
|
629
|
+
rows.push({
|
|
630
|
+
id,
|
|
631
|
+
selected: selected.has(id),
|
|
632
|
+
mode: mcpModeLabel(id, modeKey),
|
|
633
|
+
mode_key: modeKey,
|
|
634
|
+
connection_mode: server.connection_mode,
|
|
635
|
+
state: lifecycleState(selected.has(id), envStatus, install, snippet, client, probe),
|
|
636
|
+
env: envStatus,
|
|
637
|
+
install,
|
|
638
|
+
snippet,
|
|
639
|
+
client,
|
|
640
|
+
probe,
|
|
641
|
+
next: lifecycleNextAction(id, server, selected.has(id), envStatus, install, snippet, client, probe)
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
return { servers: rows };
|
|
645
|
+
}
|
|
646
|
+
export async function readCapabilityLock(root) {
|
|
647
|
+
try {
|
|
648
|
+
const parsed = JSON.parse(await readFile(capabilityLockPath(root), "utf8"));
|
|
649
|
+
const record = typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
650
|
+
return {
|
|
651
|
+
version: typeof record.version === "number" ? record.version : 1,
|
|
652
|
+
generator: normalizeCapabilityLockGenerator(record.generator),
|
|
653
|
+
mcp: typeof record.mcp === "object" && record.mcp !== null ? record.mcp : {},
|
|
654
|
+
skills: normalizeCapabilityLockSkills(record.skills)
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
catch (error) {
|
|
658
|
+
if (isMissingFileError(error))
|
|
659
|
+
return defaultCapabilityLock();
|
|
660
|
+
throw error;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
export async function setupMcpServer(root, serverName, options = {}, runner = defaultRunner) {
|
|
664
|
+
assertKnownMcpServers([serverName]);
|
|
665
|
+
const mode = normalizeMcpMode(serverName, options.mode);
|
|
666
|
+
const server = resolveMcpServer(serverName, mode);
|
|
667
|
+
if (serverName !== "overleaf") {
|
|
668
|
+
const commands = server.setup_commands.length > 0 ? server.setup_commands : [];
|
|
669
|
+
return {
|
|
670
|
+
ok: true,
|
|
671
|
+
server: serverName,
|
|
672
|
+
mode,
|
|
673
|
+
commands,
|
|
674
|
+
created: [],
|
|
675
|
+
warnings: commands.length === 0 ? [`${serverName}: no finite setup command is defined`] : [],
|
|
676
|
+
errors: [],
|
|
677
|
+
next: commands.length > 0 ? commands : [`run npm run mcp:status`]
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
const env = options.env ?? process.env;
|
|
681
|
+
const required = ["OVERLEAF_TOKEN", "PROJECT_ID"];
|
|
682
|
+
const missing = required.filter((name) => !envHasValue(env, name));
|
|
683
|
+
const paths = overleafPaths(root);
|
|
684
|
+
const commands = [
|
|
685
|
+
`git clone --depth 1 https://github.com/YounesBensafia/overleaf-mcp-server.git ${paths.relativeServer}`,
|
|
686
|
+
`cd ${paths.relativeServer} && uv sync`,
|
|
687
|
+
`write wrapper ${paths.relativeWrapper}`
|
|
688
|
+
];
|
|
689
|
+
if (missing.length > 0) {
|
|
690
|
+
return {
|
|
691
|
+
ok: false,
|
|
692
|
+
server: serverName,
|
|
693
|
+
mode,
|
|
694
|
+
commands,
|
|
695
|
+
created: [],
|
|
696
|
+
warnings: [],
|
|
697
|
+
errors: [`overleaf: missing required environment variable(s): ${missing.join(", ")}`],
|
|
698
|
+
next: [`fill ${missing.join(", ")} in ${options.envFile ?? ".env.local"}`]
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
if (options.dryRun) {
|
|
702
|
+
return {
|
|
703
|
+
ok: true,
|
|
704
|
+
server: serverName,
|
|
705
|
+
mode,
|
|
706
|
+
commands,
|
|
707
|
+
created: [],
|
|
708
|
+
warnings: [],
|
|
709
|
+
errors: [],
|
|
710
|
+
next: [`run npm run mcp:setup -- overleaf --mode local --env-file ${options.envFile ?? ".env.local"}`]
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
await mkdir(paths.wrapperDir, { recursive: true });
|
|
714
|
+
if (!existsSync(paths.serverDir)) {
|
|
715
|
+
await runner.run(["git", "clone", "--depth", "1", "https://github.com/YounesBensafia/overleaf-mcp-server.git", paths.serverDir], {
|
|
716
|
+
cwd: root
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
await runner.run(["uv", "sync"], { cwd: paths.serverDir });
|
|
720
|
+
await writeFile(paths.wrapper, renderOverleafWrapper(), "utf8");
|
|
721
|
+
await writeFile(paths.launcher, renderOverleafLauncher(), "utf8");
|
|
722
|
+
await chmod(paths.wrapper, 0o755);
|
|
723
|
+
await chmod(paths.launcher, 0o755);
|
|
724
|
+
await updateCapabilityLock(root, (lock) => {
|
|
725
|
+
const entry = ensureLockMcpEntry(lock, "overleaf", server);
|
|
726
|
+
entry.setup = {
|
|
727
|
+
status: "ready",
|
|
728
|
+
server_path: paths.relativeServer,
|
|
729
|
+
wrapper_path: paths.relativeWrapper,
|
|
730
|
+
env_file: options.envFile ? toPosix(relative(root, resolve(root, options.envFile))) : ".env.local",
|
|
731
|
+
updated_at: nowIso()
|
|
732
|
+
};
|
|
733
|
+
});
|
|
734
|
+
return {
|
|
735
|
+
ok: true,
|
|
736
|
+
server: serverName,
|
|
737
|
+
mode,
|
|
738
|
+
commands,
|
|
739
|
+
created: [paths.relativeWrapper, paths.relativeLauncher],
|
|
740
|
+
warnings: [],
|
|
741
|
+
errors: [],
|
|
742
|
+
next: [
|
|
743
|
+
"npm run mcp:client:add -- overleaf --agent codex",
|
|
744
|
+
"npm run mcp:probe -- overleaf --env-file .env.local"
|
|
745
|
+
]
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
export async function clientAddMcpServer(root, serverName, options = {}, runner = defaultRunner) {
|
|
749
|
+
return clientMcpServer(root, serverName, "add", options, runner);
|
|
750
|
+
}
|
|
751
|
+
export async function clientRemoveMcpServer(root, serverName, options = {}, runner = defaultRunner) {
|
|
752
|
+
return clientMcpServer(root, serverName, "remove", options, runner);
|
|
753
|
+
}
|
|
754
|
+
async function clientMcpServer(root, serverName, action, options, runner) {
|
|
755
|
+
assertKnownMcpServers([serverName]);
|
|
756
|
+
const state = await readCapabilities(root);
|
|
757
|
+
const agent = assertKnownAgentTarget(options.agent ?? state.agent);
|
|
758
|
+
const mode = normalizeMcpMode(serverName, options.mode ?? state.mcp_server_modes[serverName]);
|
|
759
|
+
const server = resolveMcpServerForState(state, serverName, mode);
|
|
760
|
+
if (agent !== "codex") {
|
|
761
|
+
return {
|
|
762
|
+
ok: false,
|
|
763
|
+
server: serverName,
|
|
764
|
+
agent,
|
|
765
|
+
command: [],
|
|
766
|
+
instructions: [`${agent} client registration is manual; load docs/agent/generated/${mcpSnippetFileName(agent)} in the client.`]
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
const command = action === "remove"
|
|
770
|
+
? ["codex", "mcp", "remove", serverName]
|
|
771
|
+
: codexAddCommand(root, serverName, server, state);
|
|
772
|
+
if (action === "add") {
|
|
773
|
+
const readiness = await clientRegistrationReadiness(root, serverName, server, mode, state);
|
|
774
|
+
if (!readiness.ready) {
|
|
775
|
+
return {
|
|
776
|
+
ok: false,
|
|
777
|
+
server: serverName,
|
|
778
|
+
agent,
|
|
779
|
+
command,
|
|
780
|
+
instructions: readiness.instructions
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
if (!options.dryRun) {
|
|
785
|
+
try {
|
|
786
|
+
await runner.run(command, { cwd: root });
|
|
787
|
+
await updateCapabilityLock(root, (lock) => {
|
|
788
|
+
const entry = ensureLockMcpEntry(lock, serverName, server);
|
|
789
|
+
entry.clients = entry.clients ?? {};
|
|
790
|
+
entry.clients.codex = { status: action === "add" ? "registered" : "removed", updated_at: nowIso() };
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
catch (error) {
|
|
794
|
+
return {
|
|
795
|
+
ok: false,
|
|
796
|
+
server: serverName,
|
|
797
|
+
agent,
|
|
798
|
+
command,
|
|
799
|
+
instructions: [
|
|
800
|
+
`Codex CLI registration failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
801
|
+
`Run manually: ${command.join(" ")}`
|
|
802
|
+
]
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
return {
|
|
807
|
+
ok: true,
|
|
808
|
+
server: serverName,
|
|
809
|
+
agent,
|
|
810
|
+
command,
|
|
811
|
+
instructions: options.dryRun ? [`Dry run only; no client config was changed.`] : []
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
function serializeCapabilityState(state) {
|
|
815
|
+
const serialized = {
|
|
816
|
+
agent: state.agent,
|
|
817
|
+
preset: state.preset,
|
|
818
|
+
scope: state.scope,
|
|
819
|
+
mcp_servers: state.mcp_servers
|
|
820
|
+
};
|
|
821
|
+
if (Object.keys(state.mcp_server_modes).length > 0)
|
|
822
|
+
serialized.mcp_server_modes = state.mcp_server_modes;
|
|
823
|
+
if (Object.keys(state.mcp_server_remote).length > 0)
|
|
824
|
+
serialized.mcp_server_remote = state.mcp_server_remote;
|
|
825
|
+
return serialized;
|
|
826
|
+
}
|
|
827
|
+
function normalizeMcpServerModeMap(modes, servers) {
|
|
828
|
+
const selected = new Set(servers);
|
|
829
|
+
const result = {};
|
|
830
|
+
for (const [server, mode] of Object.entries(modes)) {
|
|
831
|
+
if (selected.has(server))
|
|
832
|
+
result[server] = normalizeMcpMode(server, mode);
|
|
833
|
+
}
|
|
834
|
+
return result;
|
|
835
|
+
}
|
|
836
|
+
function normalizeMcpServerRemoteMap(remote, servers, modes) {
|
|
837
|
+
const selected = new Set(servers);
|
|
838
|
+
const result = {};
|
|
839
|
+
for (const [server, config] of Object.entries(remote)) {
|
|
840
|
+
if (!selected.has(server))
|
|
841
|
+
continue;
|
|
842
|
+
const mode = normalizeMcpMode(server, modes[server]);
|
|
843
|
+
if (mode === "remote-custom")
|
|
844
|
+
result[server] = normalizeRemoteConfig(server, config);
|
|
845
|
+
}
|
|
846
|
+
return result;
|
|
847
|
+
}
|
|
848
|
+
function selectedMcpServerModes(state, servers) {
|
|
849
|
+
const modes = {};
|
|
850
|
+
for (const server of servers) {
|
|
851
|
+
modes[server] = normalizeMcpMode(server, state.mcp_server_modes?.[server]);
|
|
852
|
+
}
|
|
853
|
+
return modes;
|
|
854
|
+
}
|
|
855
|
+
export function resolveMcpServerForState(state, serverName, mode) {
|
|
856
|
+
const server = resolveMcpServer(serverName, mode ?? state.mcp_server_modes?.[serverName]);
|
|
857
|
+
if (server.selected_mode !== "remote-custom")
|
|
858
|
+
return server;
|
|
859
|
+
const remote = state.mcp_server_remote?.[serverName];
|
|
860
|
+
const remoteConfigured = Boolean(remote?.url || remote?.url_env);
|
|
861
|
+
return {
|
|
862
|
+
...server,
|
|
863
|
+
hosted_url: remote?.url ?? "",
|
|
864
|
+
remote_url_env: remote?.url_env,
|
|
865
|
+
remote_configured: remoteConfigured,
|
|
866
|
+
required_env: remote?.url_env ? [...server.required_env, remote.url_env] : server.required_env,
|
|
867
|
+
recommended_env: remote?.bearer_token_env_var
|
|
868
|
+
? [...server.recommended_env, remote.bearer_token_env_var]
|
|
869
|
+
: server.recommended_env
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
async function readGeneratedMcpServers(root, state) {
|
|
873
|
+
const generated = new Set();
|
|
874
|
+
try {
|
|
875
|
+
const rawSnippet = await readFile(join(root, "docs", "agent", "generated", mcpSnippetFileName(state.agent)), "utf8");
|
|
876
|
+
const snippet = JSON.parse(rawSnippet);
|
|
877
|
+
for (const name of Object.keys(snippet.mcpServers ?? {}))
|
|
878
|
+
generated.add(name);
|
|
879
|
+
}
|
|
880
|
+
catch {
|
|
881
|
+
return generated;
|
|
882
|
+
}
|
|
883
|
+
return generated;
|
|
884
|
+
}
|
|
885
|
+
function lifecycleEnvStatus(server, env) {
|
|
886
|
+
if (server.required_env.some((name) => !envHasValue(env, name)))
|
|
887
|
+
return "missing-required";
|
|
888
|
+
if (server.recommended_env.some((name) => !envHasValue(env, name)))
|
|
889
|
+
return "missing-recommended";
|
|
890
|
+
return "ok";
|
|
891
|
+
}
|
|
892
|
+
function lifecycleState(selected, envStatus, install, snippet, client, probe) {
|
|
893
|
+
if (!selected)
|
|
894
|
+
return "not selected";
|
|
895
|
+
if (envStatus === "missing-required")
|
|
896
|
+
return "missing env";
|
|
897
|
+
if (install === "setup needed" || install === "requires local app" || install === "manual setup")
|
|
898
|
+
return install;
|
|
899
|
+
if (snippet === "missing")
|
|
900
|
+
return "setup needed";
|
|
901
|
+
if (client.endsWith(":not-added"))
|
|
902
|
+
return "setup needed";
|
|
903
|
+
if (probe === "unknown")
|
|
904
|
+
return "probe needed";
|
|
905
|
+
if (!isSuccessfulProbeStatus(probe))
|
|
906
|
+
return "probe failed";
|
|
907
|
+
return "ready";
|
|
908
|
+
}
|
|
909
|
+
function lifecycleInstallStatus(root, serverName, server, lock) {
|
|
910
|
+
if (server.connection_mode === "remote-curated" || server.connection_mode === "remote-custom")
|
|
911
|
+
return "remote";
|
|
912
|
+
if (server.connection_mode === "manual-local") {
|
|
913
|
+
const entry = lock.mcp[serverName];
|
|
914
|
+
const setup = entry?.setup;
|
|
915
|
+
if (setup?.status === "ready" &&
|
|
916
|
+
entry?.selected_mode === server.selected_mode &&
|
|
917
|
+
entry?.connection_mode === server.connection_mode &&
|
|
918
|
+
setup.wrapper_path &&
|
|
919
|
+
existsSync(join(root, setup.wrapper_path))) {
|
|
920
|
+
return "ready";
|
|
921
|
+
}
|
|
922
|
+
return "setup needed";
|
|
923
|
+
}
|
|
924
|
+
if (server.connection_mode === "local-service")
|
|
925
|
+
return "requires local app";
|
|
926
|
+
if (server.install_command)
|
|
927
|
+
return "finite-installer";
|
|
928
|
+
if (server.command)
|
|
929
|
+
return "runtime-only";
|
|
930
|
+
return "manual setup";
|
|
931
|
+
}
|
|
932
|
+
function lifecycleClientStatus(serverName, server, selected, agent, lock) {
|
|
933
|
+
if (!selected)
|
|
934
|
+
return "none";
|
|
935
|
+
if (!isSnippetCapable(server))
|
|
936
|
+
return "unsupported";
|
|
937
|
+
const normalizedAgent = assertKnownAgentTarget(agent);
|
|
938
|
+
if (normalizedAgent === "universal")
|
|
939
|
+
return "snippet";
|
|
940
|
+
if (normalizedAgent === "codex") {
|
|
941
|
+
const status = lock.mcp[serverName]?.clients?.codex?.status;
|
|
942
|
+
return status === "registered" ? "codex:registered" : "codex:not-added";
|
|
943
|
+
}
|
|
944
|
+
if (normalizedAgent === "claude-code" || normalizedAgent === "cursor")
|
|
945
|
+
return `${normalizedAgent}:manual`;
|
|
946
|
+
return "unknown";
|
|
947
|
+
}
|
|
948
|
+
function lifecycleNextAction(serverName, server, selected, envStatus, install, snippet, client, probe) {
|
|
949
|
+
if (!selected) {
|
|
950
|
+
const recommended = mcpRecommendedMode(serverName);
|
|
951
|
+
const alternatives = mcpServerModeKeys(serverName).filter((mode) => mode !== recommended);
|
|
952
|
+
const altText = alternatives.length > 0
|
|
953
|
+
? `, alternative: ${alternatives.map((mode) => mcpModeKeyLabelForAction(mode)).join(", ")}`
|
|
954
|
+
: "";
|
|
955
|
+
return `enable ${serverName}, recommended: ${mcpModeKeyLabelForAction(recommended)}${altText}`;
|
|
956
|
+
}
|
|
957
|
+
if (envStatus === "missing-required") {
|
|
958
|
+
if (server.connection_mode === "manual-local") {
|
|
959
|
+
return `fill ${server.required_env.join(", ")} in .env.local; then run npm run mcp:setup -- ${serverName} --mode local --env-file .env.local`;
|
|
960
|
+
}
|
|
961
|
+
return `fill ${server.required_env.join(", ")} in .env.local`;
|
|
962
|
+
}
|
|
963
|
+
if (install === "setup needed" && server.connection_mode === "manual-local") {
|
|
964
|
+
return `run npm run mcp:setup -- ${serverName} --mode local --env-file .env.local`;
|
|
965
|
+
}
|
|
966
|
+
if (install === "requires local app") {
|
|
967
|
+
return server.setup_commands[0] ?? `start local service for ${serverName}`;
|
|
968
|
+
}
|
|
969
|
+
if (snippet === "missing")
|
|
970
|
+
return "run npm run update -- --apply";
|
|
971
|
+
if (client.endsWith(":not-added"))
|
|
972
|
+
return `run npm run mcp:client:add -- ${serverName} --agent codex`;
|
|
973
|
+
if (!isSuccessfulProbeStatus(probe))
|
|
974
|
+
return `run npm run mcp:probe -- ${serverName} --env-file .env.local`;
|
|
975
|
+
return "ready";
|
|
976
|
+
}
|
|
977
|
+
function isSuccessfulProbeStatus(probe) {
|
|
978
|
+
return probe === "ok" || probe === "remote-configured";
|
|
979
|
+
}
|
|
980
|
+
function mcpModeKeyLabelForAction(mode) {
|
|
981
|
+
if (mode === "remote-custom")
|
|
982
|
+
return "custom remote";
|
|
983
|
+
if (mode === "remote")
|
|
984
|
+
return "remote";
|
|
985
|
+
if (mode === "manual")
|
|
986
|
+
return "manual setup";
|
|
987
|
+
return "local";
|
|
988
|
+
}
|
|
989
|
+
function mcpInstallSkip(serverName, server) {
|
|
990
|
+
if (server.connection_mode === "manual-local") {
|
|
991
|
+
return {
|
|
992
|
+
server: serverName,
|
|
993
|
+
reason: "manual setup",
|
|
994
|
+
next: `run npm run mcp:setup -- ${serverName} --mode local --env-file .env.local`
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
if (server.connection_mode === "remote-curated" || server.connection_mode === "remote-custom") {
|
|
998
|
+
return { server: serverName, reason: "remote", next: "no local installer; register the hosted endpoint with the MCP client" };
|
|
999
|
+
}
|
|
1000
|
+
if (server.connection_mode === "local-service") {
|
|
1001
|
+
return {
|
|
1002
|
+
server: serverName,
|
|
1003
|
+
reason: "requires local app",
|
|
1004
|
+
next: server.setup_commands[0] ? `run ${server.setup_commands[0]}` : "satisfy the local service prerequisite"
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
if (server.command) {
|
|
1008
|
+
return { server: serverName, reason: "runtime-only", next: "the MCP client launches it on demand" };
|
|
1009
|
+
}
|
|
1010
|
+
return { server: serverName, reason: "manual", next: "follow docs/agent/mcp-setup.md" };
|
|
1011
|
+
}
|
|
1012
|
+
function mcpUninstallSkip(serverName, server) {
|
|
1013
|
+
const skipped = mcpInstallSkip(serverName, server);
|
|
1014
|
+
return { ...skipped, next: skipped.reason === "runtime-only" ? "no persistent tool was installed" : skipped.next };
|
|
1015
|
+
}
|
|
1016
|
+
function isSnippetCapable(server) {
|
|
1017
|
+
return Boolean(server.command ||
|
|
1018
|
+
(server.connection_mode === "remote-curated" && server.hosted_url) ||
|
|
1019
|
+
server.connection_mode === "remote-custom");
|
|
1020
|
+
}
|
|
1021
|
+
function capabilityLockPath(root) {
|
|
1022
|
+
return join(root, "docs", "agent", "capability-lock.json");
|
|
1023
|
+
}
|
|
1024
|
+
async function updateCapabilityLock(root, update) {
|
|
1025
|
+
const lock = await readCapabilityLock(root);
|
|
1026
|
+
lock.generator = {
|
|
1027
|
+
name: "create-academic-research",
|
|
1028
|
+
version: await currentPackageVersion(),
|
|
1029
|
+
updated_at: nowIso()
|
|
1030
|
+
};
|
|
1031
|
+
update(lock);
|
|
1032
|
+
await mkdir(dirname(capabilityLockPath(root)), { recursive: true });
|
|
1033
|
+
await writeFile(capabilityLockPath(root), `${JSON.stringify(lock, null, 2)}\n`, "utf8");
|
|
1034
|
+
}
|
|
1035
|
+
function defaultCapabilityLock() {
|
|
1036
|
+
return {
|
|
1037
|
+
version: 1,
|
|
1038
|
+
generator: { name: "create-academic-research" },
|
|
1039
|
+
mcp: {},
|
|
1040
|
+
skills: { sources: {}, skills: {} }
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
function normalizeCapabilityLockGenerator(value) {
|
|
1044
|
+
const record = typeof value === "object" && value !== null ? value : {};
|
|
1045
|
+
return {
|
|
1046
|
+
name: "create-academic-research",
|
|
1047
|
+
...(typeof record.version === "string" ? { version: record.version } : {}),
|
|
1048
|
+
...(typeof record.updated_at === "string" ? { updated_at: record.updated_at } : {})
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
function normalizeCapabilityLockSkills(value) {
|
|
1052
|
+
const record = typeof value === "object" && value !== null ? value : {};
|
|
1053
|
+
return {
|
|
1054
|
+
...(typeof record.preset === "string" ? { preset: record.preset } : {}),
|
|
1055
|
+
...(typeof record.agent === "string" ? { agent: record.agent } : {}),
|
|
1056
|
+
...(Array.isArray(record.explicit_skill_ids)
|
|
1057
|
+
? { explicit_skill_ids: record.explicit_skill_ids.filter((item) => typeof item === "string") }
|
|
1058
|
+
: {}),
|
|
1059
|
+
...(record.last_action === "install" || record.last_action === "update" || record.last_action === "remove"
|
|
1060
|
+
? { last_action: record.last_action }
|
|
1061
|
+
: {}),
|
|
1062
|
+
...(record.status === "ready" || record.status === "updated" || record.status === "removed"
|
|
1063
|
+
? { status: record.status }
|
|
1064
|
+
: {}),
|
|
1065
|
+
...(typeof record.updated_at === "string" ? { updated_at: record.updated_at } : {}),
|
|
1066
|
+
sources: normalizeCapabilityLockEntryMap(record.sources),
|
|
1067
|
+
skills: normalizeCapabilityLockEntryMap(record.skills)
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
function normalizeCapabilityLockEntryMap(value) {
|
|
1071
|
+
if (typeof value !== "object" || value === null)
|
|
1072
|
+
return {};
|
|
1073
|
+
const result = {};
|
|
1074
|
+
for (const [name, rawEntry] of Object.entries(value)) {
|
|
1075
|
+
if (typeof rawEntry !== "object" || rawEntry === null)
|
|
1076
|
+
continue;
|
|
1077
|
+
const entry = rawEntry;
|
|
1078
|
+
if (typeof entry.source !== "string")
|
|
1079
|
+
continue;
|
|
1080
|
+
result[name] = {
|
|
1081
|
+
source: entry.source,
|
|
1082
|
+
...(Array.isArray(entry.skill_ids)
|
|
1083
|
+
? { skill_ids: entry.skill_ids.filter((item) => typeof item === "string") }
|
|
1084
|
+
: {}),
|
|
1085
|
+
action: entry.action === "update" || entry.action === "remove" ? entry.action : "install",
|
|
1086
|
+
status: entry.status === "updated" || entry.status === "removed" ? entry.status : "ready",
|
|
1087
|
+
...(typeof entry.updated_at === "string" ? { updated_at: entry.updated_at } : {}),
|
|
1088
|
+
...(typeof entry.root === "string" ? { root: entry.root } : {}),
|
|
1089
|
+
...(typeof entry.path === "string" ? { path: entry.path } : {})
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
return result;
|
|
1093
|
+
}
|
|
1094
|
+
function ensureLockMcpEntry(lock, serverName, server) {
|
|
1095
|
+
const entry = lock.mcp[serverName] ?? {};
|
|
1096
|
+
entry.selected_mode = server.selected_mode;
|
|
1097
|
+
entry.connection_mode = server.connection_mode;
|
|
1098
|
+
lock.mcp[serverName] = entry;
|
|
1099
|
+
return entry;
|
|
1100
|
+
}
|
|
1101
|
+
function overleafPaths(root) {
|
|
1102
|
+
const relativeBase = ".academic-research/mcp/overleaf";
|
|
1103
|
+
const wrapperDir = join(root, relativeBase);
|
|
1104
|
+
return {
|
|
1105
|
+
wrapperDir,
|
|
1106
|
+
serverDir: join(wrapperDir, "server"),
|
|
1107
|
+
wrapper: join(wrapperDir, "run-overleaf-mcp.sh"),
|
|
1108
|
+
launcher: join(wrapperDir, "run-overleaf-mcp.mjs"),
|
|
1109
|
+
relativeServer: `${relativeBase}/server`,
|
|
1110
|
+
relativeWrapper: `${relativeBase}/run-overleaf-mcp.sh`,
|
|
1111
|
+
relativeLauncher: `${relativeBase}/run-overleaf-mcp.mjs`
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
function renderOverleafWrapper() {
|
|
1115
|
+
return [
|
|
1116
|
+
"#!/bin/sh",
|
|
1117
|
+
"set -eu",
|
|
1118
|
+
"WRAPPER_DIR=$(CDPATH= cd -- \"$(dirname -- \"$0\")\" && pwd)",
|
|
1119
|
+
"# .env.local is parsed by the Node launcher; this shell wrapper never evaluates it.",
|
|
1120
|
+
"exec node \"$WRAPPER_DIR/run-overleaf-mcp.mjs\"",
|
|
1121
|
+
""
|
|
1122
|
+
].join("\n");
|
|
1123
|
+
}
|
|
1124
|
+
function renderOverleafLauncher() {
|
|
1125
|
+
return [
|
|
1126
|
+
"#!/usr/bin/env node",
|
|
1127
|
+
"import { existsSync, readFileSync } from 'node:fs';",
|
|
1128
|
+
"import { dirname, join, resolve } from 'node:path';",
|
|
1129
|
+
"import { fileURLToPath } from 'node:url';",
|
|
1130
|
+
"import { spawn } from 'node:child_process';",
|
|
1131
|
+
"",
|
|
1132
|
+
"const wrapperDir = dirname(fileURLToPath(import.meta.url));",
|
|
1133
|
+
"const projectRoot = resolve(wrapperDir, '../../..');",
|
|
1134
|
+
"const serverDir = process.env.OVERLEAF_MCP_SERVER_PATH || join(projectRoot, '.academic-research/mcp/overleaf/server');",
|
|
1135
|
+
"const envFile = process.env.ACADEMIC_RESEARCH_MCP_ENV_FILE || join(projectRoot, '.env.local');",
|
|
1136
|
+
"const env = { ...process.env };",
|
|
1137
|
+
"if (existsSync(envFile)) {",
|
|
1138
|
+
" for (const [name, value] of Object.entries(parseDotenv(readFileSync(envFile, 'utf8'), envFile))) {",
|
|
1139
|
+
" if (value || !(name in env)) env[name] = value;",
|
|
1140
|
+
" }",
|
|
1141
|
+
"}",
|
|
1142
|
+
"env.PYTHONPATH = env.PYTHONPATH || '.';",
|
|
1143
|
+
"const child = spawn('uv', ['run', 'src/main.py'], { cwd: serverDir, env, stdio: 'inherit' });",
|
|
1144
|
+
"child.on('error', (error) => {",
|
|
1145
|
+
" console.error(`Failed to launch Overleaf MCP server: ${error.message}`);",
|
|
1146
|
+
" process.exitCode = 1;",
|
|
1147
|
+
"});",
|
|
1148
|
+
"child.on('exit', (code, signal) => {",
|
|
1149
|
+
" if (signal) process.kill(process.pid, signal);",
|
|
1150
|
+
" process.exit(code ?? 1);",
|
|
1151
|
+
"});",
|
|
1152
|
+
"",
|
|
1153
|
+
"function parseDotenv(raw, path) {",
|
|
1154
|
+
" const env = {};",
|
|
1155
|
+
" const lines = raw.split(/\\r?\\n/);",
|
|
1156
|
+
" for (const [index, line] of lines.entries()) {",
|
|
1157
|
+
" let text = line.trim();",
|
|
1158
|
+
" if (!text || text.startsWith('#')) continue;",
|
|
1159
|
+
" if (text.startsWith('export ')) text = text.slice('export '.length).trimStart();",
|
|
1160
|
+
" const equals = text.indexOf('=');",
|
|
1161
|
+
" if (equals === -1) throw new Error(`${path}:${index + 1}: expected KEY=value`);",
|
|
1162
|
+
" const key = text.slice(0, equals).trim();",
|
|
1163
|
+
" if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {",
|
|
1164
|
+
" throw new Error(`${path}:${index + 1}: invalid environment variable name: ${key}`);",
|
|
1165
|
+
" }",
|
|
1166
|
+
" env[key] = parseDotenvValue(text.slice(equals + 1).trim(), path, index + 1);",
|
|
1167
|
+
" }",
|
|
1168
|
+
" return env;",
|
|
1169
|
+
"}",
|
|
1170
|
+
"",
|
|
1171
|
+
"function parseDotenvValue(value, path, line) {",
|
|
1172
|
+
" if (!value) return '';",
|
|
1173
|
+
" const quote = value[0];",
|
|
1174
|
+
" if (quote === \"'\" || quote === '\"') {",
|
|
1175
|
+
" if (!value.endsWith(quote) || value.length === 1) throw new Error(`${path}:${line}: unterminated quoted value`);",
|
|
1176
|
+
" const unquoted = value.slice(1, -1);",
|
|
1177
|
+
" if (quote === \"'\") return unquoted;",
|
|
1178
|
+
" return unquoted",
|
|
1179
|
+
" .replaceAll('\\\\n', '\\n')",
|
|
1180
|
+
" .replaceAll('\\\\r', '\\r')",
|
|
1181
|
+
" .replaceAll('\\\\t', '\\t')",
|
|
1182
|
+
" .replaceAll('\\\\\"', '\"')",
|
|
1183
|
+
" .replaceAll('\\\\\\\\', '\\\\');",
|
|
1184
|
+
" }",
|
|
1185
|
+
" return value.replace(/\\s+#.*$/, '').trimEnd();",
|
|
1186
|
+
"}",
|
|
1187
|
+
""
|
|
1188
|
+
].join("\n");
|
|
1189
|
+
}
|
|
1190
|
+
async function clientRegistrationReadiness(root, serverName, server, mode, state) {
|
|
1191
|
+
const remote = state.mcp_server_remote?.[serverName];
|
|
1192
|
+
if (server.connection_mode === "remote-custom" && remote?.url_env) {
|
|
1193
|
+
return {
|
|
1194
|
+
ready: false,
|
|
1195
|
+
instructions: [
|
|
1196
|
+
"Codex CLI does not support URL env vars for remote MCP endpoints.",
|
|
1197
|
+
`Either re-enable this server with \`npm run mcp:enable -- ${serverName} --mode remote-custom --url <url>\` if the endpoint URL may be stored in Codex config, or register it manually with \`codex mcp add ${serverName} --url "$${remote.url_env}"\` from a shell where the env var is set.`
|
|
1198
|
+
]
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
if (serverName !== "overleaf")
|
|
1202
|
+
return { ready: true, instructions: [] };
|
|
1203
|
+
const lock = await readCapabilityLock(root);
|
|
1204
|
+
const setup = lock.mcp.overleaf?.setup;
|
|
1205
|
+
const wrapperPath = setup?.wrapper_path ? join(root, setup.wrapper_path) : "";
|
|
1206
|
+
const ready = setup?.status === "ready" &&
|
|
1207
|
+
lock.mcp.overleaf?.selected_mode === mode &&
|
|
1208
|
+
lock.mcp.overleaf?.connection_mode === server.connection_mode &&
|
|
1209
|
+
Boolean(wrapperPath) &&
|
|
1210
|
+
existsSync(wrapperPath);
|
|
1211
|
+
if (ready)
|
|
1212
|
+
return { ready: true, instructions: [] };
|
|
1213
|
+
return {
|
|
1214
|
+
ready: false,
|
|
1215
|
+
instructions: [
|
|
1216
|
+
"Overleaf setup is not ready. Next: npm run mcp:setup -- overleaf --mode local --env-file .env.local"
|
|
1217
|
+
]
|
|
1218
|
+
};
|
|
1219
|
+
}
|
|
1220
|
+
function codexAddCommand(root, serverName, server, state) {
|
|
1221
|
+
if ((server.connection_mode === "remote-curated" || server.connection_mode === "remote-custom") && server.hosted_url) {
|
|
1222
|
+
const command = ["codex", "mcp", "add", serverName, "--url", server.hosted_url];
|
|
1223
|
+
const tokenEnv = state.mcp_server_remote?.[serverName]?.bearer_token_env_var;
|
|
1224
|
+
if (tokenEnv)
|
|
1225
|
+
command.push("--bearer-token-env-var", tokenEnv);
|
|
1226
|
+
return command;
|
|
1227
|
+
}
|
|
1228
|
+
if (server.connection_mode === "remote-custom" && state.mcp_server_remote?.[serverName]?.url_env) {
|
|
1229
|
+
return [];
|
|
1230
|
+
}
|
|
1231
|
+
if (!server.command)
|
|
1232
|
+
throw new Error(`${serverName} does not have a command or hosted URL for client registration`);
|
|
1233
|
+
const command = commandForClient(root, server.command);
|
|
1234
|
+
return ["codex", "mcp", "add", serverName, "--", command, ...server.args];
|
|
1235
|
+
}
|
|
1236
|
+
function commandForClient(root, command) {
|
|
1237
|
+
if (!command.includes("/") && !command.includes("\\"))
|
|
1238
|
+
return command;
|
|
1239
|
+
return isAbsolute(command) ? command : join(root, command);
|
|
1240
|
+
}
|
|
1241
|
+
async function recordSkillPresetLock(root, preset, agent, action) {
|
|
1242
|
+
const selected = AGENT_STACK.presets[preset];
|
|
1243
|
+
if (!selected)
|
|
1244
|
+
return;
|
|
1245
|
+
await updateCapabilityLock(root, (lock) => {
|
|
1246
|
+
lock.skills.preset = preset;
|
|
1247
|
+
lock.skills.agent = agent;
|
|
1248
|
+
delete lock.skills.explicit_skill_ids;
|
|
1249
|
+
lock.skills.last_action = action;
|
|
1250
|
+
lock.skills.status = "ready";
|
|
1251
|
+
lock.skills.updated_at = nowIso();
|
|
1252
|
+
for (const bundleName of selected.skill_bundles) {
|
|
1253
|
+
for (const source of skillSourcesForBundle(bundleName)) {
|
|
1254
|
+
lock.skills.sources[source.key] = {
|
|
1255
|
+
source: source.source,
|
|
1256
|
+
skill_ids: source.skillIds,
|
|
1257
|
+
action,
|
|
1258
|
+
status: "ready",
|
|
1259
|
+
updated_at: nowIso()
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
1265
|
+
async function recordExplicitSkillLock(root, skills, agent, action) {
|
|
1266
|
+
await updateCapabilityLock(root, (lock) => {
|
|
1267
|
+
lock.skills.agent = agent;
|
|
1268
|
+
lock.skills.explicit_skill_ids = skills;
|
|
1269
|
+
lock.skills.last_action = action;
|
|
1270
|
+
lock.skills.status = "ready";
|
|
1271
|
+
lock.skills.updated_at = nowIso();
|
|
1272
|
+
for (const skill of skills) {
|
|
1273
|
+
const source = skillSourceForId(skill);
|
|
1274
|
+
if (!source)
|
|
1275
|
+
continue;
|
|
1276
|
+
lock.skills.skills[skill] = {
|
|
1277
|
+
source,
|
|
1278
|
+
action,
|
|
1279
|
+
status: "ready",
|
|
1280
|
+
updated_at: nowIso()
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1285
|
+
async function recordSkillUpdateLock(root) {
|
|
1286
|
+
await updateCapabilityLock(root, (lock) => {
|
|
1287
|
+
lock.skills.last_action = "update";
|
|
1288
|
+
lock.skills.status = "updated";
|
|
1289
|
+
lock.skills.updated_at = nowIso();
|
|
1290
|
+
for (const entry of Object.values(lock.skills.sources)) {
|
|
1291
|
+
entry.action = "update";
|
|
1292
|
+
entry.status = "updated";
|
|
1293
|
+
entry.updated_at = nowIso();
|
|
1294
|
+
}
|
|
1295
|
+
for (const entry of Object.values(lock.skills.skills)) {
|
|
1296
|
+
entry.action = "update";
|
|
1297
|
+
entry.status = "updated";
|
|
1298
|
+
entry.updated_at = nowIso();
|
|
1299
|
+
}
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
async function recordRemovedSkillLock(root, skills) {
|
|
1303
|
+
await updateCapabilityLock(root, (lock) => {
|
|
1304
|
+
lock.skills.last_action = "remove";
|
|
1305
|
+
lock.skills.status = "removed";
|
|
1306
|
+
lock.skills.updated_at = nowIso();
|
|
1307
|
+
for (const skill of normalizeSkillIds(skills)) {
|
|
1308
|
+
const source = skillSourceForId(skill) ?? lock.skills.skills[skill]?.source ?? "unknown";
|
|
1309
|
+
lock.skills.skills[skill] = {
|
|
1310
|
+
source,
|
|
1311
|
+
action: "remove",
|
|
1312
|
+
status: "removed",
|
|
1313
|
+
updated_at: nowIso()
|
|
1314
|
+
};
|
|
1315
|
+
}
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
function skillSourcesForBundle(bundleName) {
|
|
1319
|
+
const bundle = AGENT_STACK.skill_bundles[bundleName];
|
|
1320
|
+
if (!bundle)
|
|
1321
|
+
return [];
|
|
1322
|
+
const selections = new Map();
|
|
1323
|
+
for (const command of bundle.commands) {
|
|
1324
|
+
const match = /\bskills\s+add\s+(\S+)/.exec(command);
|
|
1325
|
+
const source = match?.[1];
|
|
1326
|
+
if (!source)
|
|
1327
|
+
continue;
|
|
1328
|
+
const knownSource = skillSourceEntryForSource(source);
|
|
1329
|
+
const key = knownSource?.key ?? bundleName;
|
|
1330
|
+
const skillIds = skillIdsForBundleCommand(command, source);
|
|
1331
|
+
const existing = selections.get(key);
|
|
1332
|
+
selections.set(key, {
|
|
1333
|
+
key,
|
|
1334
|
+
source,
|
|
1335
|
+
skillIds: [...new Set([...(existing?.skillIds ?? []), ...skillIds])]
|
|
1336
|
+
});
|
|
1337
|
+
}
|
|
1338
|
+
return [...selections.values()];
|
|
1339
|
+
}
|
|
1340
|
+
function skillSourceEntryForSource(source) {
|
|
1341
|
+
for (const [key, entry] of Object.entries(AGENT_STACK.skill_sources)) {
|
|
1342
|
+
if (entry.source === source)
|
|
1343
|
+
return { key, skills: entry.skills };
|
|
1344
|
+
}
|
|
1345
|
+
return undefined;
|
|
1346
|
+
}
|
|
1347
|
+
function skillIdsForBundleCommand(command, source) {
|
|
1348
|
+
const known = skillSourceEntryForSource(source);
|
|
1349
|
+
const tokens = splitCommand(command);
|
|
1350
|
+
const skillFlag = tokens.indexOf("--skill");
|
|
1351
|
+
if (skillFlag === -1)
|
|
1352
|
+
return known?.skills ?? [];
|
|
1353
|
+
const selected = [];
|
|
1354
|
+
for (let index = skillFlag + 1; index < tokens.length; index += 1) {
|
|
1355
|
+
const token = tokens[index];
|
|
1356
|
+
if (token.startsWith("-"))
|
|
1357
|
+
break;
|
|
1358
|
+
if (token === "*")
|
|
1359
|
+
return known?.skills ?? [];
|
|
1360
|
+
selected.push(token);
|
|
1361
|
+
}
|
|
1362
|
+
return selected.length > 0 ? selected : known?.skills ?? [];
|
|
1363
|
+
}
|
|
1364
|
+
function nowIso() {
|
|
1365
|
+
return new Date().toISOString();
|
|
1366
|
+
}
|
|
1367
|
+
async function currentPackageVersion() {
|
|
1368
|
+
try {
|
|
1369
|
+
const parsed = JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8"));
|
|
1370
|
+
return typeof parsed.version === "string" ? parsed.version : undefined;
|
|
1371
|
+
}
|
|
1372
|
+
catch {
|
|
1373
|
+
return undefined;
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
463
1376
|
async function readCapabilitiesFile(root) {
|
|
464
1377
|
const path = join(root, "configs/capabilities.yaml");
|
|
465
1378
|
return normalizeCapabilityState(YAML.parse(await readFile(path, "utf8")));
|
|
@@ -500,17 +1413,49 @@ function splitCommand(command) {
|
|
|
500
1413
|
}
|
|
501
1414
|
function normalizeCapabilityState(value) {
|
|
502
1415
|
const record = typeof value === "object" && value !== null ? value : {};
|
|
1416
|
+
const servers = Array.isArray(record.mcp_servers)
|
|
1417
|
+
? record.mcp_servers.filter((item) => typeof item === "string")
|
|
1418
|
+
: [];
|
|
1419
|
+
const modeRecord = typeof record.mcp_server_modes === "object" && record.mcp_server_modes !== null
|
|
1420
|
+
? record.mcp_server_modes
|
|
1421
|
+
: {};
|
|
1422
|
+
const mcpServerModes = {};
|
|
1423
|
+
for (const [server, mode] of Object.entries(modeRecord)) {
|
|
1424
|
+
if (typeof mode === "string" && servers.includes(server)) {
|
|
1425
|
+
mcpServerModes[server] = normalizeMcpMode(server, mode);
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
const remoteRecord = typeof record.mcp_server_remote === "object" && record.mcp_server_remote !== null
|
|
1429
|
+
? record.mcp_server_remote
|
|
1430
|
+
: {};
|
|
1431
|
+
const mcpServerRemote = {};
|
|
1432
|
+
for (const [server, config] of Object.entries(remoteRecord)) {
|
|
1433
|
+
if (!servers.includes(server))
|
|
1434
|
+
continue;
|
|
1435
|
+
if (normalizeMcpMode(server, mcpServerModes[server]) !== "remote-custom")
|
|
1436
|
+
continue;
|
|
1437
|
+
if (typeof config === "object" && config !== null) {
|
|
1438
|
+
mcpServerRemote[server] = normalizeRemoteConfig(server, config);
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
503
1441
|
return {
|
|
504
1442
|
agent: assertKnownAgentTarget(typeof record.agent === "string" ? record.agent : undefined),
|
|
505
1443
|
preset: typeof record.preset === "string" ? record.preset : "default",
|
|
506
1444
|
scope: "project-local",
|
|
507
|
-
mcp_servers:
|
|
508
|
-
|
|
509
|
-
|
|
1445
|
+
mcp_servers: servers,
|
|
1446
|
+
mcp_server_modes: mcpServerModes,
|
|
1447
|
+
mcp_server_remote: mcpServerRemote
|
|
510
1448
|
};
|
|
511
1449
|
}
|
|
512
1450
|
function defaultCapabilities() {
|
|
513
|
-
return {
|
|
1451
|
+
return {
|
|
1452
|
+
agent: DEFAULT_AGENT,
|
|
1453
|
+
preset: "default",
|
|
1454
|
+
scope: "project-local",
|
|
1455
|
+
mcp_servers: [],
|
|
1456
|
+
mcp_server_modes: {},
|
|
1457
|
+
mcp_server_remote: {}
|
|
1458
|
+
};
|
|
514
1459
|
}
|
|
515
1460
|
function isMissingFileError(error) {
|
|
516
1461
|
return (typeof error === "object" &&
|