create-academic-research 0.1.13 → 0.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -11
- package/dist/bin/academic-research.js +0 -0
- package/dist/bin/create-academic-research.js +0 -0
- package/dist/src/capabilities.d.ts +132 -3
- package/dist/src/capabilities.js +993 -48
- package/dist/src/cli.js +448 -33
- package/dist/src/mcp-env.d.ts +4 -0
- package/dist/src/mcp-env.js +2 -2
- package/dist/src/mcp-probe.d.ts +3 -2
- package/dist/src/mcp-probe.js +87 -30
- package/dist/src/project.d.ts +18 -0
- package/dist/src/project.js +654 -22
- package/dist/src/stack.d.ts +38 -0
- package/dist/src/stack.js +260 -14
- package/package.json +2 -2
- package/template/README.md +37 -4
- package/template/_gitignore +1 -0
- package/template/docs/agent/mcp-client-setup.md +43 -3
- package/template/docs/agent/mcp-setup.md +60 -0
- package/template/docs/getting-started.md +17 -5
- package/template/package.json +7 -1
package/dist/src/project.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
427
|
+
return (process.env.CREATE_ACADEMIC_RESEARCH_PACKAGE_SPEC ??
|
|
223
428
|
(preserveExistingSpec ? existingSpec : undefined) ??
|
|
224
|
-
await currentPackageVersion();
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
...
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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)))
|