create-academic-research 0.1.15 → 0.1.17

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 CHANGED
@@ -115,7 +115,8 @@ Inside a generated project:
115
115
  ```bash
116
116
  npm run doctor
117
117
  npm run update
118
- npm run setup
118
+ npm run update -- --apply
119
+ npm run setup -- --env-file .env.local
119
120
  npm run rename -- --title "New Title" --slug new-title --package new_title
120
121
  npm run agents:list
121
122
  npm run skills:presets
@@ -177,9 +178,11 @@ edited locally, update reports `skip` instead of overwriting it.
177
178
  existing files. It adds the research contract, merges lifecycle package scripts,
178
179
  and preserves existing README, `.gitignore`, and custom package scripts.
179
180
 
180
- `academic-research setup` is a non-destructive onboarding status command. It
181
+ `academic-research setup` is the friendly post-update recovery command. It
181
182
  prints the active preset, agent, skill counts, selected MCP records, and next
182
- commands without changing files.
183
+ commands. When you pass `--env-file .env.local`, it may complete safe
184
+ project-local setup such as the Overleaf wrapper and generated MCP snippet. It
185
+ does not register global MCP clients.
183
186
 
184
187
  Skills are project-local by default.
185
188
 
@@ -205,7 +208,7 @@ MCP commands are split by side-effect:
205
208
  | `mcp env` | Print required/recommended env vars, hosted endpoints, local prerequisites, and setup commands for selected servers. Use `--dotenv --all` to print dotenv content or `--write .env.example --all` to regenerate `.env.example`. |
206
209
  | `mcp enable` | Select an MCP server in project records and generated snippets. Use `--mode local`, `--mode remote`, or `--mode remote-custom --url <url>` where supported. |
207
210
  | `mcp disable` | Remove an MCP server from project records and generated snippets. |
208
- | `mcp setup` | Run or dry-run finite setup for manual-local integrations such as Overleaf. |
211
+ | `mcp setup` | Run or dry-run finite project-local setup for manual-local integrations such as Overleaf, including wrapper and generated snippet refresh. |
209
212
  | `mcp client add` / `mcp client remove` | Register or remove supported MCP client entries, currently Codex, without writing secrets into client config. |
210
213
  | `mcp install` | Run finite external tool install commands for selected MCP servers. It must not launch stdio MCP servers. |
211
214
  | `mcp uninstall` | Run the external uninstall command when one exists. |
@@ -166,6 +166,7 @@ interface SkillInstallOptions {
166
166
  }
167
167
  export declare function readCapabilities(root: string): Promise<CapabilityState>;
168
168
  export declare function writeCapabilities(root: string, state: Partial<CapabilityState>): Promise<void>;
169
+ export declare function writeCapabilityGeneratedFiles(root: string, state: CapabilityState): Promise<void>;
169
170
  export declare function writeMcpEnvironmentExample(root: string): Promise<void>;
170
171
  export declare function initializeCapabilities(root: string, options?: InitializeCapabilitiesOptions): Promise<void>;
171
172
  export declare function buildSkillInstallCommands(root: string, preset?: string, options?: SkillInstallOptions): Promise<string[][]>;
@@ -198,4 +199,6 @@ export declare function setupMcpServer(root: string, serverName: string, options
198
199
  export declare function clientAddMcpServer(root: string, serverName: string, options?: McpClientOptions, runner?: Runner): Promise<McpClientResult>;
199
200
  export declare function clientRemoveMcpServer(root: string, serverName: string, options?: McpClientOptions, runner?: Runner): Promise<McpClientResult>;
200
201
  export declare function resolveMcpServerForState(state: CapabilityState, serverName: string, mode?: string): ResolvedMcpServer;
202
+ export declare function mcpMissingGeneratedSnippetMessage(serverName: string, server: ResolvedMcpServer, env?: NodeJS.ProcessEnv): string;
203
+ export declare function mcpLocalSetupGitignoreWarning(root: string): Promise<string | undefined>;
201
204
  export declare function assertKnownMcpServers(servers: string[]): void;
@@ -24,21 +24,16 @@ export async function readCapabilities(root) {
24
24
  }
25
25
  }
26
26
  export async function writeCapabilities(root, state) {
27
- const mcpServers = [...(state.mcp_servers ?? [])];
28
- const next = {
29
- agent: assertKnownAgentTarget(state.agent),
30
- preset: state.preset ?? "default",
31
- scope: "project-local",
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 ?? {})
35
- };
36
- await writeFile(join(root, "configs/capabilities.yaml"), YAML.stringify(serializeCapabilityState(next)), "utf8");
37
- await writeCapabilityProfile(root, next);
38
- await writeMcpSetup(root, next);
39
- await writeMcpSnippet(root, next);
27
+ const next = normalizeCapabilityWriteState(state);
28
+ await writeCapabilityConfig(root, next);
29
+ await writeCapabilityGeneratedFiles(root, next);
40
30
  await appendCapabilityLog(root, next);
41
31
  }
32
+ export async function writeCapabilityGeneratedFiles(root, state) {
33
+ await writeCapabilityProfile(root, state);
34
+ await writeMcpSetup(root, state);
35
+ await writeMcpSnippet(root, state);
36
+ }
42
37
  export async function writeMcpEnvironmentExample(root) {
43
38
  await writeFile(join(root, ".env.example"), formatMcpDotenv(Object.keys(AGENT_STACK.mcp_servers)), "utf8");
44
39
  }
@@ -321,6 +316,18 @@ export async function doctorMcpServers(root, options = {}) {
321
316
  errors.push(`invalid generated MCP snippet: ${snippetPath}: ${message}`);
322
317
  }
323
318
  }
319
+ const lock = await readCapabilityLock(root);
320
+ const hasManualLocal = enabled.some((name) => {
321
+ if (!AGENT_STACK.mcp_servers[name])
322
+ return false;
323
+ const server = resolveMcpServerForState(state, name, modes[name]);
324
+ return server.connection_mode === "manual-local";
325
+ });
326
+ if (hasManualLocal) {
327
+ const gitignoreWarning = await mcpLocalSetupGitignoreWarning(root);
328
+ if (gitignoreWarning)
329
+ warnings.push(gitignoreWarning);
330
+ }
324
331
  for (const name of enabled) {
325
332
  const server = resolveMcpServerForState(state, name, modes[name]);
326
333
  if (!server)
@@ -339,7 +346,6 @@ export async function doctorMcpServers(root, options = {}) {
339
346
  warnings.push(`${name}: requires local service: ${server.local_service}`);
340
347
  }
341
348
  if (server.connection_mode === "manual-local") {
342
- const lock = await readCapabilityLock(root);
343
349
  if (lock.mcp[name]?.setup?.status !== "ready") {
344
350
  warnings.push(`${name}: local setup not complete; run npm run mcp:setup -- ${name} --mode local --env-file .env.local`);
345
351
  }
@@ -349,7 +355,14 @@ export async function doctorMcpServers(root, options = {}) {
349
355
  continue;
350
356
  }
351
357
  if (!generatedServers.has(name)) {
352
- errors.push(`${name}: enabled but missing from generated MCP snippet`);
358
+ errors.push(mcpMissingGeneratedSnippetMessage(name, server, env));
359
+ continue;
360
+ }
361
+ if (state.agent === "codex" &&
362
+ server.connection_mode === "manual-local" &&
363
+ lock.mcp[name]?.setup?.status === "ready" &&
364
+ lock.mcp[name]?.clients?.codex?.status !== "registered") {
365
+ warnings.push(`${name} is setup locally but not registered in Codex\nNEXT: npm run mcp:client:add -- ${name} --agent codex`);
353
366
  }
354
367
  }
355
368
  return { ok: errors.length === 0, errors, warnings, enabled };
@@ -662,8 +675,15 @@ export async function readCapabilityLock(root) {
662
675
  }
663
676
  export async function setupMcpServer(root, serverName, options = {}, runner = defaultRunner) {
664
677
  assertKnownMcpServers([serverName]);
665
- const mode = normalizeMcpMode(serverName, options.mode);
666
- const server = resolveMcpServer(serverName, mode);
678
+ const state = await readCapabilities(root);
679
+ const mode = normalizeMcpMode(serverName, options.mode ?? state.mcp_server_modes?.[serverName]);
680
+ const nextState = normalizeCapabilityWriteState({
681
+ ...state,
682
+ mcp_servers: dedupe([...(state.mcp_servers ?? []), serverName]),
683
+ mcp_server_modes: { ...(state.mcp_server_modes ?? {}), [serverName]: mode },
684
+ mcp_server_remote: state.mcp_server_remote ?? {}
685
+ });
686
+ const server = resolveMcpServerForState(nextState, serverName, mode);
667
687
  if (serverName !== "overleaf") {
668
688
  const commands = server.setup_commands.length > 0 ? server.setup_commands : [];
669
689
  return {
@@ -686,6 +706,7 @@ export async function setupMcpServer(root, serverName, options = {}, runner = de
686
706
  `cd ${paths.relativeServer} && uv sync`,
687
707
  `write wrapper ${paths.relativeWrapper}`
688
708
  ];
709
+ const gitignoreWarning = await mcpLocalSetupGitignoreWarning(root);
689
710
  if (missing.length > 0) {
690
711
  return {
691
712
  ok: false,
@@ -693,7 +714,7 @@ export async function setupMcpServer(root, serverName, options = {}, runner = de
693
714
  mode,
694
715
  commands,
695
716
  created: [],
696
- warnings: [],
717
+ warnings: gitignoreWarning ? [gitignoreWarning] : [],
697
718
  errors: [`overleaf: missing required environment variable(s): ${missing.join(", ")}`],
698
719
  next: [`fill ${missing.join(", ")} in ${options.envFile ?? ".env.local"}`]
699
720
  };
@@ -705,7 +726,7 @@ export async function setupMcpServer(root, serverName, options = {}, runner = de
705
726
  mode,
706
727
  commands,
707
728
  created: [],
708
- warnings: [],
729
+ warnings: gitignoreWarning ? [gitignoreWarning] : [],
709
730
  errors: [],
710
731
  next: [`run npm run mcp:setup -- overleaf --mode local --env-file ${options.envFile ?? ".env.local"}`]
711
732
  };
@@ -727,17 +748,19 @@ export async function setupMcpServer(root, serverName, options = {}, runner = de
727
748
  status: "ready",
728
749
  server_path: paths.relativeServer,
729
750
  wrapper_path: paths.relativeWrapper,
730
- env_file: options.envFile ? toPosix(relative(root, resolve(root, options.envFile))) : ".env.local",
751
+ env_file: mcpEnvFileRecord(root, options.envFile),
731
752
  updated_at: nowIso()
732
753
  };
733
754
  });
755
+ await writeCapabilityConfig(root, nextState);
756
+ await writeCapabilityGeneratedFiles(root, nextState);
734
757
  return {
735
758
  ok: true,
736
759
  server: serverName,
737
760
  mode,
738
761
  commands,
739
762
  created: [paths.relativeWrapper, paths.relativeLauncher],
740
- warnings: [],
763
+ warnings: gitignoreWarning ? [gitignoreWarning] : [],
741
764
  errors: [],
742
765
  next: [
743
766
  "npm run mcp:client:add -- overleaf --agent codex",
@@ -824,6 +847,20 @@ function serializeCapabilityState(state) {
824
847
  serialized.mcp_server_remote = state.mcp_server_remote;
825
848
  return serialized;
826
849
  }
850
+ function normalizeCapabilityWriteState(state) {
851
+ const mcpServers = [...(state.mcp_servers ?? [])];
852
+ return {
853
+ agent: assertKnownAgentTarget(state.agent),
854
+ preset: state.preset ?? "default",
855
+ scope: "project-local",
856
+ mcp_servers: mcpServers,
857
+ mcp_server_modes: normalizeMcpServerModeMap(state.mcp_server_modes ?? {}, mcpServers),
858
+ mcp_server_remote: normalizeMcpServerRemoteMap(state.mcp_server_remote ?? {}, mcpServers, state.mcp_server_modes ?? {})
859
+ };
860
+ }
861
+ async function writeCapabilityConfig(root, state) {
862
+ await writeFile(join(root, "configs/capabilities.yaml"), YAML.stringify(serializeCapabilityState(state)), "utf8");
863
+ }
827
864
  function normalizeMcpServerModeMap(modes, servers) {
828
865
  const selected = new Set(servers);
829
866
  const result = {};
@@ -986,6 +1023,19 @@ function mcpModeKeyLabelForAction(mode) {
986
1023
  return "manual setup";
987
1024
  return "local";
988
1025
  }
1026
+ export function mcpMissingGeneratedSnippetMessage(serverName, server, env = process.env) {
1027
+ const lines = [`${serverName}: enabled but missing from generated MCP snippet`];
1028
+ if (server.connection_mode === "manual-local") {
1029
+ lines.push(`NEXT: npm run mcp:setup -- ${serverName} --mode local --env-file .env.local`);
1030
+ const missing = server.required_env.filter((name) => !envHasValue(env, name));
1031
+ if (missing.length > 0)
1032
+ lines.push(`Missing env vars: ${missing.join(", ")}`);
1033
+ }
1034
+ else {
1035
+ lines.push("NEXT: npm run update -- --apply");
1036
+ }
1037
+ return lines.join("\n");
1038
+ }
989
1039
  function mcpInstallSkip(serverName, server) {
990
1040
  if (server.connection_mode === "manual-local") {
991
1041
  return {
@@ -1098,6 +1148,41 @@ function ensureLockMcpEntry(lock, serverName, server) {
1098
1148
  lock.mcp[serverName] = entry;
1099
1149
  return entry;
1100
1150
  }
1151
+ export async function mcpLocalSetupGitignoreWarning(root) {
1152
+ if (await mcpLocalSetupPathIsIgnored(root))
1153
+ return undefined;
1154
+ return [
1155
+ ".academic-research/mcp/ is not ignored",
1156
+ "NEXT: add .academic-research/mcp/ to .gitignore before committing local MCP server files"
1157
+ ].join("\n");
1158
+ }
1159
+ async function mcpLocalSetupPathIsIgnored(root) {
1160
+ try {
1161
+ const gitignore = await readFile(join(root, ".gitignore"), "utf8");
1162
+ return gitignore
1163
+ .split(/\r?\n/)
1164
+ .map((line) => line.trim())
1165
+ .filter((line) => line && !line.startsWith("#"))
1166
+ .some((line) => {
1167
+ const normalized = line.replaceAll("\\", "/").replace(/^\/+/, "");
1168
+ return normalized === ".academic-research/mcp/" || normalized === ".academic-research/mcp";
1169
+ });
1170
+ }
1171
+ catch (error) {
1172
+ if (isMissingFileError(error))
1173
+ return false;
1174
+ throw error;
1175
+ }
1176
+ }
1177
+ function mcpEnvFileRecord(root, envFile) {
1178
+ if (!envFile)
1179
+ return ".env.local";
1180
+ const resolved = resolve(root, envFile);
1181
+ const relativePath = relative(root, resolved);
1182
+ if (!relativePath.startsWith("..") && !isAbsolute(relativePath))
1183
+ return toPosix(relativePath);
1184
+ return ".env.local";
1185
+ }
1101
1186
  function overleafPaths(root) {
1102
1187
  const relativeBase = ".academic-research/mcp/overleaf";
1103
1188
  const wrapperDir = join(root, relativeBase);
package/dist/src/cli.js CHANGED
@@ -19,6 +19,7 @@ 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 SETUP_FLAGS = flagSchema(["help"], ["root", "env-file"]);
22
23
  const UPDATE_FLAGS = flagSchema(["help", "dry-run", "apply"], ["root"]);
23
24
  const INIT_FLAGS = flagSchema(["help", "install-skills"], ["root", "title", "slug", "package", "preset", "profile", "agent"]);
24
25
  const RENAME_FLAGS = flagSchema(["help"], ["root", "title", "slug", "package"]);
@@ -173,6 +174,12 @@ async function updateCommand(argv) {
173
174
  if (!apply && result.changes.length > 0) {
174
175
  console.log("Run `npm run update -- --apply` from a generated project to write these managed changes.");
175
176
  }
177
+ if (apply && await projectLocalMcpSetupNeeded(root)) {
178
+ console.log("");
179
+ console.log("Next:");
180
+ console.log("1. Run npm run setup -- --env-file .env.local to complete project-local tool setup.");
181
+ console.log("2. Run npm run doctor to verify the project.");
182
+ }
176
183
  return 0;
177
184
  }
178
185
  async function initCommand(argv) {
@@ -198,13 +205,15 @@ async function initCommand(argv) {
198
205
  return 0;
199
206
  }
200
207
  async function setupCommand(argv) {
201
- const parsed = parseFlags(argv, ROOT_FLAGS);
208
+ const parsed = parseFlags(argv, SETUP_FLAGS);
202
209
  if (flagBool(parsed.flags, "help")) {
203
210
  printSetupHelp();
204
211
  return 0;
205
212
  }
206
213
  assertNoArguments(parsed.positionals, "setup");
207
214
  const root = resolve(flagString(parsed.flags, "root") ?? ".");
215
+ const env = await mcpCommandEnvironment(root, parsed.flags);
216
+ const setupResults = await runProjectLocalMcpSetup(root, env, flagString(parsed.flags, "env-file"));
208
217
  const project = await doctorProject(root);
209
218
  const state = await readCapabilities(root);
210
219
  const skills = await listInstalledSkills(root);
@@ -219,13 +228,30 @@ async function setupCommand(argv) {
219
228
  console.log(`installed_skill_copies\t${skills.length}`);
220
229
  console.log(`mcp_enabled\t${state.mcp_servers.length > 0 ? state.mcp_servers.join(",") : "none"}`);
221
230
  console.log(`mcp_selected\t${state.mcp_servers.length > 0 ? state.mcp_servers.join(",") : "none"}`);
231
+ for (const result of setupResults) {
232
+ if (result.ok) {
233
+ console.log(`Completed project-local MCP setup: ${result.server}`);
234
+ }
235
+ }
222
236
  if (!project.ok) {
223
237
  for (const error of project.errors)
224
238
  console.error(`ERROR: ${error}`);
225
239
  }
226
- for (const warning of project.warnings)
240
+ const setupWarnings = [];
241
+ for (const result of setupResults) {
242
+ for (const error of result.errors)
243
+ console.error(`ERROR: ${error}`);
244
+ setupWarnings.push(...result.warnings);
245
+ if (!result.ok && result.next.length > 0) {
246
+ console.log("");
247
+ console.log(`Next for ${result.server}`);
248
+ for (const command of result.next)
249
+ console.log(command);
250
+ }
251
+ }
252
+ for (const warning of dedupeStrings([...setupWarnings, ...project.warnings]))
227
253
  console.warn(`WARN: ${warning}`);
228
- const lifecycle = await getMcpLifecycleStatus(root);
254
+ const lifecycle = await getMcpLifecycleStatus(root, { env });
229
255
  console.log("");
230
256
  console.log("Next Commands");
231
257
  console.log(`npm run skills:install -- --preset ${state.preset}`);
@@ -649,6 +675,28 @@ function setupNextCommands(item) {
649
675
  commands.push(item.next.replace(/^run /, ""));
650
676
  return dedupeStrings(commands);
651
677
  }
678
+ async function runProjectLocalMcpSetup(root, env, envFile) {
679
+ const lifecycle = await getMcpLifecycleStatus(root, { env });
680
+ const results = [];
681
+ for (const item of lifecycle.servers) {
682
+ if (!item.selected || item.connection_mode !== "manual-local")
683
+ continue;
684
+ if (item.install === "ready" && item.snippet !== "missing")
685
+ continue;
686
+ results.push(await setupMcpServer(root, item.id, {
687
+ mode: item.mode_key,
688
+ envFile: envFile ?? ".env.local",
689
+ env
690
+ }));
691
+ }
692
+ return results;
693
+ }
694
+ async function projectLocalMcpSetupNeeded(root) {
695
+ const lifecycle = await getMcpLifecycleStatus(root);
696
+ return lifecycle.servers.some((item) => item.selected &&
697
+ item.connection_mode === "manual-local" &&
698
+ (item.install !== "ready" || item.snippet === "missing"));
699
+ }
652
700
  function dedupeStrings(values) {
653
701
  return [...new Set(values.filter(Boolean))];
654
702
  }
@@ -943,10 +991,11 @@ function printSetupHelp() {
943
991
  console.log([
944
992
  "Usage: academic-research setup [options]",
945
993
  "",
946
- "Print project onboarding status and next commands without changing files.",
994
+ "Print project onboarding status and complete safe project-local setup when possible.",
947
995
  "",
948
996
  "Options:",
949
997
  " --root <path> Project root. Default: current directory.",
998
+ " --env-file <path> Read local env values for guided project-local MCP setup.",
950
999
  " -h, --help Show this help."
951
1000
  ].join("\n"));
952
1001
  }
@@ -3,7 +3,7 @@ import { copyFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promi
3
3
  import { basename, dirname, join, relative, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import YAML from "yaml";
6
- import { DEFAULT_AGENT, formatMcpDotenv, initializeCapabilities, installSkills, readCapabilities, renderCapabilityProfile, renderMcpSetup, renderMcpSnippet, resolveMcpServerForState, writeMcpEnvironmentExample } from "./capabilities.js";
6
+ import { DEFAULT_AGENT, formatMcpDotenv, initializeCapabilities, installSkills, mcpLocalSetupGitignoreWarning, mcpMissingGeneratedSnippetMessage, readCapabilities, readCapabilityLock, renderCapabilityProfile, renderMcpSetup, renderMcpSnippet, resolveMcpServerForState, writeMcpEnvironmentExample } from "./capabilities.js";
7
7
  import { assertKnownAgentTarget } from "./agents.js";
8
8
  import { copyDirectory, exists, isNonEmptyDirectory, movePath, readJson, writeJson } from "./files.js";
9
9
  import { packageify, slugify, titleFromSlug } from "./names.js";
@@ -239,6 +239,18 @@ export async function doctorProject(root) {
239
239
  if (needsMcpEnvDoctor) {
240
240
  warnings.push("MCP readiness may require local secrets; run npm run mcp:doctor -- --env-file .env.local");
241
241
  }
242
+ const lock = await readCapabilityLock(target);
243
+ const hasManualLocalMcp = state.mcp_servers.some((serverName) => {
244
+ if (!AGENT_STACK.mcp_servers[serverName])
245
+ return false;
246
+ const server = resolveMcpServerForState(state, serverName, state.mcp_server_modes[serverName]);
247
+ return server.connection_mode === "manual-local";
248
+ });
249
+ if (hasManualLocalMcp) {
250
+ const gitignoreWarning = await mcpLocalSetupGitignoreWarning(target);
251
+ if (gitignoreWarning)
252
+ warnings.push(gitignoreWarning);
253
+ }
242
254
  for (const serverName of state.mcp_servers) {
243
255
  if (!AGENT_STACK.mcp_servers[serverName])
244
256
  continue;
@@ -264,7 +276,16 @@ export async function doctorProject(root) {
264
276
  const generated = JSON.parse(raw);
265
277
  for (const server of snippetServers) {
266
278
  if (!Object.hasOwn(generated.mcpServers ?? {}, server)) {
267
- errors.push(`${server}: enabled but missing from generated MCP snippet`);
279
+ const resolved = resolveMcpServerForState(state, server, state.mcp_server_modes[server]);
280
+ errors.push(mcpMissingGeneratedSnippetMessage(server, resolved, process.env));
281
+ continue;
282
+ }
283
+ const resolved = resolveMcpServerForState(state, server, state.mcp_server_modes[server]);
284
+ if (state.agent === "codex" &&
285
+ resolved.connection_mode === "manual-local" &&
286
+ lock.mcp[server]?.setup?.status === "ready" &&
287
+ lock.mcp[server]?.clients?.codex?.status !== "registered") {
288
+ warnings.push(`${server} is setup locally but not registered in Codex\nNEXT: npm run mcp:client:add -- ${server} --agent codex`);
268
289
  }
269
290
  }
270
291
  }
@@ -606,17 +627,18 @@ async function stageManagedFiles(root, specs, manifest, options) {
606
627
  nextManifest.files[relativePath] = stableManagedRecord(existing, managedRecordCandidateForWrittenFile(spec));
607
628
  continue;
608
629
  }
609
- options.changes.push({
610
- path: relativePath,
611
- action: "skip",
612
- reason: manifest ? "local edits detected" : "unknown legacy content"
613
- });
630
+ if (isUnchangedSkippedManagedRecord(existing, currentChecksum, generatedChecksum)) {
631
+ nextManifest.files[relativePath] = existing;
632
+ continue;
633
+ }
634
+ const reason = skippedManagedRecordReason(existing, manifest !== undefined, currentChecksum);
635
+ options.changes.push({ path: relativePath, action: "skip", reason });
614
636
  nextManifest.files[relativePath] = stableManagedRecord(existing, {
615
637
  path: relativePath,
616
638
  policy: spec.policy,
617
639
  generated_checksum: generatedChecksum,
618
640
  current_checksum: currentChecksum,
619
- reason: manifest ? "local edits detected" : "unknown legacy content"
641
+ reason
620
642
  });
621
643
  }
622
644
  return nextManifest;
@@ -696,6 +718,16 @@ function stableManagedRecord(existing, candidate) {
696
718
  return existing;
697
719
  return { ...candidate, updated_at: nowIso() };
698
720
  }
721
+ function isUnchangedSkippedManagedRecord(record, currentChecksum, generatedChecksum) {
722
+ return Boolean(record?.reason &&
723
+ record.current_checksum === currentChecksum &&
724
+ record.generated_checksum === generatedChecksum);
725
+ }
726
+ function skippedManagedRecordReason(existing, hasManifest, currentChecksum) {
727
+ if (existing?.reason && existing.current_checksum === currentChecksum)
728
+ return existing.reason;
729
+ return hasManifest ? "local edits detected" : "unknown legacy content";
730
+ }
699
731
  function managedManifestSemanticallyEqual(left, right) {
700
732
  if (left.version !== right.version)
701
733
  return false;
@@ -835,6 +867,8 @@ async function validateManagedManifestDrift(root, warnings) {
835
867
  continue;
836
868
  const checksum = checksumText(current);
837
869
  const record = manifest?.files[relativePath];
870
+ if (isUnchangedSkippedManagedRecord(record, checksum, checksumText(spec.content)))
871
+ continue;
838
872
  if (canSafelyUpdateManagedFile(record, checksum)) {
839
873
  warnings.push(`${relativePath} is not current; run npm run update -- --apply`);
840
874
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-academic-research",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "Scaffold agent-ready academic research repositories with SOTA, source ledgers, wiki memory, MCP setup, and project-local skills.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -111,7 +111,10 @@ git. `mcp doctor` checks the current process environment unless you explicitly
111
111
  pass `--env-file .env.local`.
112
112
 
113
113
  `setup` prints the current project capability state, installed skill counts,
114
- selected MCP records, and the next onboarding commands without changing files.
114
+ selected MCP records, and the next onboarding commands. With
115
+ `-- --env-file .env.local`, it can complete safe project-local setup such as the
116
+ Overleaf wrapper and generated MCP snippet. It does not register global MCP
117
+ clients.
115
118
  `mcp smoke` performs a non-launching MCP readiness check: it reports required
116
119
  env vars, local/manual setup, and whether client runtime commands such as `uvx`
117
120
  or `npx` are available. `mcp probe` is opt-in: local stdio servers get a real
@@ -69,6 +69,9 @@ credentialed local integrations should use a wrapper that loads `.env.local` at
69
69
  runtime. Overleaf client registration is intentionally blocked until
70
70
  `npm run mcp:setup -- overleaf --mode local --env-file .env.local` has created
71
71
  the wrapper and recorded non-secret setup facts.
72
+ After a legacy scaffold update, `npm run setup -- --env-file .env.local` can
73
+ perform that project-local setup and refresh the generated snippet; global
74
+ client registration remains explicit.
72
75
 
73
76
  Custom remote endpoints may use a stored URL or a URL env var name. Bearer token
74
77
  support stores only the token env var name. Codex automatic registration
@@ -70,3 +70,14 @@ npm run mcp:setup -- overleaf --mode local --env-file .env.local
70
70
  npm run mcp:client:add -- overleaf --agent codex --dry-run
71
71
  npm run mcp:probe -- overleaf --env-file .env.local
72
72
  ```
