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.
@@ -1,8 +1,9 @@
1
- import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
- import { basename, dirname, join, resolve } from "node:path";
1
+ import { createHash } from "node:crypto";
2
+ import { copyFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, join, relative, resolve } from "node:path";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import YAML from "yaml";
5
- import { DEFAULT_AGENT, initializeCapabilities, installSkills, writeMcpEnvironmentExample } from "./capabilities.js";
6
+ import { DEFAULT_AGENT, formatMcpDotenv, initializeCapabilities, installSkills, readCapabilities, renderCapabilityProfile, renderMcpSetup, renderMcpSnippet, resolveMcpServerForState, writeMcpEnvironmentExample } from "./capabilities.js";
6
7
  import { assertKnownAgentTarget } from "./agents.js";
7
8
  import { copyDirectory, exists, isNonEmptyDirectory, movePath, readJson, writeJson } from "./files.js";
8
9
  import { packageify, slugify, titleFromSlug } from "./names.js";
@@ -90,11 +91,8 @@ export async function createProject(options) {
90
91
  const title = options.title ?? titleFromSlug(options.slug ?? basename(target));
91
92
  const slug = slugify(options.slug ?? title);
92
93
  const packageName = packageify(options.packageName ?? slug);
93
- const preset = options.preset ?? "default";
94
+ const preset = assertKnownPreset(options.preset ?? "default");
94
95
  const agent = assertKnownAgentTarget(options.agent ?? DEFAULT_AGENT);
95
- if (!AGENT_STACK.presets[preset]) {
96
- throw new Error(`unknown capability preset: ${preset}. Expected one of: ${Object.keys(AGENT_STACK.presets).join(", ")}`);
97
- }
98
96
  await mkdir(dirname(target), { recursive: true });
99
97
  await copyDirectory(templateRoot, target);
100
98
  await writeGeneratedGitignore(target);
@@ -103,11 +101,45 @@ export async function createProject(options) {
103
101
  await writeAgentStack(target);
104
102
  await writeMcpEnvironmentExample(target);
105
103
  await initializeCapabilities(target, { preset, agent });
104
+ await writeManagedFileManifest(target);
106
105
  if (options.installSkills) {
107
106
  await installSkills(target, preset);
108
107
  }
109
108
  return { root: target, title, slug, packageName };
110
109
  }
110
+ export async function initProject(options) {
111
+ const target = resolve(options.target);
112
+ const title = options.title ?? titleFromSlug(options.slug ?? basename(target));
113
+ const slug = slugify(options.slug ?? title);
114
+ const packageName = packageify(options.packageName ?? slug);
115
+ const preset = assertKnownPreset(options.preset ?? "default");
116
+ const agent = assertKnownAgentTarget(options.agent ?? DEFAULT_AGENT);
117
+ await mkdir(target, { recursive: true });
118
+ const created = await copyDirectoryMissing(templateRoot, target);
119
+ await writeGeneratedGitignore(target, { overwrite: false });
120
+ const project = await personalizeInitializedProject(target, {
121
+ title,
122
+ slug,
123
+ packageName,
124
+ profile: options.profile ?? "academic-general"
125
+ }, created);
126
+ await writeGeneratedPackageJson(target, { slug: project.slug });
127
+ if (created.has("configs/agent-stack.yaml"))
128
+ await writeAgentStack(target);
129
+ if (created.has(".env.example"))
130
+ await writeMcpEnvironmentExample(target);
131
+ if (created.has("configs/capabilities.yaml")) {
132
+ await initializeCapabilities(target, { preset, agent });
133
+ }
134
+ else {
135
+ await updateManagedCapabilityFiles(target, { apply: true, changes: [] });
136
+ }
137
+ await writeManagedFileManifest(target);
138
+ if (options.installSkills) {
139
+ await installSkills(target, preset, { agent });
140
+ }
141
+ return project;
142
+ }
111
143
  export async function renameProject(root, options) {
112
144
  const target = resolve(root);
113
145
  const configPath = join(target, "configs/default.yaml");
@@ -124,11 +156,25 @@ export async function renameProject(root, options) {
124
156
  previousPackage
125
157
  });
126
158
  await writeGeneratedPackageJson(target, { slug, preserveExistingSpec: true });
159
+ await writeManagedFileManifest(target);
127
160
  return { root: target, title, slug, packageName };
128
161
  }
