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/dist/src/cli.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { basename, delimiter, dirname, join, resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
- import { assertKnownMcpServers, disableMcpServers, doctorMcpServers, enableMcpServers, DEFAULT_AGENT, installMcpTools, installSkillIds, installSkills, formatMcpDotenv, listInstalledSkills, listMcpEnvironmentEntries, mergeMcpEnvironment, mcpToolCommandTexts, probeMcpServers, readCapabilities, readMcpEnvironmentFile, removeSkills, uninstallMcpTools, updateSkills } from "./capabilities.js";
5
- import { createProject, doctorProject, renameProject } from "./project.js";
4
+ import { assertKnownMcpServers, clientAddMcpServer, clientRemoveMcpServer, disableMcpServers, doctorMcpServers, enableMcpServers, DEFAULT_AGENT, getMcpLifecycleStatus, installMcpTools, installSkillIds, installSkills, formatMcpDotenv, listInstalledSkills, listMcpEnvironmentEntries, mergeMcpEnvironment, mcpToolCommandTexts, probeMcpServers, readCapabilities, readMcpEnvironmentFile, removeSkills, resolveMcpServerForState, setupMcpServer, uninstallMcpTools, updateSkills } from "./capabilities.js";
5
+ import { createProject, doctorProject, initProject, renameProject, updateProject } from "./project.js";
6
6
  import { askCreateOptions } from "./prompts.js";
7
- import { AGENT_STACK, presetMcpServers } from "./stack.js";
7
+ import { AGENT_STACK, mcpModeLabel, mcpRecommendedMode, mcpServerModeKeys, mcpSupportedModeLabels, presetMcpServers, resolveMcpServer } from "./stack.js";
8
8
  import { formatAgentAliasLines, formatAgentTargetList, formatSupportedAgentTargetLines } from "./agents.js";
9
9
  import { packageify, slugify, titleFromSlug } from "./names.js";
10
10
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
@@ -19,9 +19,11 @@ const CREATE_FLAGS = flagSchema([
19
19
  "no-install-mcp-tools"
20
20
  ], ["title", "slug", "package", "preset", "profile", "agent"]);
21
21
  const ROOT_FLAGS = flagSchema(["help"], ["root"]);
22
+ const UPDATE_FLAGS = flagSchema(["help", "dry-run", "apply"], ["root"]);
23
+ const INIT_FLAGS = flagSchema(["help", "install-skills"], ["root", "title", "slug", "package", "preset", "profile", "agent"]);
22
24
  const RENAME_FLAGS = flagSchema(["help"], ["root", "title", "slug", "package"]);
23
25
  const SKILLS_FLAGS = flagSchema(["help"], ["root", "preset", "agent"]);
24
- const MCP_FLAGS = flagSchema(["help", "all", "dotenv", "required", "recommended"], ["root", "agent", "env-file", "write", "timeout-ms"]);
26
+ const MCP_FLAGS = flagSchema(["help", "all", "dotenv", "required", "recommended", "dry-run", "verbose"], ["root", "agent", "env-file", "write", "timeout-ms", "mode", "url", "url-env", "bearer-token-env-var"]);
25
27
  export async function main(argv = process.argv.slice(2), mode = "create") {
26
28
  try {
27
29
  if (mode === "create")
@@ -113,6 +115,10 @@ async function lifecycleMain(argv) {
113
115
  }
114
116
  if (command === "doctor")
115
117
  return doctorCommand(argv.slice(1));
118
+ if (command === "update")
119
+ return updateCommand(argv.slice(1));
120
+ if (command === "init")
121
+ return initCommand(argv.slice(1));
116
122
  if (command === "setup")
117
123
  return setupCommand(argv.slice(1));
118
124
  if (command === "rename")
@@ -136,10 +142,61 @@ async function doctorCommand(argv) {
136
142
  const result = await doctorProject(root);
137
143
  for (const error of result.errors)
138
144
  console.error(`ERROR: ${error}`);
145
+ for (const warning of result.warnings)
146
+ console.warn(`WARN: ${warning}`);
139
147
  if (result.ok)
140
148
  console.log(`OK: ${root}`);
141
149
  return result.ok ? 0 : 1;
142
150
  }
151
+ async function updateCommand(argv) {
152
+ const parsed = parseFlags(argv, UPDATE_FLAGS);
153
+ if (flagBool(parsed.flags, "help")) {
154
+ printUpdateHelp();
155
+ return 0;
156
+ }
157
+ assertNoArguments(parsed.positionals, "update");
158
+ if (flagBool(parsed.flags, "dry-run") && flagBool(parsed.flags, "apply")) {
159
+ throw new Error("update cannot use --dry-run and --apply together");
160
+ }
161
+ const root = resolve(flagString(parsed.flags, "root") ?? ".");
162
+ const apply = flagBool(parsed.flags, "apply");
163
+ const result = await updateProject(root, { apply });
164
+ console.log(`${apply ? "UPDATED" : "DRY-RUN"}: ${root}`);
165
+ if (result.changes.length === 0) {
166
+ console.log("No managed file changes.");
167
+ }
168
+ else {
169
+ for (const change of result.changes) {
170
+ console.log(`${change.action}\t${change.path}${change.reason ? `\t${change.reason}` : ""}`);
171
+ }
172
+ }
173
+ if (!apply && result.changes.length > 0) {
174
+ console.log("Run `npm run update -- --apply` from a generated project to write these managed changes.");
175
+ }
176
+ return 0;
177
+ }
178
+ async function initCommand(argv) {
179
+ const parsed = parseFlags(argv, INIT_FLAGS);
180
+ if (flagBool(parsed.flags, "help")) {
181
+ printInitHelp();
182
+ return 0;
183
+ }
184
+ assertNoArguments(parsed.positionals, "init");
185
+ const root = resolve(flagString(parsed.flags, "root") ?? ".");
186
+ const result = await initProject({
187
+ target: root,
188
+ title: flagString(parsed.flags, "title"),
189
+ slug: flagString(parsed.flags, "slug"),
190
+ packageName: flagString(parsed.flags, "package"),
191
+ profile: flagString(parsed.flags, "profile") ?? "academic-general",
192
+ preset: flagString(parsed.flags, "preset") ?? "default",
193
+ agent: flagString(parsed.flags, "agent") ?? DEFAULT_AGENT,
194
+ installSkills: flagBool(parsed.flags, "install-skills")
195
+ });
196
+ console.log(`Initialized ${result.slug} at ${result.root}`);
197
+ console.log("Next: run `npm run doctor`.");
198
+ return 0;
199
+ }
143
200
  async function setupCommand(argv) {
144
201
  const parsed = parseFlags(argv, ROOT_FLAGS);
145
202
  if (flagBool(parsed.flags, "help")) {
@@ -161,19 +218,28 @@ async function setupCommand(argv) {
161
218
  console.log(`installed_skill_ids\t${skillIds.size}`);
162
219
  console.log(`installed_skill_copies\t${skills.length}`);
163
220
  console.log(`mcp_enabled\t${state.mcp_servers.length > 0 ? state.mcp_servers.join(",") : "none"}`);
221
+ console.log(`mcp_selected\t${state.mcp_servers.length > 0 ? state.mcp_servers.join(",") : "none"}`);
164
222
  if (!project.ok) {
165
223
  for (const error of project.errors)
166
224
  console.error(`ERROR: ${error}`);
167
225
  }
226
+ for (const warning of project.warnings)
227
+ console.warn(`WARN: ${warning}`);
228
+ const lifecycle = await getMcpLifecycleStatus(root);
168
229
  console.log("");
169
230
  console.log("Next Commands");
170
231
  console.log(`npm run skills:install -- --preset ${state.preset}`);
171
232
  console.log("npm run skills:status");
172
233
  console.log("npm run mcp:list");
234
+ console.log("npm run mcp:status");
173
235
  console.log("npm run mcp:env");
174
236
  console.log("npm run mcp:dotenv");
175
237
  console.log("npm run mcp:smoke");
176
- console.log("npm run mcp:probe -- arxiv");
238
+ for (const item of lifecycle.servers.filter((server) => server.selected)) {
239
+ for (const command of setupNextCommands(item)) {
240
+ console.log(command);
241
+ }
242
+ }
177
243
  console.log("npm run doctor");
178
244
  return project.ok ? 0 : 1;
179
245
  }
@@ -313,6 +379,41 @@ async function mcpCommand(argv) {
313
379
  }
314
380
  return 0;
315
381
  }
382
+ if (subcommand === "modes") {
383
+ assertOnlyOptions(parsed.flags, "mcp modes", ["root"]);
384
+ const root = resolve(flagString(parsed.flags, "root") ?? ".");
385
+ const state = await readCapabilities(root);
386
+ if (parsed.positionals.length > 1) {
387
+ throw new Error(`mcp modes accepts at most one server: ${parsed.positionals.join(" ")}`);
388
+ }
389
+ if (parsed.positionals.length === 1) {
390
+ assertKnownMcpServers(parsed.positionals);
391
+ printMcpModeDetail(parsed.positionals[0], state);
392
+ return 0;
393
+ }
394
+ printMcpModesTable(state);
395
+ return 0;
396
+ }
397
+ if (subcommand === "status") {
398
+ assertOnlyOptions(parsed.flags, "mcp status", ["root", "env-file", "verbose"]);
399
+ const root = resolve(flagString(parsed.flags, "root") ?? ".");
400
+ assertNoArguments(parsed.positionals, "mcp status");
401
+ const env = await mcpCommandEnvironment(root, parsed.flags);
402
+ const status = await getMcpLifecycleStatus(root, { env });
403
+ if (flagBool(parsed.flags, "verbose")) {
404
+ console.log("id\tselected\tmode\tconnection_mode\tenv\tinstall\tsnippet\tclient\tprobe\tnext");
405
+ for (const item of status.servers) {
406
+ console.log(`${item.id}\t${item.selected ? "yes" : "no"}\t${item.mode}\t${item.connection_mode}\t${item.env}\t${item.install}\t${item.snippet}\t${item.client}\t${item.probe}\t${item.next}`);
407
+ }
408
+ }
409
+ else {
410
+ console.log("id\tselected\tmode\tstate\tnext");
411
+ for (const item of status.servers) {
412
+ console.log(`${item.id}\t${item.selected ? "yes" : "no"}\t${item.mode}\t${item.state}\t${friendlyNext(item.next)}`);
413
+ }
414
+ }
415
+ return 0;
416
+ }
316
417
  if (subcommand === "enabled") {
317
418
  assertOnlyOptions(parsed.flags, "mcp enabled", ["root"]);
318
419
  const root = resolve(flagString(parsed.flags, "root") ?? ".");
@@ -334,37 +435,45 @@ async function mcpCommand(argv) {
334
435
  if (subcommand === "commands") {
335
436
  assertOnlyOptions(parsed.flags, "mcp commands", ["root"]);
336
437
  const root = resolve(flagString(parsed.flags, "root") ?? ".");
337
- const selected = parsed.positionals.length > 0 ? parsed.positionals : (await readCapabilities(root)).mcp_servers;
338
- const commands = mcpToolCommandTexts(selected, "install_command");
438
+ const state = await readCapabilities(root);
439
+ const selected = parsed.positionals.length > 0 ? parsed.positionals : state.mcp_servers;
440
+ const modes = Object.fromEntries(selected.map((server) => [server, state.mcp_server_modes[server]]));
441
+ const commands = mcpToolCommandTexts(selected, "install_command", modes);
339
442
  for (const command of commands)
340
443
  console.log(command);
341
444
  return 0;
342
445
  }
343
446
  if (subcommand === "env") {
344
- assertOnlyOptions(parsed.flags, "mcp env", ["root", "all", "dotenv", "required", "recommended", "write"]);
447
+ assertOnlyOptions(parsed.flags, "mcp env", ["root", "all", "dotenv", "required", "recommended", "write", "mode"]);
345
448
  const root = resolve(flagString(parsed.flags, "root") ?? ".");
346
449
  if (flagBool(parsed.flags, "required") && flagBool(parsed.flags, "recommended")) {
347
450
  throw new Error("mcp env cannot use --required and --recommended together");
348
451
  }
452
+ const state = await readCapabilities(root);
349
453
  const selected = flagBool(parsed.flags, "all")
350
454
  ? Object.keys(AGENT_STACK.mcp_servers)
351
455
  : parsed.positionals.length > 0
352
456
  ? parsed.positionals
353
- : (await readCapabilities(root)).mcp_servers;
457
+ : state.mcp_servers;
354
458
  assertKnownMcpServers(selected);
459
+ const mode = flagString(parsed.flags, "mode");
460
+ const modes = mode ? undefined : Object.fromEntries(selected.map((server) => [server, state.mcp_server_modes[server]]));
355
461
  const filters = {
356
462
  requiredOnly: flagBool(parsed.flags, "required"),
357
- recommendedOnly: flagBool(parsed.flags, "recommended")
463
+ recommendedOnly: flagBool(parsed.flags, "recommended"),
464
+ mode,
465
+ modes,
466
+ remote: state.mcp_server_remote
358
467
  };
359
468
  const writePath = flagString(parsed.flags, "write");
360
469
  if (writePath) {
361
470
  const outputPath = resolve(root, writePath);
362
- writeFileSync(outputPath, formatMcpDotenv(selected, filters), "utf8");
471
+ writeFileSync(outputPath, formatMcpDotenvWithRemote(selected, filters), "utf8");
363
472
  console.log(`Wrote MCP dotenv environment reference: ${outputPath}`);
364
473
  return 0;
365
474
  }
366
475
  if (flagBool(parsed.flags, "dotenv")) {
367
- process.stdout.write(formatMcpDotenv(selected, filters));
476
+ process.stdout.write(formatMcpDotenvWithRemote(selected, filters));
368
477
  return 0;
369
478
  }
370
479
  console.log("id\ttype\tvalue");
@@ -372,11 +481,15 @@ async function mcpCommand(argv) {
372
481
  return 0;
373
482
  }
374
483
  if (subcommand === "enable") {
375
- assertOnlyOptions(parsed.flags, "mcp enable", ["root", "agent"]);
484
+ assertOnlyOptions(parsed.flags, "mcp enable", ["root", "agent", "mode", "url", "url-env", "bearer-token-env-var"]);
376
485
  const root = resolve(flagString(parsed.flags, "root") ?? ".");
377
486
  assertSomeArguments(parsed.positionals, "mcp enable");
378
487
  const agent = flagString(parsed.flags, "agent");
379
- await enableMcpServers(root, parsed.positionals, agent ? { agent } : {});
488
+ await enableMcpServers(root, parsed.positionals, {
489
+ ...(agent ? { agent } : {}),
490
+ mode: flagString(parsed.flags, "mode"),
491
+ remote: mcpRemoteOptions(parsed.flags)
492
+ });
380
493
  return 0;
381
494
  }
382
495
  if (subcommand === "disable") {
@@ -392,6 +505,9 @@ async function mcpCommand(argv) {
392
505
  const root = resolve(flagString(parsed.flags, "root") ?? ".");
393
506
  const result = await installMcpTools(root, parsed.positionals);
394
507
  console.log(`Ran ${result.count ?? 0} MCP install command(s).`);
508
+ for (const skipped of result.skipped ?? []) {
509
+ console.log(`Skipped ${skipped.server}: ${skipped.reason}; ${skipped.next ?? "no install action needed"}.`);
510
+ }
395
511
  return 0;
396
512
  }
397
513
  if (subcommand === "uninstall") {
@@ -399,17 +515,41 @@ async function mcpCommand(argv) {
399
515
  const root = resolve(flagString(parsed.flags, "root") ?? ".");
400
516
  const result = await uninstallMcpTools(root, parsed.positionals);
401
517
  console.log(`Ran ${result.count ?? 0} MCP uninstall command(s).`);
518
+ for (const skipped of result.skipped ?? []) {
519
+ console.log(`Skipped ${skipped.server}: ${skipped.reason}; ${skipped.next ?? "no uninstall action needed"}.`);
520
+ }
402
521
  return 0;
403
522
  }
523
+ if (subcommand === "setup") {
524
+ assertOnlyOptions(parsed.flags, "mcp setup", ["root", "mode", "env-file", "dry-run"]);
525
+ const root = resolve(flagString(parsed.flags, "root") ?? ".");
526
+ assertSomeArguments(parsed.positionals, "mcp setup");
527
+ if (parsed.positionals.length > 1)
528
+ throw new Error(`mcp setup accepts one server at a time: ${parsed.positionals.join(" ")}`);
529
+ const env = await mcpCommandEnvironment(root, parsed.flags);
530
+ const result = await setupMcpServer(root, parsed.positionals[0], {
531
+ mode: flagString(parsed.flags, "mode"),
532
+ envFile: flagString(parsed.flags, "env-file"),
533
+ env,
534
+ dryRun: flagBool(parsed.flags, "dry-run")
535
+ });
536
+ printMcpSetupResult(result);
537
+ return result.ok ? 0 : 1;
538
+ }
539
+ if (subcommand === "client") {
540
+ return mcpClientCommand(parsed);
541
+ }
404
542
  if (subcommand === "smoke") {
405
- assertOnlyOptions(parsed.flags, "mcp smoke", ["root", "env-file"]);
543
+ assertOnlyOptions(parsed.flags, "mcp smoke", ["root", "env-file", "mode"]);
406
544
  const root = resolve(flagString(parsed.flags, "root") ?? ".");
407
545
  const env = await mcpCommandEnvironment(root, parsed.flags);
408
546
  const state = await readCapabilities(root);
409
547
  const explicitSelection = parsed.positionals.length > 0;
410
548
  const selected = explicitSelection ? parsed.positionals : state.mcp_servers;
411
549
  assertKnownMcpServers(selected);
412
- const failed = printMcpSmokeDiagnostics(selected, env);
550
+ const mode = flagString(parsed.flags, "mode");
551
+ const modes = Object.fromEntries(selected.map((server) => [server, mode ?? state.mcp_server_modes[server]]));
552
+ const failed = printMcpSmokeDiagnostics(root, selected, env, modes, state);
413
553
  if (!explicitSelection) {
414
554
  const result = await doctorMcpServers(root, { env });
415
555
  for (const error of result.errors)
@@ -435,7 +575,7 @@ async function mcpCommand(argv) {
435
575
  return result.ok ? 0 : 1;
436
576
  }
437
577
  if (subcommand === "probe") {
438
- assertOnlyOptions(parsed.flags, "mcp probe", ["root", "all", "env-file", "timeout-ms"]);
578
+ assertOnlyOptions(parsed.flags, "mcp probe", ["root", "all", "env-file", "timeout-ms", "mode"]);
439
579
  const root = resolve(flagString(parsed.flags, "root") ?? ".");
440
580
  const selected = flagBool(parsed.flags, "all")
441
581
  ? Object.keys(AGENT_STACK.mcp_servers)
@@ -445,7 +585,12 @@ async function mcpCommand(argv) {
445
585
  assertKnownMcpServers(selected);
446
586
  const timeoutMs = parseTimeoutMs(flagString(parsed.flags, "timeout-ms"));
447
587
  const env = await mcpCommandEnvironment(root, parsed.flags);
448
- const result = await probeMcpServers(root, selected, { env, timeoutMs, clientVersion: packageVersion });
588
+ const result = await probeMcpServers(root, selected, {
589
+ env,
590
+ timeoutMs,
591
+ clientVersion: packageVersion,
592
+ mode: flagString(parsed.flags, "mode")
593
+ });
449
594
  console.log("id\tstatus\tdetail");
450
595
  for (const item of result.results)
451
596
  console.log(`${item.server}\t${item.status}\t${item.detail}`);
@@ -453,6 +598,138 @@ async function mcpCommand(argv) {
453
598
  }
454
599
  throw new Error(`unknown mcp command: ${subcommand}`);
455
600
  }
601
+ async function mcpClientCommand(parsed) {
602
+ const action = parsed.positionals[0];
603
+ const server = parsed.positionals[1];
604
+ if (!action || action === "help" || action === "--help" || action === "-h") {
605
+ printMcpHelp();
606
+ return 0;
607
+ }
608
+ if (action !== "add" && action !== "remove")
609
+ throw new Error(`unknown mcp client command: ${action}`);
610
+ if (!server)
611
+ throw new Error(`mcp client ${action} requires a server`);
612
+ if (parsed.positionals.length > 2) {
613
+ throw new Error(`mcp client ${action} accepts one server: ${parsed.positionals.slice(1).join(" ")}`);
614
+ }
615
+ assertOnlyOptions(parsed.flags, `mcp client ${action}`, ["root", "agent", "mode", "dry-run"]);
616
+ const root = resolve(flagString(parsed.flags, "root") ?? ".");
617
+ const options = {
618
+ agent: flagString(parsed.flags, "agent"),
619
+ mode: flagString(parsed.flags, "mode"),
620
+ dryRun: flagBool(parsed.flags, "dry-run")
621
+ };
622
+ const result = action === "add"
623
+ ? await clientAddMcpServer(root, server, options)
624
+ : await clientRemoveMcpServer(root, server, options);
625
+ if (result.command.length > 0) {
626
+ console.log(result.command.join(" "));
627
+ }
628
+ for (const instruction of result.instructions)
629
+ console.log(instruction);
630
+ return result.ok || options.dryRun ? 0 : 1;
631
+ }
632
+ function setupNextCommands(item) {
633
+ const commands = [];
634
+ if (item.next === "ready")
635
+ return commands;
636
+ if (item.connection_mode === "manual-local") {
637
+ if (item.env === "missing-required")
638
+ commands.push("fill OVERLEAF_TOKEN, PROJECT_ID in .env.local");
639
+ if (item.install !== "ready") {
640
+ commands.push("npm run mcp:setup -- overleaf --mode local --env-file .env.local");
641
+ return dedupeStrings(commands);
642
+ }
643
+ if (item.client.endsWith(":not-added"))
644
+ commands.push("npm run mcp:client:add -- overleaf --agent codex");
645
+ if (item.probe === "unknown")
646
+ commands.push("npm run mcp:probe -- overleaf --env-file .env.local");
647
+ return dedupeStrings(commands);
648
+ }
649
+ commands.push(item.next.replace(/^run /, ""));
650
+ return dedupeStrings(commands);
651
+ }
652
+ function dedupeStrings(values) {
653
+ return [...new Set(values.filter(Boolean))];
654
+ }
655
+ function printMcpModesTable(state) {
656
+ const selected = new Set(state.mcp_servers ?? []);
657
+ console.log("id\tselected\trecommended\tsupported\tenv\tnext");
658
+ for (const name of Object.keys(AGENT_STACK.mcp_servers)) {
659
+ const required = modeEnvSummary(name);
660
+ const recommended = modeKeyDisplay(mcpRecommendedMode(name));
661
+ const supported = orderedModeLabels(name).join(", ");
662
+ const next = selected.has(name) ? "ready" : `enable ${name}`;
663
+ console.log(`${name}\t${selected.has(name) ? "yes" : "no"}\t${recommended}\t${supported}\t${required}\t${next}`);
664
+ }
665
+ }
666
+ function printMcpModeDetail(serverName, state) {
667
+ const selected = new Set(state.mcp_servers ?? []);
668
+ const uniqueLabels = orderedModeLabels(serverName);
669
+ console.log(`${serverName} supports ${formatHumanList(uniqueLabels)}.`);
670
+ console.log(`Selected: ${selected.has(serverName) ? "yes" : "no"}`);
671
+ console.log(`Recommended: ${mcpModeLabel(serverName, mcpRecommendedMode(serverName))}`);
672
+ console.log(`Env: ${modeEnvSummary(serverName)}`);
673
+ console.log(`Next: ${selected.has(serverName) ? "npm run mcp:status" : `npm run mcp:enable -- ${serverName} --mode ${mcpRecommendedMode(serverName)}`}`);
674
+ for (const mode of mcpServerModeKeys(serverName)) {
675
+ const resolved = resolveMcpServer(serverName, mode);
676
+ const details = [
677
+ `mode ${mode}: ${mcpModeLabel(serverName, mode)}`,
678
+ resolved.hosted_url ? `endpoint ${resolved.hosted_url}` : "",
679
+ resolved.command ? `runtime ${[resolved.command, ...resolved.args].join(" ")}` : "",
680
+ resolved.local_service ? `requires ${resolved.local_service}` : ""
681
+ ].filter(Boolean);
682
+ console.log(details.join("; "));
683
+ }
684
+ }
685
+ function modeEnvSummary(serverName) {
686
+ const names = new Set();
687
+ for (const mode of mcpServerModeKeys(serverName)) {
688
+ const server = resolveMcpServer(serverName, mode);
689
+ for (const name of server.required_env)
690
+ names.add(name);
691
+ for (const name of server.recommended_env)
692
+ names.add(name);
693
+ }
694
+ return names.size > 0 ? [...names].join(", ") : "none";
695
+ }
696
+ function orderedModeLabels(serverName) {
697
+ const recommended = mcpModeLabel(serverName, mcpRecommendedMode(serverName));
698
+ const labels = mcpSupportedModeLabels(serverName);
699
+ return [recommended, ...labels.filter((label) => label !== recommended)];
700
+ }
701
+ function modeKeyDisplay(mode) {
702
+ if (mode === "remote-custom")
703
+ return "custom remote";
704
+ if (mode === "remote")
705
+ return "remote";
706
+ if (mode === "manual")
707
+ return "manual setup";
708
+ return "local";
709
+ }
710
+ function formatHumanList(values) {
711
+ if (values.length <= 1)
712
+ return values[0] ?? "";
713
+ if (values.length === 2)
714
+ return `${values[0]} and ${values[1]}`;
715
+ return `${values.slice(0, -1).join(", ")}, and ${values.at(-1)}`;
716
+ }
717
+ function friendlyNext(next) {
718
+ return next.replace(/^run /, "");
719
+ }
720
+ function mcpRemoteOptions(flags) {
721
+ const url = flagString(flags, "url");
722
+ const urlEnv = flagString(flags, "url-env");
723
+ const bearerTokenEnvVar = flagString(flags, "bearer-token-env-var");
724
+ if (!url && !urlEnv && !bearerTokenEnvVar)
725
+ return undefined;
726
+ return {
727
+ ...(url ? { url } : {}),
728
+ ...(urlEnv ? { url_env: urlEnv } : {}),
729
+ transport: "streamable-http",
730
+ ...(bearerTokenEnvVar ? { bearer_token_env_var: bearerTokenEnvVar } : {})
731
+ };
732
+ }
456
733
  function parseFlags(argv, schema) {
457
734
  const flags = {};
458
735
  const positionals = [];
@@ -580,6 +857,9 @@ export function formatInteractiveCreateGuide() {
580
857
  " MCP installers are optional and run only finite installer commands.",
581
858
  " MCP execution modes are explicit: uvx-runtime, npx-runtime, local-service, manual, or fallback.",
582
859
  " Use `npm run mcp:env -- <server>` to inspect env vars and local prerequisites.",
860
+ " Use `npm run mcp:status` to see selected mode, setup, client, probe, and next action.",
861
+ " Use `npm run mcp:enable -- <server> --mode remote` for hosted endpoints where supported.",
862
+ " Use `npm run mcp:setup -- overleaf --mode local --env-file .env.local` for manual-local setup.",
583
863
  " Use `npm run mcp:env -- --dotenv --all` to print a committed env example.",
584
864
  " Use `npm run mcp:dotenv` to regenerate a committed env example.",
585
865
  " Use `npm run mcp:doctor -- --env-file .env.local` to check explicit local secrets.",
@@ -619,7 +899,7 @@ function printMissingTargetHelp() {
619
899
  }
620
900
  function printLifecycleHelp() {
621
901
  console.log([
622
- "Usage: academic-research <doctor|setup|rename|agents|skills|mcp>",
902
+ "Usage: academic-research <doctor|update|init|setup|rename|agents|skills|mcp>",
623
903
  "",
624
904
  "Manage a generated academic research repository after creation.",
625
905
  "",
@@ -628,6 +908,37 @@ function printLifecycleHelp() {
628
908
  " -v, --version Show package version."
629
909
  ].join("\n"));
630
910
  }
911
+ function printUpdateHelp() {
912
+ console.log([
913
+ "Usage: academic-research update [options]",
914
+ "",
915
+ "Preview or apply non-destructive updates to managed project files.",
916
+ "",
917
+ "Options:",
918
+ " --root <path> Project root. Default: current directory.",
919
+ " --dry-run Preview managed changes without writing. Default.",
920
+ " --apply Write managed changes.",
921
+ " -h, --help Show this help."
922
+ ].join("\n"));
923
+ }
924
+ function printInitHelp() {
925
+ console.log([
926
+ "Usage: academic-research init [options]",
927
+ "",
928
+ "Initialize an existing repository without overwriting existing files.",
929
+ "",
930
+ "Options:",
931
+ " --root <path> Project root. Default: current directory.",
932
+ " --title <name> Project title. Default: title-cased directory name.",
933
+ " --slug <name> Repository/package slug. Default: normalized directory name.",
934
+ " --package <name> Python package name. Default: normalized directory name.",
935
+ " --preset <name> Capability preset: minimal, default, enhanced, literature, writing, full.",
936
+ " --profile <name> Project profile metadata. Default: academic-general.",
937
+ " --agent <id> Agent target: universal, auto, or a supported skills.sh id.",
938
+ " --install-skills Install project-local skills after initialization.",
939
+ " -h, --help Show this help."
940
+ ].join("\n"));
941
+ }
631
942
  function printSetupHelp() {
632
943
  console.log([
633
944
  "Usage: academic-research setup [options]",
@@ -673,12 +984,20 @@ function printSkillsHelp() {
673
984
  }
674
985
  function printMcpHelp() {
675
986
  console.log([
676
- "Usage: academic-research mcp <list|enabled|available|commands|env|enable|disable|install|uninstall|smoke|doctor|probe> [servers...]",
987
+ "Usage: academic-research mcp <list|modes|status|enabled|available|commands|env|enable|disable|setup|client|install|uninstall|smoke|doctor|probe> [servers...]",
677
988
  "",
678
989
  "Manage MCP records, readiness checks, and finite external MCP tool installs.",
679
990
  "",
680
991
  "Examples:",
992
+ " academic-research mcp modes",
993
+ " academic-research mcp modes openalex",
681
994
  " academic-research mcp env openalex semantic-scholar",
995
+ " academic-research mcp enable openalex --mode remote",
996
+ " academic-research mcp enable openalex --mode remote-custom --url https://example.com/mcp",
997
+ " academic-research mcp status",
998
+ " academic-research mcp status --verbose",
999
+ " academic-research mcp setup overleaf --mode local --env-file .env.local",
1000
+ " academic-research mcp client add overleaf --agent codex",
682
1001
  " academic-research mcp env --dotenv --all > .env.example",
683
1002
  " academic-research mcp env --write .env.example --all",
684
1003
  " academic-research mcp doctor --env-file .env.local",
@@ -687,31 +1006,45 @@ function printMcpHelp() {
687
1006
  "",
688
1007
  "Options:",
689
1008
  " --root <path> Project root for project-state commands.",
690
- " --agent <id> Agent for enable/disable generated snippets.",
1009
+ " --agent <id> Agent for enable/disable snippets or client registration.",
1010
+ " --mode <mode> Connection mode: local, remote, remote-custom, or manual where supported.",
1011
+ " --url <url> Custom remote MCP endpoint URL for --mode remote-custom.",
1012
+ " --url-env <name> Env var that contains a custom remote MCP endpoint URL.",
1013
+ " --bearer-token-env-var <name>",
1014
+ " Env var that contains a custom remote bearer token; value is not stored.",
691
1015
  " --all Select all catalog MCP servers for mcp env.",
1016
+ " --verbose Show technical MCP lifecycle fields for mcp status.",
692
1017
  " --dotenv Print mcp env as dotenv content.",
693
1018
  " --write <path> Write mcp env dotenv content to a file.",
694
- " --env-file <path> Read local env values for mcp smoke, doctor, and probe.",
1019
+ " --env-file <path> Read local env values for mcp setup, smoke, doctor, and probe.",
695
1020
  " --timeout-ms <ms> Per-server probe timeout. Default: 5000.",
696
1021
  " --required Print only required env vars for mcp env.",
697
1022
  " --recommended Print only recommended/default env vars for mcp env.",
1023
+ " --dry-run Print setup or client registration actions without changing external state.",
698
1024
  " -h, --help Show this help."
699
1025
  ].join("\n"));
700
1026
  }
701
- function printMcpSmokeDiagnostics(servers, env = process.env) {
1027
+ function printMcpSmokeDiagnostics(root, servers, env = process.env, modes = {}, state) {
702
1028
  let failed = false;
703
1029
  console.log("id\tstatus\truntime\tcheck");
704
1030
  for (const name of servers) {
705
- const server = AGENT_STACK.mcp_servers[name];
706
- const missingRequired = server.required_env.filter((envName) => !env[envName]);
1031
+ const server = state ? resolveMcpServerForState(state, name, modes[name]) : resolveMcpServer(name, modes[name]);
1032
+ const missingRequired = server.required_env.filter((envName) => !envHasValue(env, envName));
707
1033
  if (missingRequired.length > 0)
708
1034
  failed = true;
709
- const runtime = server.command ? [server.command, ...server.args].join(" ") : "manual setup";
1035
+ const runtime = mcpSmokeRuntime(name, server, state);
710
1036
  let status = "manual";
711
1037
  if (missingRequired.length > 0) {
712
1038
  status = `missing-required-env:${missingRequired.join(",")}`;
713
1039
  }
714
- else if (server.command && commandExists(server.command, env)) {
1040
+ else if (server.connection_mode === "remote-custom" && !server.remote_configured) {
1041
+ failed = true;
1042
+ status = "missing-remote-url";
1043
+ }
1044
+ else if (server.connection_mode === "remote-curated" || server.connection_mode === "remote-custom") {
1045
+ status = "remote-endpoint";
1046
+ }
1047
+ else if (server.command && commandExists(commandForRuntime(root, server.command), env)) {
715
1048
  status = "runtime-found";
716
1049
  }
717
1050
  else if (server.command) {
@@ -724,6 +1057,51 @@ function printMcpSmokeDiagnostics(servers, env = process.env) {
724
1057
  }
725
1058
  return failed;
726
1059
  }
1060
+ function mcpSmokeRuntime(name, server, state) {
1061
+ if (server.connection_mode === "remote-custom") {
1062
+ const urlEnv = state?.mcp_server_remote?.[name]?.url_env;
1063
+ if (!server.remote_configured)
1064
+ return "custom remote endpoint not configured";
1065
+ return urlEnv ? `custom remote endpoint from ${urlEnv}` : "custom remote endpoint";
1066
+ }
1067
+ if (server.hosted_url && !server.command)
1068
+ return server.hosted_url;
1069
+ if (server.command)
1070
+ return [server.command, ...server.args].join(" ");
1071
+ return "manual setup";
1072
+ }
1073
+ function envHasValue(env, name) {
1074
+ return typeof env[name] === "string" && env[name] !== "";
1075
+ }
1076
+ function printMcpSetupResult(result) {
1077
+ const title = result.server === "overleaf" ? "Overleaf setup plan" : `MCP setup plan: ${result.server}`;
1078
+ console.log(title);
1079
+ console.log(`server\t${result.server}`);
1080
+ console.log(`mode\t${result.mode}`);
1081
+ console.log(`status\t${result.ok ? "ok" : "blocked"}`);
1082
+ for (const error of result.errors)
1083
+ console.error(`ERROR: ${error}`);
1084
+ for (const warning of result.warnings)
1085
+ console.warn(`WARN: ${warning}`);
1086
+ if (result.commands.length > 0) {
1087
+ console.log("");
1088
+ console.log("Commands");
1089
+ for (const command of result.commands)
1090
+ console.log(command);
1091
+ }
1092
+ if (result.created.length > 0) {
1093
+ console.log("");
1094
+ console.log("Created");
1095
+ for (const path of result.created)
1096
+ console.log(path);
1097
+ }
1098
+ if (result.next.length > 0) {
1099
+ console.log("");
1100
+ console.log("Next");
1101
+ for (const command of result.next)
1102
+ console.log(command);
1103
+ }
1104
+ }
727
1105
  function printMcpEnvironment(servers, options = {}) {
728
1106
  const grouped = new Map();
729
1107
  for (const entry of listMcpEnvironmentEntries(servers, options)) {
@@ -732,23 +1110,36 @@ function printMcpEnvironment(servers, options = {}) {
732
1110
  grouped.set(entry.server, entries);
733
1111
  }
734
1112
  for (const name of servers) {
735
- const server = AGENT_STACK.mcp_servers[name];
1113
+ const modeServer = flagModeServer(name, options.mode ?? options.modes?.[name]);
736
1114
  const entries = grouped.get(name) ?? [];
737
1115
  let wroteLine = false;
738
1116
  for (const entry of entries) {
739
1117
  console.log(`${name}\t${entry.kind}\t${entry.name}${entry.value ? `=${entry.value}` : ""}`);
740
1118
  wroteLine = true;
741
1119
  }
742
- if (!options.requiredOnly && !options.recommendedOnly && server.hosted_url) {
743
- console.log(`${name}\thosted-endpoint\t${server.hosted_url}`);
1120
+ if (!options.requiredOnly && !options.recommendedOnly && modeServer.hosted_url) {
1121
+ console.log(`${name}\thosted-endpoint\t${modeServer.hosted_url}`);
1122
+ wroteLine = true;
1123
+ }
1124
+ const remote = options.remote?.[name];
1125
+ if (!options.requiredOnly && !options.recommendedOnly && remote?.url) {
1126
+ console.log(`${name}\tcustom-remote-url\t${remote.url}`);
1127
+ wroteLine = true;
1128
+ }
1129
+ if (!options.recommendedOnly && remote?.url_env) {
1130
+ console.log(`${name}\trequired\t${remote.url_env}`);
744
1131
  wroteLine = true;
745
1132
  }
746
- if (!options.requiredOnly && !options.recommendedOnly && server.local_service) {
747
- console.log(`${name}\tlocal-service\t${server.local_service}`);
1133
+ if (!options.requiredOnly && remote?.bearer_token_env_var) {
1134
+ console.log(`${name}\trecommended\t${remote.bearer_token_env_var}`);
1135
+ wroteLine = true;
1136
+ }
1137
+ if (!options.requiredOnly && !options.recommendedOnly && modeServer.local_service) {
1138
+ console.log(`${name}\tlocal-service\t${modeServer.local_service}`);
748
1139
  wroteLine = true;
749
1140
  }
750
1141
  if (!options.requiredOnly && !options.recommendedOnly) {
751
- for (const command of server.setup_commands) {
1142
+ for (const command of modeServer.setup_commands) {
752
1143
  console.log(`${name}\tsetup-command\t${command}`);
753
1144
  wroteLine = true;
754
1145
  }
@@ -757,6 +1148,22 @@ function printMcpEnvironment(servers, options = {}) {
757
1148
  console.log(`${name}\tnone\t-`);
758
1149
  }
759
1150
  }
1151
+ function formatMcpDotenvWithRemote(servers, options = {}) {
1152
+ const base = formatMcpDotenv(servers, options);
1153
+ const lines = [];
1154
+ for (const name of servers) {
1155
+ const remote = options.remote?.[name];
1156
+ if (!remote)
1157
+ continue;
1158
+ if (!options.recommendedOnly && remote.url_env)
1159
+ lines.push(`${remote.url_env}=`);
1160
+ if (!options.requiredOnly && remote.bearer_token_env_var)
1161
+ lines.push(`${remote.bearer_token_env_var}=`);
1162
+ }
1163
+ if (lines.length === 0)
1164
+ return base;
1165
+ return `${base.trimEnd()}\n\n# Custom remote MCP endpoint environment\n${lines.join("\n")}\n`;
1166
+ }
760
1167
  function commandExists(command, env = process.env) {
761
1168
  if (!command)
762
1169
  return false;
@@ -776,6 +1183,14 @@ function commandExists(command, env = process.env) {
776
1183
  }
777
1184
  return false;
778
1185
  }
1186
+ function commandForRuntime(root, command) {
1187
+ if (!command.includes("/") && !command.includes("\\"))
1188
+ return command;
1189
+ return resolve(root, command);
1190
+ }
1191
+ function flagModeServer(name, mode) {
1192
+ return resolveMcpServer(name, mode);
1193
+ }
779
1194
  function readPackageVersion() {
780
1195
  try {
781
1196
  const packageJson = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));