create-academic-research 0.1.13 → 0.1.14

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
@@ -114,6 +114,7 @@ Inside a generated project:
114
114
 
115
115
  ```bash
116
116
  npm run doctor
117
+ npm run update
117
118
  npm run setup
118
119
  npm run rename -- --title "New Title" --slug new-title --package new_title
119
120
  npm run agents:list
@@ -147,6 +148,13 @@ For direct one-off invocation without the generated package scripts, use
147
148
 
148
149
  ## Command Model
149
150
 
151
+ `academic-research update` is a dry-run by default. It reports managed project
152
+ files that would change and writes them only with `--apply`.
153
+
154
+ `academic-research init` initializes an existing repository without overwriting
155
+ existing files. It adds the research contract, merges lifecycle package scripts,
156
+ and preserves existing README, `.gitignore`, and custom package scripts.
157
+
150
158
  `academic-research setup` is a non-destructive onboarding status command. It
151
159
  prints the active preset, agent, skill counts, enabled MCP records, and next
152
160
  commands without changing files.
@@ -43,6 +43,10 @@ export interface McpProbeOptions {
43
43
  timeoutMs?: number;
44
44
  clientVersion?: string;
45
45
  }
46
+ export interface RenderedMcpSnippet {
47
+ fileName: string;
48
+ content: string;
49
+ }
46
50
  interface SkillInstallOptions {
47
51
  agent?: string;
48
52
  }
@@ -69,4 +73,7 @@ export declare function installMcpTools(root: string, servers: string[], runner?
69
73
  export declare function uninstallMcpTools(root: string, servers: string[], runner?: Runner): Promise<CapabilityCommandResult>;
70
74
  export declare function doctorMcpServers(root: string, options?: McpDoctorOptions): Promise<McpDoctorResult>;
71
75
  export declare function probeMcpServers(root: string, servers: string[], options?: McpProbeOptions): Promise<ProbeResult>;
76
+ export declare function renderMcpSnippet(state: CapabilityState): RenderedMcpSnippet;
77
+ export declare function renderCapabilityProfile(state: CapabilityState): string;
78
+ export declare function renderMcpSetup(state: CapabilityState): string;
72
79
  export declare function assertKnownMcpServers(servers: string[]): void;
@@ -303,6 +303,13 @@ export async function probeMcpServers(root, servers, options = {}) {
303
303
  return probeMcpServerList(root, selected, env, timeoutMs, options.clientVersion);
304
304
  }
305
305
  async function writeMcpSnippet(root, state) {
306
+ const snippet = renderMcpSnippet(state);
307
+ const outputDir = join(root, "docs/agent/generated");
308
+ await mkdir(outputDir, { recursive: true });
309
+ await removeInactiveMcpSnippets(outputDir, snippet.fileName);
310
+ await writeFile(join(outputDir, snippet.fileName), snippet.content, "utf8");
311
+ }
312
+ export function renderMcpSnippet(state) {
306
313
  const servers = {};
307
314
  for (const name of state.mcp_servers ?? []) {
308
315
  const server = AGENT_STACK.mcp_servers[name];
@@ -315,13 +322,15 @@ async function writeMcpSnippet(root, state) {
315
322
  if (Object.keys(server.env).length > 0)
316
323
  servers[name].env = server.env;
317
324
  }
318
- const outputDir = join(root, "docs/agent/generated");
319
- const outputFile = mcpSnippetFileName(state.agent);
320
- await mkdir(outputDir, { recursive: true });
321
- await removeInactiveMcpSnippets(outputDir, outputFile);
322
- await writeFile(join(outputDir, outputFile), `${JSON.stringify({ mcpServers: servers }, null, 2)}\n`, "utf8");
325
+ return {
326
+ fileName: mcpSnippetFileName(state.agent),
327
+ content: `${JSON.stringify({ mcpServers: servers }, null, 2)}\n`
328
+ };
323
329
  }
324
330
  async function writeCapabilityProfile(root, state) {
331
+ await writeFile(join(root, "docs/agent/capability-profile.md"), renderCapabilityProfile(state), "utf8");
332
+ }
333
+ export function renderCapabilityProfile(state) {
325
334
  const lines = [
326
335
  "# Agent Capability Profile",
327
336
  "",
@@ -352,9 +361,13 @@ async function writeCapabilityProfile(root, state) {
352
361
  }
353
362
  }
354
363
  lines.push("", "## Rules", "", "- Skill installation is project-local by default.", "- Agent target `universal` installs one shared project-local `.agents/skills` copy.", "- MCP enable/disable changes project records; install/uninstall changes external tools.", "- Keep API keys, tokens, cookies, and browser sessions out of git.", "- Cite repository source records, not raw MCP output alone.", "");
355
- await writeFile(join(root, "docs/agent/capability-profile.md"), lines.join("\n"), "utf8");
364
+ return lines.join("\n");
356
365
  }
357
366
  async function writeMcpSetup(root, state) {
367
+ await mkdir(join(root, "docs/agent"), { recursive: true });
368
+ await writeFile(join(root, "docs/agent/mcp-setup.md"), renderMcpSetup(state), "utf8");
369
+ }
370
+ export function renderMcpSetup(state) {
358
371
  const enabled = new Set(state.mcp_servers ?? []);
359
372
  const lines = [
360
373
  "# MCP Setup",
@@ -384,8 +397,7 @@ async function writeMcpSetup(root, state) {
384
397
  appendMcpPrerequisiteLines(lines, server.required_env, server.recommended_env, server.local_service);
385
398
  }
386
399
  lines.push("", "## Operating Rules", "", "- Use `.env.example` as a committed reference and put filled secrets in `.env.local`, your shell, or your MCP client secret store.", "- Print a dotenv-style reference with `npm run mcp:env -- --dotenv --all`.", "- Regenerate a dotenv-style reference with `npm run mcp:dotenv`.", "- Pass `--env-file .env.local` to `mcp doctor`, `mcp smoke`, or `mcp probe` when you want the CLI to read explicit local secrets.", "- Keep secrets in your shell, MCP client secret store, or local untracked files; do not commit tokens or API keys.", "- Prefer the smallest enabled MCP set that covers the current research question.", "- Treat MCP output as retrieval metadata. Promote claims into repository source records only after source ingestion and citation audit.", "- Run `npm run mcp:doctor` after changing MCP records or environment variables.", "- Run `npm run mcp:probe -- <server>` only when you intentionally want to start selected MCP server processes.", "");
387
- await mkdir(join(root, "docs/agent"), { recursive: true });
388
- await writeFile(join(root, "docs/agent/mcp-setup.md"), lines.join("\n"), "utf8");
400
+ return lines.join("\n");
389
401
  }
390
402
  async function appendCapabilityLog(root, state) {
391
403
  const logPath = join(root, "wiki/log.md");
package/dist/src/cli.js CHANGED
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { basename, delimiter, dirname, join, resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { assertKnownMcpServers, disableMcpServers, doctorMcpServers, enableMcpServers, DEFAULT_AGENT, installMcpTools, installSkillIds, installSkills, formatMcpDotenv, listInstalledSkills, listMcpEnvironmentEntries, mergeMcpEnvironment, mcpToolCommandTexts, probeMcpServers, readCapabilities, readMcpEnvironmentFile, removeSkills, uninstallMcpTools, updateSkills } from "./capabilities.js";
5
- import { createProject, doctorProject, renameProject } from "./project.js";
5
+ import { createProject, doctorProject, initProject, renameProject, updateProject } from "./project.js";
6
6
  import { askCreateOptions } from "./prompts.js";
7
7
  import { AGENT_STACK, presetMcpServers } from "./stack.js";
8
8
  import { formatAgentAliasLines, formatAgentTargetList, formatSupportedAgentTargetLines } from "./agents.js";
@@ -19,6 +19,8 @@ const CREATE_FLAGS = flagSchema([
19
19
  "no-install-mcp-tools"
20
20
  ], ["title", "slug", "package", "preset", "profile", "agent"]);
21
21
  const ROOT_FLAGS = flagSchema(["help"], ["root"]);
22
+ const UPDATE_FLAGS = flagSchema(["help", "dry-run", "apply"], ["root"]);
23
+ const INIT_FLAGS = flagSchema(["help", "install-skills"], ["root", "title", "slug", "package", "preset", "profile", "agent"]);
22
24
  const RENAME_FLAGS = flagSchema(["help"], ["root", "title", "slug", "package"]);
23
25
  const SKILLS_FLAGS = flagSchema(["help"], ["root", "preset", "agent"]);
24
26
  const MCP_FLAGS = flagSchema(["help", "all", "dotenv", "required", "recommended"], ["root", "agent", "env-file", "write", "timeout-ms"]);
@@ -113,6 +115,10 @@ async function lifecycleMain(argv) {
113
115
  }
114
116
  if (command === "doctor")
115
117
  return doctorCommand(argv.slice(1));
118
+ if (command === "update")
119
+ return updateCommand(argv.slice(1));
120
+ if (command === "init")
121
+ return initCommand(argv.slice(1));
116
122
  if (command === "setup")
117
123
  return setupCommand(argv.slice(1));
118
124
  if (command === "rename")
@@ -136,10 +142,60 @@ async function doctorCommand(argv) {
136
142
  const result = await doctorProject(root);
137
143
  for (const error of result.errors)
138
144
  console.error(`ERROR: ${error}`);
145
+ for (const warning of result.warnings)
146
+ console.warn(`WARN: ${warning}`);
139
147
  if (result.ok)
140
148
  console.log(`OK: ${root}`);
141
149
  return result.ok ? 0 : 1;
142
150
  }
151
+ async function updateCommand(argv) {
152
+ const parsed = parseFlags(argv, UPDATE_FLAGS);
153
+ if (flagBool(parsed.flags, "help")) {
154
+ printUpdateHelp();
155
+ return 0;
156
+ }
157
+ assertNoArguments(parsed.positionals, "update");
158
+ if (flagBool(parsed.flags, "dry-run") && flagBool(parsed.flags, "apply")) {
159
+ throw new Error("update cannot use --dry-run and --apply together");
160
+ }
161
+ const root = resolve(flagString(parsed.flags, "root") ?? ".");
162
+ const apply = flagBool(parsed.flags, "apply");
163
+ const result = await updateProject(root, { apply });
164
+ console.log(`${apply ? "UPDATED" : "DRY-RUN"}: ${root}`);
165
+ if (result.changes.length === 0) {
166
+ console.log("No managed file changes.");
167
+ }
168
+ else {
169
+ for (const change of result.changes)
170
+ console.log(`${change.action}\t${change.path}`);
171
+ }
172
+ if (!apply && result.changes.length > 0) {
173
+ console.log("Run `npm run update -- --apply` from a generated project to write these managed changes.");
174
+ }
175
+ return 0;
176
+ }
177
+ async function initCommand(argv) {
178
+ const parsed = parseFlags(argv, INIT_FLAGS);
179
+ if (flagBool(parsed.flags, "help")) {
180
+ printInitHelp();
181
+ return 0;
182
+ }
183
+ assertNoArguments(parsed.positionals, "init");
184
+ const root = resolve(flagString(parsed.flags, "root") ?? ".");
185
+ const result = await initProject({
186
+ target: root,
187
+ title: flagString(parsed.flags, "title"),
188
+ slug: flagString(parsed.flags, "slug"),
189
+ packageName: flagString(parsed.flags, "package"),
190
+ profile: flagString(parsed.flags, "profile") ?? "academic-general",
191
+ preset: flagString(parsed.flags, "preset") ?? "default",
192
+ agent: flagString(parsed.flags, "agent") ?? DEFAULT_AGENT,
193
+ installSkills: flagBool(parsed.flags, "install-skills")
194
+ });
195
+ console.log(`Initialized ${result.slug} at ${result.root}`);
196
+ console.log("Next: run `npm run doctor`.");
197
+ return 0;
198
+ }
143
199
  async function setupCommand(argv) {
144
200
  const parsed = parseFlags(argv, ROOT_FLAGS);
145
201
  if (flagBool(parsed.flags, "help")) {
@@ -165,6 +221,8 @@ async function setupCommand(argv) {
165
221
  for (const error of project.errors)
166
222
  console.error(`ERROR: ${error}`);
167
223
  }
224
+ for (const warning of project.warnings)
225
+ console.warn(`WARN: ${warning}`);
168
226
  console.log("");
169
227
  console.log("Next Commands");
170
228
  console.log(`npm run skills:install -- --preset ${state.preset}`);
@@ -619,7 +677,7 @@ function printMissingTargetHelp() {
619
677
  }
620
678
  function printLifecycleHelp() {
621
679
  console.log([
622
- "Usage: academic-research <doctor|setup|rename|agents|skills|mcp>",
680
+ "Usage: academic-research <doctor|update|init|setup|rename|agents|skills|mcp>",
623
681
  "",
624
682
  "Manage a generated academic research repository after creation.",
625
683
  "",
@@ -628,6 +686,37 @@ function printLifecycleHelp() {
628
686
  " -v, --version Show package version."
629
687
  ].join("\n"));
630
688
  }
689
+ function printUpdateHelp() {
690
+ console.log([
691
+ "Usage: academic-research update [options]",
692
+ "",
693
+ "Preview or apply non-destructive updates to managed project files.",
694
+ "",
695
+ "Options:",
696
+ " --root <path> Project root. Default: current directory.",
697
+ " --dry-run Preview managed changes without writing. Default.",
698
+ " --apply Write managed changes.",
699
+ " -h, --help Show this help."
700
+ ].join("\n"));
701
+ }
702
+ function printInitHelp() {
703
+ console.log([
704
+ "Usage: academic-research init [options]",
705
+ "",
706
+ "Initialize an existing repository without overwriting existing files.",
707
+ "",
708
+ "Options:",
709
+ " --root <path> Project root. Default: current directory.",
710
+ " --title <name> Project title. Default: title-cased directory name.",
711
+ " --slug <name> Repository/package slug. Default: normalized directory name.",
712
+ " --package <name> Python package name. Default: normalized directory name.",
713
+ " --preset <name> Capability preset: minimal, default, enhanced, literature, writing, full.",
714
+ " --profile <name> Project profile metadata. Default: academic-general.",
715
+ " --agent <id> Agent target: universal, auto, or a supported skills.sh id.",
716
+ " --install-skills Install project-local skills after initialization.",
717
+ " -h, --help Show this help."
718
+ ].join("\n"));
719
+ }
631
720
  function printSetupHelp() {
632
721
  console.log([
633
722
  "Usage: academic-research setup [options]",
@@ -13,6 +13,11 @@ export interface RenameProjectOptions {
13
13
  slug?: string;
14
14
  packageName?: string;
15
15
  }
16
+ export interface InitProjectOptions extends CreateProjectOptions {
17
+ }
18
+ export interface UpdateProjectOptions {
19
+ apply?: boolean;
20
+ }
16
21
  export interface ProjectResult {
17
22
  root: string;
18
23
  title: string;
@@ -22,7 +27,19 @@ export interface ProjectResult {
22
27
  export interface DoctorResult {
23
28
  ok: boolean;
24
29
  errors: string[];
30
+ warnings: string[];
31
+ }
32
+ export interface ProjectFileChange {
33
+ path: string;
34
+ action: "create" | "update";
35
+ }
36
+ export interface UpdateProjectResult {
37
+ root: string;
38
+ applied: boolean;
39
+ changes: ProjectFileChange[];
25
40
  }
26
41
  export declare function createProject(options: CreateProjectOptions): Promise<ProjectResult>;
42
+ export declare function initProject(options: InitProjectOptions): Promise<ProjectResult>;
27
43
  export declare function renameProject(root: string, options: RenameProjectOptions): Promise<ProjectResult>;
44
+ export declare function updateProject(root: string, options?: UpdateProjectOptions): Promise<UpdateProjectResult>;
28
45
  export declare function doctorProject(root: string): Promise<DoctorResult>;
@@ -1,8 +1,8 @@
1
- import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
- import { basename, dirname, join, resolve } from "node:path";
1
+ import { copyFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { basename, dirname, join, relative, resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import YAML from "yaml";
5
- import { DEFAULT_AGENT, initializeCapabilities, installSkills, writeMcpEnvironmentExample } from "./capabilities.js";
5
+ import { DEFAULT_AGENT, formatMcpDotenv, initializeCapabilities, installSkills, readCapabilities, renderCapabilityProfile, renderMcpSetup, renderMcpSnippet, writeMcpEnvironmentExample } from "./capabilities.js";
6
6
  import { assertKnownAgentTarget } from "./agents.js";
7
7
  import { copyDirectory, exists, isNonEmptyDirectory, movePath, readJson, writeJson } from "./files.js";
8
8
  import { packageify, slugify, titleFromSlug } from "./names.js";
@@ -90,11 +90,8 @@ export async function createProject(options) {
90
90
  const title = options.title ?? titleFromSlug(options.slug ?? basename(target));
91
91
  const slug = slugify(options.slug ?? title);
92
92
  const packageName = packageify(options.packageName ?? slug);
93
- const preset = options.preset ?? "default";
93
+ const preset = assertKnownPreset(options.preset ?? "default");
94
94
  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
95
  await mkdir(dirname(target), { recursive: true });
99
96
  await copyDirectory(templateRoot, target);
100
97
  await writeGeneratedGitignore(target);
@@ -108,6 +105,38 @@ export async function createProject(options) {
108
105
  }
109
106
  return { root: target, title, slug, packageName };
110
107
  }
108
+ export async function initProject(options) {
109
+ const target = resolve(options.target);
110
+ const title = options.title ?? titleFromSlug(options.slug ?? basename(target));
111
+ const slug = slugify(options.slug ?? title);
112
+ const packageName = packageify(options.packageName ?? slug);
113
+ const preset = assertKnownPreset(options.preset ?? "default");
114
+ const agent = assertKnownAgentTarget(options.agent ?? DEFAULT_AGENT);
115
+ await mkdir(target, { recursive: true });
116
+ const created = await copyDirectoryMissing(templateRoot, target);
117
+ await writeGeneratedGitignore(target, { overwrite: false });
118
+ const project = await personalizeInitializedProject(target, {
119
+ title,
120
+ slug,
121
+ packageName,
122
+ profile: options.profile ?? "academic-general"
123
+ }, created);
124
+ await writeGeneratedPackageJson(target, { slug: project.slug });
125
+ if (created.has("configs/agent-stack.yaml"))
126
+ await writeAgentStack(target);
127
+ if (created.has(".env.example"))
128
+ await writeMcpEnvironmentExample(target);
129
+ if (created.has("configs/capabilities.yaml")) {
130
+ await initializeCapabilities(target, { preset, agent });
131
+ }
132
+ else {
133
+ await updateManagedCapabilityFiles(target, { apply: true, changes: [] });
134
+ }
135
+ if (options.installSkills) {
136
+ await installSkills(target, preset, { agent });
137
+ }
138
+ return project;
139
+ }
111
140
  export async function renameProject(root, options) {
112
141
  const target = resolve(root);
113
142
  const configPath = join(target, "configs/default.yaml");
@@ -126,9 +155,23 @@ export async function renameProject(root, options) {
126
155
  await writeGeneratedPackageJson(target, { slug, preserveExistingSpec: true });
127
156
  return { root: target, title, slug, packageName };
128
157
  }
158
+ export async function updateProject(root, options = {}) {
159
+ const target = resolve(root);
160
+ const config = await readProjectConfig(target);
161
+ const changes = [];
162
+ await updateGeneratedPackageJson(target, config.project.slug, { apply: options.apply === true, changes });
163
+ await stageTextWrite(target, ".env.example", formatMcpDotenv(Object.keys(AGENT_STACK.mcp_servers)), { apply: options.apply === true, changes });
164
+ await stageTextWrite(target, "configs/agent-stack.yaml", YAML.stringify(AGENT_STACK), {
165
+ apply: options.apply === true,
166
+ changes
167
+ });
168
+ await updateManagedCapabilityFiles(target, { apply: options.apply === true, changes });
169
+ return { root: target, applied: options.apply === true, changes };
170
+ }
129
171
  export async function doctorProject(root) {
130
172
  const target = resolve(root);
131
173
  const errors = [];
174
+ const warnings = [];
132
175
  const required = [
133
176
  "README.md",
134
177
  ".gitignore",
@@ -175,16 +218,82 @@ export async function doctorProject(root) {
175
218
  }
176
219
  if (await exists(join(target, "configs/capabilities.yaml"))) {
177
220
  try {
178
- YAML.parse(await readFile(join(target, "configs/capabilities.yaml"), "utf8"));
221
+ const state = await readCapabilities(target);
222
+ const unknown = state.mcp_servers.filter((server) => !AGENT_STACK.mcp_servers[server]);
223
+ if (unknown.length > 0)
224
+ errors.push(`unknown MCP server in configs/capabilities.yaml: ${unknown.join(", ")}`);
225
+ const snippet = renderMcpSnippet(state);
226
+ const commandServers = state.mcp_servers.filter((server) => AGENT_STACK.mcp_servers[server]?.command);
227
+ if (commandServers.length > 0) {
228
+ try {
229
+ const raw = await readFile(join(target, "docs/agent/generated", snippet.fileName), "utf8");
230
+ const generated = JSON.parse(raw);
231
+ for (const server of commandServers) {
232
+ if (!Object.hasOwn(generated.mcpServers ?? {}, server)) {
233
+ errors.push(`${server}: enabled but missing from generated MCP snippet`);
234
+ }
235
+ }
236
+ }
237
+ catch (error) {
238
+ errors.push(`invalid generated MCP snippet: ${error instanceof Error ? error.message : String(error)}`);
239
+ }
240
+ }
241
+ await validateManagedCapabilityDrift(target, state, warnings);
179
242
  }
180
243
  catch (error) {
181
244
  errors.push(`invalid configs/capabilities.yaml: ${error instanceof Error ? error.message : String(error)}`);
182
245
  }
183
246
  }
247
+ await validatePackageContract(target, errors, warnings);
248
+ await validateManagedTextDrift(target, warnings);
249
+ await validateStaleCommandReferences(target, warnings);
184
250
  for (const [relative, requiredColumns] of Object.entries(REQUIRED_CSV_COLUMNS)) {
185
251
  await validateCsvHeader(target, relative, requiredColumns, errors);
186
252
  }
187
- return { ok: errors.length === 0, errors };
253
+ return { ok: errors.length === 0, errors, warnings };
254
+ }
255
+ async function personalizeInitializedProject(root, { title, slug, packageName, profile }, created) {
256
+ const configPath = join(root, "configs/default.yaml");
257
+ let config = await readProjectConfig(root);
258
+ if (created.has("configs/default.yaml")) {
259
+ config.project = {
260
+ ...config.project,
261
+ slug,
262
+ title,
263
+ profile,
264
+ package: packageName
265
+ };
266
+ await writeFile(configPath, YAML.stringify(config), "utf8");
267
+ }
268
+ config = await readProjectConfig(root);
269
+ const project = config.project;
270
+ if (created.has("pyproject.toml")) {
271
+ const pyprojectPath = join(root, "pyproject.toml");
272
+ const pyproject = await readFile(pyprojectPath, "utf8");
273
+ await writeFile(pyprojectPath, pyproject.replace(/^name = ".*"$/m, `name = "${project.slug}"`), "utf8");
274
+ }
275
+ if (created.has("README.md")) {
276
+ const readmePath = join(root, "README.md");
277
+ const readme = await readFile(readmePath, "utf8");
278
+ await writeFile(readmePath, readme.replace(/^# .*/m, `# ${project.title}`), "utf8");
279
+ }
280
+ await moveInitializedPythonPackage(root, project.package, created);
281
+ return { root, title: project.title, slug: project.slug, packageName: project.package };
282
+ }
283
+ async function moveInitializedPythonPackage(root, packageName, created) {
284
+ const previous = join(root, "src", "project_package");
285
+ const next = join(root, "src", packageName);
286
+ if (previous !== next && (await exists(previous)) && !(await exists(next))) {
287
+ await movePath(previous, next);
288
+ return;
289
+ }
290
+ await mkdir(dirname(join(next, "__init__.py")), { recursive: true });
291
+ if (!(await exists(join(next, "__init__.py")))) {
292
+ await writeFile(join(next, "__init__.py"), "\"\"\"Project package.\"\"\"\n", "utf8");
293
+ }
294
+ if (previous !== next && created.has("src/project_package/__init__.py")) {
295
+ await rm(join(previous, "__init__.py"), { force: true });
296
+ }
188
297
  }