162
+ export async function updateProject(root, options = {}) {
163
+ const target = resolve(root);
164
+ const changes = [];
165
+ const manifest = await readManagedFileManifest(target);
166
+ const specs = await managedFileSpecs(target);
167
+ const nextManifest = await stageManagedFiles(target, specs, manifest, {
168
+ apply: options.apply === true,
169
+ changes
170
+ });
171
+ await stageManagedManifest(target, manifest, nextManifest, { apply: options.apply === true, changes });
172
+ return { root: target, applied: options.apply === true, changes };
173
+ }
129
174
  export async function doctorProject(root) {
130
175
  const target = resolve(root);
131
176
  const errors = [];
177
+ const warnings = [];
132
178
  const required = [
133
179
  "README.md",
134
180
  ".gitignore",
@@ -175,16 +221,112 @@ export async function doctorProject(root) {
175
221
  }
176
222
  if (await exists(join(target, "configs/capabilities.yaml"))) {
177
223
  try {
178
- YAML.parse(await readFile(join(target, "configs/capabilities.yaml"), "utf8"));
224
+ const state = await readCapabilities(target);
225
+ const unknown = state.mcp_servers.filter((server) => !AGENT_STACK.mcp_servers[server]);
226
+ if (unknown.length > 0)
227
+ errors.push(`unknown MCP server in configs/capabilities.yaml: ${unknown.join(", ")}`);
228
+ const needsMcpEnvDoctor = state.mcp_servers.some((serverName) => {
229
+ const server = AGENT_STACK.mcp_servers[serverName]
230
+ ? resolveMcpServerForState(state, serverName, state.mcp_server_modes[serverName])
231
+ : undefined;
232
+ return Boolean(server &&
233
+ (server.required_env.length > 0 ||
234
+ server.recommended_env.length > 0 ||
235
+ server.local_service ||
236
+ server.execution_mode === "manual-local" ||
237
+ server.execution_mode === "local-service"));
238
+ });
239
+ if (needsMcpEnvDoctor) {
240
+ warnings.push("MCP readiness may require local secrets; run npm run mcp:doctor -- --env-file .env.local");
241
+ }
242
+ for (const serverName of state.mcp_servers) {
243
+ if (!AGENT_STACK.mcp_servers[serverName])
244
+ continue;
245
+ const server = resolveMcpServerForState(state, serverName, state.mcp_server_modes[serverName]);
246
+ if (server.connection_mode === "remote-custom" &&
247
+ server.remote_url_env &&
248
+ !envHasValue(process.env, server.remote_url_env)) {
249
+ errors.push(`${serverName}: missing required environment variable: ${server.remote_url_env}`);
250
+ }
251
+ }
252
+ const snippet = renderMcpSnippet(state);
253
+ const snippetServers = state.mcp_servers.filter((serverName) => {
254
+ if (!AGENT_STACK.mcp_servers[serverName])
255
+ return false;
256
+ const server = resolveMcpServerForState(state, serverName, state.mcp_server_modes[serverName]);
257
+ return Boolean(server.command ||
258
+ (server.connection_mode === "remote-curated" && server.hosted_url) ||
259
+ (server.connection_mode === "remote-custom" && server.remote_configured));
260
+ });
261
+ if (snippetServers.length > 0) {
262
+ try {
263
+ const raw = await readFile(join(target, "docs/agent/generated", snippet.fileName), "utf8");
264
+ const generated = JSON.parse(raw);
265
+ for (const server of snippetServers) {
266
+ if (!Object.hasOwn(generated.mcpServers ?? {}, server)) {
267
+ errors.push(`${server}: enabled but missing from generated MCP snippet`);
268
+ }
269
+ }
270
+ }
271
+ catch (error) {
272
+ errors.push(`invalid generated MCP snippet: ${error instanceof Error ? error.message : String(error)}`);
273
+ }
274
+ }
179
275
  }
180
276
  catch (error) {
181
277
  errors.push(`invalid configs/capabilities.yaml: ${error instanceof Error ? error.message : String(error)}`);
182
278
  }
183
279
  }
280
+ await validatePackageContract(target, errors, warnings);
281
+ await validateManagedManifestDrift(target, warnings);
282
+ await validateStaleCommandReferences(target, warnings);
184
283
  for (const [relative, requiredColumns] of Object.entries(REQUIRED_CSV_COLUMNS)) {
185
284
  await validateCsvHeader(target, relative, requiredColumns, errors);
186
285
  }
187
- return { ok: errors.length === 0, errors };
286
+ return { ok: errors.length === 0, errors, warnings };
287
+ }
288
+ async function personalizeInitializedProject(root, { title, slug, packageName, profile }, created) {
289
+ const configPath = join(root, "configs/default.yaml");
290
+ let config = await readProjectConfig(root);
291
+ if (created.has("configs/default.yaml")) {
292
+ config.project = {
293
+ ...config.project,
294
+ slug,
295
+ title,
296
+ profile,
297
+ package: packageName
298
+ };
299
+ await writeFile(configPath, YAML.stringify(config), "utf8");
300
+ }
301
+ config = await readProjectConfig(root);
302
+ const project = config.project;
303
+ if (created.has("pyproject.toml")) {
304
+ const pyprojectPath = join(root, "pyproject.toml");
305
+ const pyproject = await readFile(pyprojectPath, "utf8");
306
+ await writeFile(pyprojectPath, pyproject.replace(/^name = ".*"$/m, `name = "${project.slug}"`), "utf8");
307
+ }
308
+ if (created.has("README.md")) {
309
+ const readmePath = join(root, "README.md");
310
+ const readme = await readFile(readmePath, "utf8");
311
+ await writeFile(readmePath, readme.replace(/^# .*/m, `# ${project.title}`), "utf8");
312
+ }
313
+ await moveInitializedPythonPackage(root, project.package, created);
314
+ return { root, title: project.title, slug: project.slug, packageName: project.package };
315
+ }
316
+ async function moveInitializedPythonPackage(root, packageName, created) {
317
+ const previous = join(root, "src", "project_package");
318
+ const next = join(root, "src", packageName);
319
+ if (previous !== next && (await exists(previous)) && !(await exists(next))) {
320
+ await movePath(previous, next);
321
+ return;
322
+ }
323
+ await mkdir(dirname(join(next, "__init__.py")), { recursive: true });
324
+ if (!(await exists(join(next, "__init__.py")))) {
325
+ await writeFile(join(next, "__init__.py"), "\"\"\"Project package.\"\"\"\n", "utf8");
326
+ }
327
+ if (previous !== next && created.has("src/project_package/__init__.py")) {
328
+ await rm(join(previous, "__init__.py"), { force: true });
329
+ }
188
330
  }
189
331
  async function personalizeProject(root, { title, slug, packageName, profile, previousPackage = "project_package" }) {
190
332
  const configPath = join(root, "configs/default.yaml");
@@ -218,25 +360,94 @@ async function personalizeProject(root, { title, slug, packageName, profile, pre
218
360
  async function writeGeneratedPackageJson(root, { slug, preserveExistingSpec = false }) {
219
361
  const path = join(root, "package.json");
220
362
  const data = await readJson(path);
363
+ const packageSpec = await generatedPackageSpec(data, preserveExistingSpec);
364
+ await writeJson(path, generatedPackageJson(data, slug, packageSpec));
365
+ }
366
+ async function updateGeneratedPackageJson(root, slug, options) {
367
+ const path = join(root, "package.json");
368
+ const data = await readJson(path);
369
+ const packageSpec = await generatedPackageSpec(data, false);
370
+ const next = `${JSON.stringify(generatedPackageJson(data, slug, packageSpec), null, 2)}\n`;
371
+ await stageTextWrite(root, "package.json", next, options);
372
+ }
373
+ async function managedFileSpecs(root) {
374
+ const config = await readProjectConfig(root);
375
+ const packageJson = await readJson(join(root, "package.json"));
376
+ const packageSpec = await currentPackageVersion();
377
+ const state = await readCapabilities(root);
378
+ const snippet = renderMcpSnippet(state);
379
+ const currentReadme = await readOptionalText(join(root, "README.md"));
380
+ const currentDefaultConfig = await readOptionalText(join(root, "configs/default.yaml"));
381
+ const currentWikiLog = await readOptionalText(join(root, "wiki/log.md"));
382
+ return [
383
+ {
384
+ path: "README.md",
385
+ policy: "user-owned",
386
+ trackOnly: true,
387
+ content: currentReadme ?? ""
388
+ },
389
+ {
390
+ path: "configs/default.yaml",
391
+ policy: "user-owned",
392
+ trackOnly: true,
393
+ content: currentDefaultConfig ?? ""
394
+ },
395
+ {
396
+ path: "wiki/log.md",
397
+ policy: "append-only",
398
+ trackOnly: true,
399
+ content: currentWikiLog ?? ""
400
+ },
401
+ {
402
+ path: "package.json",
403
+ policy: "managed",
404
+ mergeSafe: true,
405
+ content: `${JSON.stringify(generatedPackageJson(packageJson, config.project.slug, packageSpec), null, 2)}\n`
406
+ },
407
+ { path: ".gitignore", policy: "managed", content: await templateText("_gitignore") },
408
+ { path: ".env.example", policy: "managed", content: formatMcpDotenv(Object.keys(AGENT_STACK.mcp_servers)) },
409
+ { path: "configs/agent-stack.yaml", policy: "managed", content: YAML.stringify(AGENT_STACK) },
410
+ { path: "docs/getting-started.md", policy: "managed", content: await templateText("docs/getting-started.md") },
411
+ {
412
+ path: "docs/agent/mcp-client-setup.md",
413
+ policy: "managed",
414
+ content: await templateText("docs/agent/mcp-client-setup.md")
415
+ },
416
+ { path: "scripts/README.md", policy: "managed", content: await templateText("scripts/README.md") },
417
+ { path: "docs/agent/capability-profile.md", policy: "generated", content: renderCapabilityProfile(state) },
418
+ { path: "docs/agent/mcp-setup.md", policy: "generated", content: renderMcpSetup(state) },
419
+ { path: join("docs/agent/generated", snippet.fileName), policy: "generated", content: snippet.content }
420
+ ];
421
+ }
422
+ async function templateText(relativePath) {
423
+ return readFile(join(templateRoot, relativePath), "utf8");
424
+ }
425
+ async function generatedPackageSpec(data, preserveExistingSpec) {
221
426
  const existingSpec = data.devDependencies?.["create-academic-research"];
222
- const packageSpec = process.env.CREATE_ACADEMIC_RESEARCH_PACKAGE_SPEC ??
427
+ return (process.env.CREATE_ACADEMIC_RESEARCH_PACKAGE_SPEC ??
223
428
  (preserveExistingSpec ? existingSpec : undefined) ??
224
- await currentPackageVersion();
225
- data.name = slug;
226
- data.scripts = {
227
- ...(data.scripts ?? {}),
228
- ...generatedLifecycleScripts(packageSpec)
229
- };
230
- data.devDependencies = {
231
- ...(data.devDependencies ?? {}),
232
- "create-academic-research": packageSpec
429
+ (await currentPackageVersion()));
430
+ }
431
+ function generatedPackageJson(data, slug, packageSpec) {
432
+ return {
433
+ ...data,
434
+ name: slug,
435
+ scripts: {
436
+ ...(data.scripts ?? {}),
437
+ ...generatedLifecycleScripts(packageSpec)
438
+ },
439
+ devDependencies: {
440
+ ...(data.devDependencies ?? {}),
441
+ "create-academic-research": packageSpec
442
+ }
233
443
  };
234
- await writeJson(path, data);
235
444
  }
236
445
  function generatedLifecycleScripts(packageSpec) {
237
446
  const command = `npm exec --yes --package=${lifecyclePackageSpec(packageSpec)} -- academic-research`;
447
+ const latestCommand = "npm exec --yes --package=create-academic-research@latest -- academic-research";
238
448
  return {
239
449
  doctor: `${command} doctor`,
450
+ update: `${latestCommand} update`,
240
451
  setup: `${command} setup`,
241
452
  rename: `${command} rename`,
242
453
  "agents:list": `${command} agents list`,
@@ -248,6 +459,8 @@ function generatedLifecycleScripts(packageSpec) {
248
459
  "skills:uninstall": `${command} skills uninstall`,
249
460
  "skills:update": `${command} skills update`,
250
461
  "mcp:list": `${command} mcp list`,
462
+ "mcp:modes": `${command} mcp modes`,
463
+ "mcp:status": `${command} mcp status`,
251
464
  "mcp:enabled": `${command} mcp enabled`,
252
465
  "mcp:available": `${command} mcp available`,
253
466
  "mcp:commands": `${command} mcp commands`,
@@ -255,6 +468,9 @@ function generatedLifecycleScripts(packageSpec) {
255
468
  "mcp:dotenv": `${command} mcp env --write .env.example --all`,
256
469
  "mcp:enable": `${command} mcp enable`,
257
470
  "mcp:disable": `${command} mcp disable`,
471
+ "mcp:setup": `${command} mcp setup`,
472
+ "mcp:client:add": `${command} mcp client add`,
473
+ "mcp:client:remove": `${command} mcp client remove`,
258
474
  "mcp:install": `${command} mcp install`,
259
475
  "mcp:uninstall": `${command} mcp uninstall`,
260
476
  "mcp:smoke": `${command} mcp smoke`,
@@ -271,10 +487,13 @@ function lifecyclePackageSpec(packageSpec) {
271
487
  }
272
488
  return `create-academic-research@${packageSpec}`;
273
489
  }
274
- async function writeGeneratedGitignore(root) {
490
+ async function writeGeneratedGitignore(root, options = {}) {
275
491
  const source = join(root, "_gitignore");
276
492
  if (await exists(source)) {
277
- await writeFile(join(root, ".gitignore"), await readFile(source, "utf8"), "utf8");
493
+ const target = join(root, ".gitignore");
494
+ if (options.overwrite !== false || !(await exists(target))) {
495
+ await writeFile(target, await readFile(source, "utf8"), "utf8");
496
+ }
278
497
  await rm(source);
279
498
  }
280
499
  }
@@ -285,9 +504,422 @@ async function currentPackageVersion() {
285
504
  }
286
505
  return packageJson.version;
287
506
  }
507
+ async function readProjectConfig(root) {
508
+ return YAML.parse(await readFile(join(root, "configs/default.yaml"), "utf8"));
509
+ }
510
+ async function updateManagedCapabilityFiles(root, options) {
511
+ const state = await readCapabilities(root);
512
+ await stageTextWrite(root, "docs/agent/capability-profile.md", renderCapabilityProfile(state), options);
513
+ await stageTextWrite(root, "docs/agent/mcp-setup.md", renderMcpSetup(state), options);
514
+ const snippet = renderMcpSnippet(state);
515
+ await stageTextWrite(root, join("docs/agent/generated", snippet.fileName), snippet.content, options);
516
+ }
517
+ async function stageTextWrite(root, relativePath, content, options) {
518
+ const path = join(root, relativePath);
519
+ let current;
520
+ try {
521
+ current = await readFile(path, "utf8");
522
+ }
523
+ catch (error) {
524
+ if (!isMissingFileError(error))
525
+ throw error;
526
+ }
527
+ if (current === content)
528
+ return;
529
+ options.changes.push({ path: toPosix(relativePath), action: current === undefined ? "create" : "update" });
530
+ if (!options.apply)
531
+ return;
532
+ await mkdir(dirname(path), { recursive: true });
533
+ await writeFile(path, content, "utf8");
534
+ }
535
+ async function writeManagedFileManifest(root) {
536
+ const specs = await managedFileSpecs(root);
537
+ const manifest = emptyManagedFileManifest(await currentPackageVersion());
538
+ for (const spec of specs) {
539
+ manifest.files[toPosix(spec.path)] = managedRecordForWrittenFile(spec);
540
+ }
541
+ await mkdir(dirname(managedManifestPath(root)), { recursive: true });
542
+ await writeFile(managedManifestPath(root), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
543
+ }
544
+ async function readManagedFileManifest(root) {
545
+ try {
546
+ const parsed = JSON.parse(await readFile(managedManifestPath(root), "utf8"));
547
+ return normalizeManagedFileManifest(parsed);
548
+ }
549
+ catch (error) {
550
+ if (isMissingFileError(error))
551
+ return undefined;
552
+ throw error;
553
+ }
554
+ }
555
+ async function stageManagedFiles(root, specs, manifest, options) {
556
+ const nextManifest = manifest
557
+ ? cloneManagedFileManifest(manifest)
558
+ : emptyManagedFileManifest(await currentPackageVersion());
559
+ nextManifest.generator = {
560
+ name: "create-academic-research",
561
+ version: await currentPackageVersion(),
562
+ updated_at: manifest?.generator.updated_at ?? nextManifest.generator.updated_at
563
+ };
564
+ for (const spec of specs) {
565
+ const relativePath = toPosix(spec.path);
566
+ const path = join(root, relativePath);
567
+ const current = await readOptionalText(path);
568
+ const currentChecksum = current === undefined ? undefined : checksumText(current);
569
+ const existing = manifest?.files[relativePath];
570
+ const generatedChecksum = checksumText(spec.content);
571
+ if (spec.trackOnly) {
572
+ if (current !== undefined) {
573
+ nextManifest.files[relativePath] = stableManagedRecord(existing, {
574
+ path: relativePath,
575
+ policy: spec.policy,
576
+ generated_checksum: generatedChecksum,
577
+ baseline_checksum: currentChecksum
578
+ });
579
+ }
580
+ continue;
581
+ }
582
+ if (current === spec.content) {
583
+ nextManifest.files[relativePath] = stableManagedRecord(existing, {
584
+ path: relativePath,
585
+ policy: spec.policy,
586
+ generated_checksum: generatedChecksum,
587
+ baseline_checksum: generatedChecksum
588
+ });
589
+ continue;
590
+ }
591
+ if (current === undefined) {
592
+ options.changes.push({ path: relativePath, action: "create" });
593
+ if (options.apply) {
594
+ await mkdir(dirname(path), { recursive: true });
595
+ await writeFile(path, spec.content, "utf8");
596
+ }
597
+ nextManifest.files[relativePath] = stableManagedRecord(existing, managedRecordCandidateForWrittenFile(spec));
598
+ continue;
599
+ }
600
+ if (spec.mergeSafe || canSafelyUpdateManagedFile(existing, currentChecksum)) {
601
+ options.changes.push({ path: relativePath, action: "update" });
602
+ if (options.apply) {
603
+ await mkdir(dirname(path), { recursive: true });
604
+ await writeFile(path, spec.content, "utf8");
605
+ }
606
+ nextManifest.files[relativePath] = stableManagedRecord(existing, managedRecordCandidateForWrittenFile(spec));
607
+ continue;
608
+ }
609
+ options.changes.push({
610
+ path: relativePath,
611
+ action: "skip",
612
+ reason: manifest ? "local edits detected" : "unknown legacy content"
613
+ });
614
+ nextManifest.files[relativePath] = stableManagedRecord(existing, {
615
+ path: relativePath,
616
+ policy: spec.policy,
617
+ generated_checksum: generatedChecksum,
618
+ current_checksum: currentChecksum,
619
+ reason: manifest ? "local edits detected" : "unknown legacy content"
620
+ });
621
+ }
622
+ return nextManifest;
623
+ }
624
+ async function stageManagedManifest(root, previous, next, options) {
625
+ if (previous && managedManifestSemanticallyEqual(previous, next))
626
+ return;
627
+ next.generator = {
628
+ name: "create-academic-research",
629
+ version: await currentPackageVersion(),
630
+ updated_at: nowIso()
631
+ };
632
+ const content = `${JSON.stringify(next, null, 2)}\n`;
633
+ const current = await readOptionalText(managedManifestPath(root));
634
+ options.changes.push({
635
+ path: ".academic-research/managed-files.json",
636
+ action: previous ? "update" : "create"
637
+ });
638
+ if (options.apply && current !== content) {
639
+ await mkdir(dirname(managedManifestPath(root)), { recursive: true });
640
+ await writeFile(managedManifestPath(root), content, "utf8");
641
+ }
642
+ }
643
+ function emptyManagedFileManifest(version) {
644
+ return {
645
+ version: 1,
646
+ generator: {
647
+ name: "create-academic-research",
648
+ version,
649
+ updated_at: nowIso()
650
+ },
651
+ files: {}
652
+ };
653
+ }
654
+ function normalizeManagedFileManifest(value) {
655
+ const record = typeof value === "object" && value !== null ? value : {};
656
+ const generator = typeof record.generator === "object" && record.generator !== null
657
+ ? record.generator
658
+ : {};
659
+ const files = typeof record.files === "object" && record.files !== null
660
+ ? record.files
661
+ : {};
662
+ return {
663
+ version: 1,
664
+ generator: {
665
+ name: "create-academic-research",
666
+ version: typeof generator.version === "string" ? generator.version : "unknown",
667
+ updated_at: typeof generator.updated_at === "string" ? generator.updated_at : nowIso()
668
+ },
669
+ files
670
+ };
671
+ }
672
+ function cloneManagedFileManifest(manifest) {
673
+ return {
674
+ version: 1,
675
+ generator: { ...manifest.generator },
676
+ files: Object.fromEntries(Object.entries(manifest.files).map(([path, record]) => [path, { ...record }]))
677
+ };
678
+ }
679
+ function managedRecordForWrittenFile(spec) {
680
+ return {
681
+ ...managedRecordCandidateForWrittenFile(spec),
682
+ updated_at: nowIso()
683
+ };
684
+ }
685
+ function managedRecordCandidateForWrittenFile(spec) {
686
+ const generatedChecksum = checksumText(spec.content);
687
+ return {
688
+ path: toPosix(spec.path),
689
+ policy: spec.policy,
690
+ generated_checksum: generatedChecksum,
691
+ baseline_checksum: generatedChecksum
692
+ };
693
+ }
694
+ function stableManagedRecord(existing, candidate) {
695
+ if (existing && managedRecordSemanticallyEqual(existing, candidate))
696
+ return existing;
697
+ return { ...candidate, updated_at: nowIso() };
698
+ }
699
+ function managedManifestSemanticallyEqual(left, right) {
700
+ if (left.version !== right.version)
701
+ return false;
702
+ if (left.generator.name !== right.generator.name)
703
+ return false;
704
+ if (left.generator.version !== right.generator.version)
705
+ return false;
706
+ const leftPaths = Object.keys(left.files).sort();
707
+ const rightPaths = Object.keys(right.files).sort();
708
+ if (leftPaths.length !== rightPaths.length)
709
+ return false;
710
+ for (let index = 0; index < leftPaths.length; index += 1) {
711
+ const path = leftPaths[index];
712
+ if (path !== rightPaths[index])
713
+ return false;
714
+ if (!managedRecordSemanticallyEqual(left.files[path], right.files[path]))
715
+ return false;
716
+ }
717
+ return true;
718
+ }
719
+ function managedRecordSemanticallyEqual(left, right) {
720
+ return (left.path === right.path &&
721
+ left.policy === right.policy &&
722
+ left.generated_checksum === right.generated_checksum &&
723
+ left.baseline_checksum === right.baseline_checksum &&
724
+ left.current_checksum === right.current_checksum &&
725
+ left.reason === right.reason);
726
+ }
727
+ function canSafelyUpdateManagedFile(record, currentChecksum) {
728
+ if (!record || !currentChecksum)
729
+ return false;
730
+ return currentChecksum === record.baseline_checksum || currentChecksum === record.generated_checksum;
731
+ }
732
+ async function readOptionalText(path) {
733
+ try {
734
+ return await readFile(path, "utf8");
735
+ }
736
+ catch (error) {
737
+ if (isMissingFileError(error))
738
+ return undefined;
739
+ throw error;
740
+ }
741
+ }
742
+ function managedManifestPath(root) {
743
+ return join(root, ".academic-research", "managed-files.json");
744
+ }
745
+ function checksumText(value) {
746
+ return `sha256:${createHash("sha256").update(value, "utf8").digest("hex")}`;
747
+ }
748
+ function nowIso() {
749
+ return new Date().toISOString();
750
+ }
751
+ async function copyDirectoryMissing(source, target) {
752
+ const created = new Set();
753
+ async function copyChildren(sourceDir, targetDir) {
754
+ await mkdir(targetDir, { recursive: true });
755
+ const entries = await readdir(sourceDir, { withFileTypes: true });
756
+ for (const entry of entries) {
757
+ if (entry.name === "node_modules" || entry.name === "__pycache__")
758
+ continue;
759
+ const sourcePath = join(sourceDir, entry.name);
760
+ const targetPath = join(targetDir, entry.name);
761
+ if (entry.isDirectory()) {
762
+ await copyChildren(sourcePath, targetPath);
763
+ continue;
764
+ }
765
+ if (await exists(targetPath))
766
+ continue;
767
+ await mkdir(dirname(targetPath), { recursive: true });
768
+ await copyFile(sourcePath, targetPath);
769
+ created.add(toPosix(relative(target, targetPath)));
770
+ }
771
+ }
772
+ await copyChildren(source, target);
773
+ return created;
774
+ }
288
775
  async function writeAgentStack(root) {
289
776
  await writeFile(join(root, "configs/agent-stack.yaml"), YAML.stringify(AGENT_STACK), "utf8");
290
777
  }
778
+ async function validatePackageContract(root, errors, warnings) {
779
+ const path = join(root, "package.json");
780
+ if (!(await exists(path)))
781
+ return;
782
+ let data;
783
+ try {
784
+ data = await readJson(path);
785
+ }
786
+ catch (error) {
787
+ errors.push(`invalid package.json: ${error instanceof Error ? error.message : String(error)}`);
788
+ return;
789
+ }
790
+ const packageSpec = data.devDependencies?.["create-academic-research"] ?? (await currentPackageVersion());
791
+ const expectedScripts = generatedLifecycleScripts(packageSpec);
792
+ for (const [name, expected] of Object.entries(expectedScripts)) {
793
+ const actual = data.scripts?.[name];
794
+ if (!actual) {
795
+ warnings.push(`package.json missing lifecycle script: ${name}`);
796
+ continue;
797
+ }
798
+ if (isStaleLifecycleCommand(actual)) {
799
+ errors.push(`package.json script ${name} uses stale academic-research invocation`);
800
+ continue;
801
+ }
802
+ if (actual !== expected) {
803
+ warnings.push(`package.json script ${name} differs from the current managed command`);
804
+ }
805
+ }
806
+ const current = await currentPackageVersion();
807
+ if (isOlderSimpleVersion(packageSpec, current)) {
808
+ warnings.push(`create-academic-research ${packageSpec} is older than ${current}; run npm run update -- --apply or npm exec --yes --package=create-academic-research@latest -- academic-research update --root . --apply`);
809
+ }
810
+ }
811
+ async function validateManagedManifestDrift(root, warnings) {
812
+ let specs;
813
+ try {
814
+ specs = await managedFileSpecs(root);
815
+ }
816
+ catch {
817
+ return;
818
+ }
819
+ const manifest = await readManagedFileManifest(root);
820
+ if (!manifest) {
821
+ warnings.push("managed-file manifest is missing; run npm exec --yes --package=create-academic-research@latest -- academic-research update --root . --apply");
822
+ }
823
+ for (const spec of specs) {
824
+ if (spec.trackOnly)
825
+ continue;
826
+ if (spec.path === "package.json")
827
+ continue;
828
+ const relativePath = toPosix(spec.path);
829
+ const current = await readOptionalText(join(root, relativePath));
830
+ if (current === undefined) {
831
+ warnings.push(`${relativePath} is missing; run npm run update -- --apply`);
832
+ continue;
833
+ }
834
+ if (current === spec.content)
835
+ continue;
836
+ const checksum = checksumText(current);
837
+ const record = manifest?.files[relativePath];
838
+ if (canSafelyUpdateManagedFile(record, checksum)) {
839
+ warnings.push(`${relativePath} is not current; run npm run update -- --apply`);
840
+ }
841
+ else {
842
+ warnings.push(`${relativePath} has local edits; run npm run update to preview managed changes`);
843
+ }
844
+ }
845
+ }
846
+ async function warnIfTextDrift(root, relativePath, expected, warning, warnings) {
847
+ try {
848
+ const actual = await readFile(join(root, relativePath), "utf8");
849
+ if (actual !== expected)
850
+ warnings.push(warning);
851
+ }
852
+ catch (error) {
853
+ if (!isMissingFileError(error))
854
+ throw error;
855
+ }
856
+ }
857
+ async function validateStaleCommandReferences(root, warnings) {
858
+ const docs = [
859
+ "README.md",
860
+ "docs/getting-started.md",
861
+ "docs/agent/capability-profile.md",
862
+ "docs/agent/mcp-client-setup.md",
863
+ "docs/agent/mcp-setup.md",
864
+ "scripts/README.md"
865
+ ];
866
+ for (const relativePath of docs) {
867
+ try {
868
+ const text = await readFile(join(root, relativePath), "utf8");
869
+ if (containsStaleCommandReference(text)) {
870
+ warnings.push(`stale command reference in ${relativePath}; prefer project npm scripts`);
871
+ }
872
+ }
873
+ catch (error) {
874
+ if (!isMissingFileError(error))
875
+ throw error;
876
+ }
877
+ }
878
+ }
879
+ function containsStaleCommandReference(text) {
880
+ return (/\bnpx\s+academic-research\b/.test(text) ||
881
+ /(^|[`(>\s])academic-research\s+(doctor|setup|rename|agents|skills|mcp)\b/.test(text));
882
+ }
883
+ function isStaleLifecycleCommand(command) {
884
+ return /^academic-research\s+/.test(command) || /\bnpx\s+academic-research\b/.test(command);
885
+ }
886
+ function isOlderSimpleVersion(left, right) {
887
+ const leftParts = parseSimpleVersion(left);
888
+ const rightParts = parseSimpleVersion(right);
889
+ if (!leftParts || !rightParts)
890
+ return false;
891
+ for (let index = 0; index < rightParts.length; index += 1) {
892
+ if (leftParts[index] < rightParts[index])
893
+ return true;
894
+ if (leftParts[index] > rightParts[index])
895
+ return false;
896
+ }
897
+ return false;
898
+ }
899
+ function parseSimpleVersion(value) {
900
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(value);
901
+ if (!match)
902
+ return undefined;
903
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
904
+ }
905
+ function assertKnownPreset(preset) {
906
+ if (!AGENT_STACK.presets[preset]) {
907
+ throw new Error(`unknown capability preset: ${preset}. Expected one of: ${Object.keys(AGENT_STACK.presets).join(", ")}`);
908
+ }
909
+ return preset;
910
+ }
911
+ function isMissingFileError(error) {
912
+ return (typeof error === "object" &&
913
+ error !== null &&
914
+ "code" in error &&
915
+ error.code === "ENOENT");
916
+ }
917
+ function envHasValue(env, name) {
918
+ return typeof env[name] === "string" && env[name] !== "";
919
+ }
920
+ function toPosix(value) {
921
+ return value.split(/[\\/]/).join("/");
922
+ }
291
923
  async function validateCsvHeader(root, relative, requiredColumns, errors) {
292
924
  const path = join(root, relative);
293
925
  if (!(await exists(path)))