73
+
74
+ After a scaffold update, the friendlier project setup command can run the same
75
+ project-local Overleaf setup when the env file is present:
76
+
77
+ ```bash
78
+ npm run setup -- --env-file .env.local
79
+ ```
80
+
81
+ This may create ignored files under `.academic-research/mcp/` and refresh the
82
+ generated MCP snippet. It does not run `codex mcp add`; client registration
83
+ stays explicit.
@@ -8,7 +8,9 @@ Use this path for the first working session in a new research repository.
8
8
  npm install
9
9
  npm run doctor
10
10
  npm run update
11
- npm run setup
11
+ npm run update -- --apply
12
+ npm run setup -- --env-file .env.local
13
+ npm run doctor
12
14
  ```
13
15
 
14
16
  `doctor` checks required files and structural contracts. `update` uses
@@ -16,7 +18,9 @@ npm run setup
16
18
  `-- --apply`. Safe scaffold files are tracked in
17
19
  `.academic-research/managed-files.json`; locally edited files are skipped
18
20
  instead of overwritten. `setup` prints the active skill preset, installed skill
19
- count, enabled MCP records, and next commands without changing files.
21
+ count, enabled MCP records, and next commands. With `-- --env-file .env.local`,
22
+ it can complete safe project-local MCP setup such as the Overleaf wrapper and
23
+ generated snippet. It does not register global MCP clients.
20
24
 
21
25
  If an older project still has a pinned `update` script, run:
22
26