189
298
  async function personalizeProject(root, { title, slug, packageName, profile, previousPackage = "project_package" }) {
190
299
  const configPath = join(root, "configs/default.yaml");
@@ -218,25 +327,41 @@ async function personalizeProject(root, { title, slug, packageName, profile, pre
218
327
  async function writeGeneratedPackageJson(root, { slug, preserveExistingSpec = false }) {
219
328
  const path = join(root, "package.json");
220
329
  const data = await readJson(path);
330
+ const packageSpec = await generatedPackageSpec(data, preserveExistingSpec);
331
+ await writeJson(path, generatedPackageJson(data, slug, packageSpec));
332
+ }
333
+ async function updateGeneratedPackageJson(root, slug, options) {
334
+ const path = join(root, "package.json");
335
+ const data = await readJson(path);
336
+ const packageSpec = await generatedPackageSpec(data, false);
337
+ const next = `${JSON.stringify(generatedPackageJson(data, slug, packageSpec), null, 2)}\n`;
338
+ await stageTextWrite(root, "package.json", next, options);
339
+ }
340
+ async function generatedPackageSpec(data, preserveExistingSpec) {
221
341
  const existingSpec = data.devDependencies?.["create-academic-research"];
222
- const packageSpec = process.env.CREATE_ACADEMIC_RESEARCH_PACKAGE_SPEC ??
342
+ return (process.env.CREATE_ACADEMIC_RESEARCH_PACKAGE_SPEC ??
223
343
  (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
344
+ (await currentPackageVersion()));
345
+ }
346
+ function generatedPackageJson(data, slug, packageSpec) {
347
+ return {
348
+ ...data,
349
+ name: slug,
350
+ scripts: {
351
+ ...(data.scripts ?? {}),
352
+ ...generatedLifecycleScripts(packageSpec)
353
+ },
354
+ devDependencies: {
355
+ ...(data.devDependencies ?? {}),
356
+ "create-academic-research": packageSpec
357
+ }
233
358
  };
234
- await writeJson(path, data);
235
359
  }
236
360
  function generatedLifecycleScripts(packageSpec) {
237
361
  const command = `npm exec --yes --package=${lifecyclePackageSpec(packageSpec)} -- academic-research`;
238
362
  return {
239
363
  doctor: `${command} doctor`,
364
+ update: `${command} update`,
240
365
  setup: `${command} setup`,
241
366
  rename: `${command} rename`,
242
367
  "agents:list": `${command} agents list`,
@@ -271,10 +396,13 @@ function lifecyclePackageSpec(packageSpec) {
271
396
  }
272
397
  return `create-academic-research@${packageSpec}`;
273
398
  }
274
- async function writeGeneratedGitignore(root) {
399
+ async function writeGeneratedGitignore(root, options = {}) {
275
400
  const source = join(root, "_gitignore");
276
401
  if (await exists(source)) {
277
- await writeFile(join(root, ".gitignore"), await readFile(source, "utf8"), "utf8");
402
+ const target = join(root, ".gitignore");
403
+ if (options.overwrite !== false || !(await exists(target))) {
404
+ await writeFile(target, await readFile(source, "utf8"), "utf8");
405
+ }
278
406
  await rm(source);
279
407
  }
280
408
  }
@@ -285,9 +413,179 @@ async function currentPackageVersion() {
285
413
  }
286
414
  return packageJson.version;
287
415
  }
416
+ async function readProjectConfig(root) {
417
+ return YAML.parse(await readFile(join(root, "configs/default.yaml"), "utf8"));
418
+ }
419
+ async function updateManagedCapabilityFiles(root, options) {
420
+ const state = await readCapabilities(root);
421
+ await stageTextWrite(root, "docs/agent/capability-profile.md", renderCapabilityProfile(state), options);
422
+ await stageTextWrite(root, "docs/agent/mcp-setup.md", renderMcpSetup(state), options);
423
+ const snippet = renderMcpSnippet(state);
424
+ await stageTextWrite(root, join("docs/agent/generated", snippet.fileName), snippet.content, options);
425
+ }
426
+ async function stageTextWrite(root, relativePath, content, options) {
427
+ const path = join(root, relativePath);
428
+ let current;
429
+ try {
430
+ current = await readFile(path, "utf8");
431
+ }
432
+ catch (error) {
433
+ if (!isMissingFileError(error))
434
+ throw error;
435
+ }
436
+ if (current === content)
437
+ return;
438
+ options.changes.push({ path: toPosix(relativePath), action: current === undefined ? "create" : "update" });
439
+ if (!options.apply)
440
+ return;
441
+ await mkdir(dirname(path), { recursive: true });
442
+ await writeFile(path, content, "utf8");
443
+ }
444
+ async function copyDirectoryMissing(source, target) {
445
+ const created = new Set();
446
+ async function copyChildren(sourceDir, targetDir) {
447
+ await mkdir(targetDir, { recursive: true });
448
+ const entries = await readdir(sourceDir, { withFileTypes: true });
449
+ for (const entry of entries) {
450
+ if (entry.name === "node_modules" || entry.name === "__pycache__")
451
+ continue;
452
+ const sourcePath = join(sourceDir, entry.name);
453
+ const targetPath = join(targetDir, entry.name);
454
+ if (entry.isDirectory()) {
455
+ await copyChildren(sourcePath, targetPath);
456
+ continue;
457
+ }
458
+ if (await exists(targetPath))
459
+ continue;
460
+ await mkdir(dirname(targetPath), { recursive: true });
461
+ await copyFile(sourcePath, targetPath);
462
+ created.add(toPosix(relative(target, targetPath)));
463
+ }
464
+ }
465
+ await copyChildren(source, target);
466
+ return created;
467
+ }
288
468
  async function writeAgentStack(root) {
289
469
  await writeFile(join(root, "configs/agent-stack.yaml"), YAML.stringify(AGENT_STACK), "utf8");
290
470
  }
471
+ async function validatePackageContract(root, errors, warnings) {
472
+ const path = join(root, "package.json");
473
+ if (!(await exists(path)))
474
+ return;
475
+ let data;
476
+ try {
477
+ data = await readJson(path);
478
+ }
479
+ catch (error) {
480
+ errors.push(`invalid package.json: ${error instanceof Error ? error.message : String(error)}`);
481
+ return;
482
+ }
483
+ const packageSpec = data.devDependencies?.["create-academic-research"] ?? (await currentPackageVersion());
484
+ const expectedScripts = generatedLifecycleScripts(packageSpec);
485
+ for (const [name, expected] of Object.entries(expectedScripts)) {
486
+ const actual = data.scripts?.[name];
487
+ if (!actual) {
488
+ warnings.push(`package.json missing lifecycle script: ${name}`);
489
+ continue;
490
+ }
491
+ if (isStaleLifecycleCommand(actual)) {
492
+ errors.push(`package.json script ${name} uses stale academic-research invocation`);
493
+ continue;
494
+ }
495
+ if (actual !== expected) {
496
+ warnings.push(`package.json script ${name} differs from the current managed command`);
497
+ }
498
+ }
499
+ const current = await currentPackageVersion();
500
+ if (isOlderSimpleVersion(packageSpec, current)) {
501
+ warnings.push(`create-academic-research ${packageSpec} is older than ${current}; run npm run update -- --apply`);
502
+ }
503
+ }
504
+ async function validateManagedTextDrift(root, warnings) {
505
+ const expectedEnv = formatMcpDotenv(Object.keys(AGENT_STACK.mcp_servers));
506
+ await warnIfTextDrift(root, ".env.example", expectedEnv, ".env.example is not current", warnings);
507
+ await warnIfTextDrift(root, "configs/agent-stack.yaml", YAML.stringify(AGENT_STACK), "configs/agent-stack.yaml is not current", warnings);
508
+ }
509
+ async function validateManagedCapabilityDrift(root, state, warnings) {
510
+ await warnIfTextDrift(root, "docs/agent/capability-profile.md", renderCapabilityProfile(state), "docs/agent/capability-profile.md is not current", warnings);
511
+ await warnIfTextDrift(root, "docs/agent/mcp-setup.md", renderMcpSetup(state), "docs/agent/mcp-setup.md is not current", warnings);
512
+ const snippet = renderMcpSnippet(state);
513
+ await warnIfTextDrift(root, join("docs/agent/generated", snippet.fileName), snippet.content, `docs/agent/generated/${snippet.fileName} is not current`, warnings);
514
+ }
515
+ async function warnIfTextDrift(root, relativePath, expected, warning, warnings) {
516
+ try {
517
+ const actual = await readFile(join(root, relativePath), "utf8");
518
+ if (actual !== expected)
519
+ warnings.push(warning);
520
+ }
521
+ catch (error) {
522
+ if (!isMissingFileError(error))
523
+ throw error;
524
+ }
525
+ }
526
+ async function validateStaleCommandReferences(root, warnings) {
527
+ const docs = [
528
+ "README.md",
529
+ "docs/getting-started.md",
530
+ "docs/agent/capability-profile.md",
531
+ "docs/agent/mcp-client-setup.md",
532
+ "docs/agent/mcp-setup.md",
533
+ "scripts/README.md"
534
+ ];
535
+ for (const relativePath of docs) {
536
+ try {
537
+ const text = await readFile(join(root, relativePath), "utf8");
538
+ if (containsStaleCommandReference(text)) {
539
+ warnings.push(`stale command reference in ${relativePath}; prefer project npm scripts`);
540
+ }
541
+ }
542
+ catch (error) {
543
+ if (!isMissingFileError(error))
544
+ throw error;
545
+ }
546
+ }
547
+ }
548
+ function containsStaleCommandReference(text) {
549
+ return (/\bnpx\s+academic-research\b/.test(text) ||
550
+ /(^|[`(>\s])academic-research\s+(doctor|setup|rename|agents|skills|mcp)\b/.test(text));
551
+ }
552
+ function isStaleLifecycleCommand(command) {
553
+ return /^academic-research\s+/.test(command) || /\bnpx\s+academic-research\b/.test(command);
554
+ }
555
+ function isOlderSimpleVersion(left, right) {
556
+ const leftParts = parseSimpleVersion(left);
557
+ const rightParts = parseSimpleVersion(right);
558
+ if (!leftParts || !rightParts)
559
+ return false;
560
+ for (let index = 0; index < rightParts.length; index += 1) {
561
+ if (leftParts[index] < rightParts[index])
562
+ return true;
563
+ if (leftParts[index] > rightParts[index])
564
+ return false;
565
+ }
566
+ return false;
567
+ }
568
+ function parseSimpleVersion(value) {
569
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(value);
570
+ if (!match)
571
+ return undefined;
572
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
573
+ }
574
+ function assertKnownPreset(preset) {
575
+ if (!AGENT_STACK.presets[preset]) {
576
+ throw new Error(`unknown capability preset: ${preset}. Expected one of: ${Object.keys(AGENT_STACK.presets).join(", ")}`);
577
+ }
578
+ return preset;
579
+ }
580
+ function isMissingFileError(error) {
581
+ return (typeof error === "object" &&
582
+ error !== null &&
583
+ "code" in error &&
584
+ error.code === "ENOENT");
585
+ }
586
+ function toPosix(value) {
587
+ return value.split(/[\\/]/).join("/");
588
+ }
291
589
  async function validateCsvHeader(root, relative, requiredColumns, errors) {
292
590
  const path = join(root, relative);
293
591
  if (!(await exists(path)))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-academic-research",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
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",
@@ -31,6 +31,7 @@ source .venv/bin/activate
31
31
  python -m pip install --upgrade pip
32
32
  python -m pip install -e ".[dev]"
33
33
  npm run doctor
34
+ npm run update
34
35
  ```
35
36
 
36
37
  ## Core Folders
@@ -60,6 +61,7 @@ npm run skills:install -- --preset enhanced
60
61
  npm run skills:install -- source-ingestion sota-literature-review
61
62
  npm run skills:list
62
63
  npm run skills:status
64
+ npm run update
63
65
  npm run setup
64
66
  npm run mcp:dotenv
65
67
  npm run mcp:list
@@ -7,12 +7,14 @@ Use this path for the first working session in a new research repository.
7
7
  ```bash
8
8
  npm install
9
9
  npm run doctor
10
+ npm run update
10
11
  npm run setup
11
12
  ```
12
13
 
13
- `doctor` checks required files and structural contracts. `setup` prints the
14
- active skill preset, installed skill count, enabled MCP records, and next
15
- commands without changing files.
14
+ `doctor` checks required files and structural contracts. `update` is a dry-run
15
+ unless you pass `-- --apply`. `setup` prints the active skill preset,
16
+ installed skill count, enabled MCP records, and next commands without changing
17
+ files.
16
18
 
17
19
  ## 2. Install Project-Local Skills
18
20
 
@@ -4,6 +4,7 @@
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "doctor": "npm exec --yes --package=create-academic-research@latest -- academic-research doctor",
7
+ "update": "npm exec --yes --package=create-academic-research@latest -- academic-research update",
7
8
  "setup": "npm exec --yes --package=create-academic-research@latest -- academic-research setup",
8
9
  "rename": "npm exec --yes --package=create-academic-research@latest -- academic-research rename",
9
10
  "agents:list": "npm exec --yes --package=create-academic-research@latest -- academic-research agents list",
@@ -29,6 +30,6 @@
29
30
  "mcp:probe": "npm exec --yes --package=create-academic-research@latest -- academic-research mcp probe"
30
31
  },
31
32
  "devDependencies": {
32
- "create-academic-research": "0.1.13"
33
+ "create-academic-research": "0.1.14"
33
34
  }
34
35
  }