@contentrain/mcp 1.3.0 → 1.5.0

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.
Files changed (80) hide show
  1. package/README.md +5 -3
  2. package/dist/{annotations-Clhbw2lh.mjs → annotations-BFJbfSKe.mjs} +13 -1
  3. package/dist/{annotations-Clhbw2lh.mjs.map → annotations-BFJbfSKe.mjs.map} +1 -1
  4. package/dist/{apply-manager-D5ym5Jba.mjs → apply-manager-CDss8ZIr.mjs} +5 -5
  5. package/dist/{apply-manager-D5ym5Jba.mjs.map → apply-manager-CDss8ZIr.mjs.map} +1 -1
  6. package/dist/{branch-lifecycle-CFVpTpNT.mjs → branch-lifecycle-DjX3MXNG.mjs} +10 -7
  7. package/dist/{branch-lifecycle-CFVpTpNT.mjs.map → branch-lifecycle-DjX3MXNG.mjs.map} +1 -1
  8. package/dist/{config-BIl5Ybj0.mjs → config-Lw7vpK9n.mjs} +4 -2
  9. package/dist/config-Lw7vpK9n.mjs.map +1 -0
  10. package/dist/{context-J0OU9Nfp.mjs → context-DS40WOdw.mjs} +2 -2
  11. package/dist/{context-J0OU9Nfp.mjs.map → context-DS40WOdw.mjs.map} +1 -1
  12. package/dist/core/apply-manager.mjs +5 -5
  13. package/dist/core/config.d.mts.map +1 -1
  14. package/dist/core/config.mjs +1 -1
  15. package/dist/core/context.mjs +2 -2
  16. package/dist/core/doctor.mjs +3 -3
  17. package/dist/core/validator/index.d.mts.map +1 -1
  18. package/dist/core/validator/index.mjs +2 -2
  19. package/dist/{doctor-CgBoT6h1.mjs → doctor-Dxu_SzZy.mjs} +3 -3
  20. package/dist/{doctor-CgBoT6h1.mjs.map → doctor-Dxu_SzZy.mjs.map} +1 -1
  21. package/dist/git/branch-lifecycle.d.mts.map +1 -1
  22. package/dist/git/branch-lifecycle.mjs +2 -2
  23. package/dist/git/transaction.d.mts.map +1 -1
  24. package/dist/git/transaction.mjs +3 -3
  25. package/dist/index.mjs +10 -10
  26. package/dist/{local-DvP4Y4T-.mjs → local-BEl5L7p5.mjs} +4 -4
  27. package/dist/{local-DvP4Y4T-.mjs.map → local-BEl5L7p5.mjs.map} +1 -1
  28. package/dist/providers/github/index.d.mts +60 -3
  29. package/dist/providers/github/index.d.mts.map +1 -1
  30. package/dist/providers/github/index.mjs +85 -5
  31. package/dist/providers/github/index.mjs.map +1 -1
  32. package/dist/providers/local/index.mjs +4 -4
  33. package/dist/server/http/index.d.mts +63 -21
  34. package/dist/server/http/index.d.mts.map +1 -1
  35. package/dist/server/http/index.mjs +165 -54
  36. package/dist/server/http/index.mjs.map +1 -1
  37. package/dist/{server-B1OJrUQ9.mjs → server-BiiVUjgs.mjs} +254 -48
  38. package/dist/server-BiiVUjgs.mjs.map +1 -0
  39. package/dist/server.mjs +10 -10
  40. package/dist/testing/conformance.d.mts +59 -0
  41. package/dist/testing/conformance.d.mts.map +1 -0
  42. package/dist/testing/conformance.mjs +59 -0
  43. package/dist/testing/conformance.mjs.map +1 -0
  44. package/dist/tools/annotations.d.mts.map +1 -1
  45. package/dist/tools/annotations.mjs +1 -1
  46. package/dist/{transaction-DgkNgWSI.mjs → transaction-0D_edyh0.mjs} +48 -5
  47. package/dist/transaction-0D_edyh0.mjs.map +1 -0
  48. package/dist/{validator-B0nNRFeU.mjs → validator-BX2ou7V-.mjs} +46 -17
  49. package/dist/validator-BX2ou7V-.mjs.map +1 -0
  50. package/package.json +11 -6
  51. package/testing/conformance/01-new-collection-entry/expected/.contentrain/config.json +15 -0
  52. package/testing/conformance/01-new-collection-entry/expected/.contentrain/content/blog/blog/en.json +6 -0
  53. package/testing/conformance/01-new-collection-entry/expected/.contentrain/context.json +22 -0
  54. package/testing/conformance/01-new-collection-entry/expected/.contentrain/meta/blog/en.json +7 -0
  55. package/testing/conformance/01-new-collection-entry/expected/.contentrain/models/blog.json +16 -0
  56. package/testing/conformance/01-new-collection-entry/scenario.json +19 -0
  57. package/testing/conformance/01-new-collection-entry/setup/.contentrain/config.json +15 -0
  58. package/testing/conformance/01-new-collection-entry/setup/.contentrain/models/blog.json +16 -0
  59. package/testing/conformance/02-new-singleton/expected/.contentrain/config.json +15 -0
  60. package/testing/conformance/02-new-singleton/expected/.contentrain/content/marketing/hero/en.json +4 -0
  61. package/testing/conformance/02-new-singleton/expected/.contentrain/context.json +22 -0
  62. package/testing/conformance/02-new-singleton/expected/.contentrain/meta/hero/en.json +5 -0
  63. package/testing/conformance/02-new-singleton/expected/.contentrain/models/hero.json +16 -0
  64. package/testing/conformance/02-new-singleton/scenario.json +18 -0
  65. package/testing/conformance/02-new-singleton/setup/.contentrain/config.json +15 -0
  66. package/testing/conformance/02-new-singleton/setup/.contentrain/models/hero.json +16 -0
  67. package/testing/conformance/03-new-dictionary/expected/.contentrain/config.json +15 -0
  68. package/testing/conformance/03-new-dictionary/expected/.contentrain/content/marketing/ui-strings/en.json +5 -0
  69. package/testing/conformance/03-new-dictionary/expected/.contentrain/context.json +22 -0
  70. package/testing/conformance/03-new-dictionary/expected/.contentrain/meta/ui-strings/en.json +5 -0
  71. package/testing/conformance/03-new-dictionary/expected/.contentrain/models/ui-strings.json +7 -0
  72. package/testing/conformance/03-new-dictionary/scenario.json +19 -0
  73. package/testing/conformance/03-new-dictionary/setup/.contentrain/config.json +15 -0
  74. package/testing/conformance/03-new-dictionary/setup/.contentrain/models/ui-strings.json +7 -0
  75. package/testing/conformance/README.md +77 -0
  76. package/testing/conformance/scenarios.json +36 -0
  77. package/dist/config-BIl5Ybj0.mjs.map +0 -1
  78. package/dist/server-B1OJrUQ9.mjs.map +0 -1
  79. package/dist/transaction-DgkNgWSI.mjs.map +0 -1
  80. package/dist/validator-B0nNRFeU.mjs.map +0 -1
@@ -1,22 +1,22 @@
1
1
  import { i as planContentDelete, n as planModelDelete, r as planContentSave, t as planModelSave } from "./ops-DjEWi5Wa.mjs";
2
2
  import { n as contentFilePath, t as contentDirPath } from "./paths-CYl5vCBD.mjs";
3
3
  import { a as readJson, n as ensureDir, r as pathExists, s as writeJson, t as contentrainDir } from "./fs-DLbVB-Ek.mjs";
4
- import { n as readVocabulary, t as readConfig } from "./config-BIl5Ybj0.mjs";
4
+ import { n as readVocabulary, t as readConfig } from "./config-Lw7vpK9n.mjs";
5
5
  import { S as writeContent, _ as resolveMdFilePath, a as fieldDefZodSchema, c as validateModelDefinition, d as listContent, g as resolveLocaleStrategy, h as resolveJsonFilePath, l as writeModel, m as resolveContentDir, n as checkReferences, o as listModels, r as countEntries, s as readModel, u as deleteContent } from "./model-manager-BhLsUgaB.mjs";
6
6
  import { n as readMeta, r as writeMeta } from "./meta-manager-C900rEyG.mjs";
7
- import { n as readContext, t as buildContextChange } from "./context-J0OU9Nfp.mjs";
8
- import { i as mergeBranch, n as createTransaction, r as ensureContentBranch, t as buildBranchName } from "./transaction-DgkNgWSI.mjs";
9
- import { t as LocalProvider } from "./local-DvP4Y4T-.mjs";
7
+ import { n as readContext, t as buildContextChange } from "./context-DS40WOdw.mjs";
8
+ import { i as mergeBranch, n as createTransaction, r as ensureContentBranch, t as buildBranchName } from "./transaction-0D_edyh0.mjs";
9
+ import { n as isMerged, t as LocalProvider } from "./local-BEl5L7p5.mjs";
10
10
  import { t as detectStack } from "./detect-DL9mFmaS.mjs";
11
- import { n as checkBranchHealth, r as cleanupMergedBranches } from "./branch-lifecycle-CFVpTpNT.mjs";
12
- import { t as TOOL_ANNOTATIONS } from "./annotations-Clhbw2lh.mjs";
11
+ import { n as checkBranchHealth, r as cleanupMergedBranches } from "./branch-lifecycle-DjX3MXNG.mjs";
12
+ import { t as TOOL_ANNOTATIONS } from "./annotations-BFJbfSKe.mjs";
13
13
  import { n as listTemplates, t as getTemplate } from "./templates-VrLVqfe5.mjs";
14
14
  import { t as OverlayReader } from "./overlay-reader-DOdyDRIv.mjs";
15
- import { t as validateProject } from "./validator-B0nNRFeU.mjs";
15
+ import { t as validateProject } from "./validator-BX2ou7V-.mjs";
16
16
  import { t as buildGraph } from "./graph-builder-DXv2JEp-.mjs";
17
17
  import { n as scanSummary, t as scanCandidates } from "./scanner-DKMwc03k.mjs";
18
- import { n as applyExtract, r as applyReuse } from "./apply-manager-D5ym5Jba.mjs";
19
- import { t as runDoctor } from "./doctor-CgBoT6h1.mjs";
18
+ import { n as applyExtract, r as applyReuse } from "./apply-manager-CDss8ZIr.mjs";
19
+ import { t as runDoctor } from "./doctor-Dxu_SzZy.mjs";
20
20
  import { join } from "node:path";
21
21
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
22
22
  import { CONTENTRAIN_BRANCH } from "@contentrain/types";
@@ -42,6 +42,17 @@ function registerContextTools(server, provider, projectRoot) {
42
42
  const models = await listModels(provider);
43
43
  const context = projectRoot ? await readContext(projectRoot) : await readContext(provider);
44
44
  const vocabulary = await readVocabulary(provider);
45
+ let stats = context?.stats ?? null;
46
+ if (projectRoot) {
47
+ const fullModels = await Promise.all(models.map((m) => readModel(provider, m.id)));
48
+ const entryCounts = await Promise.all(fullModels.filter((m) => m !== null).map((m) => countEntries(provider, m)));
49
+ stats = {
50
+ models: models.length,
51
+ entries: entryCounts.reduce((acc, c) => acc + c.total, 0),
52
+ locales: config?.locales.supported ?? context?.stats?.locales ?? ["en"],
53
+ ...context?.stats?.lastSync ? { lastSync: context.stats.lastSync } : {}
54
+ };
55
+ }
45
56
  const errors = [];
46
57
  if (!config) errors.push(".contentrain/config.json missing");
47
58
  const result = {
@@ -54,9 +65,9 @@ function registerContextTools(server, provider, projectRoot) {
54
65
  repository: config.repository
55
66
  } : null,
56
67
  models,
57
- context: context ? {
58
- lastOperation: context.lastOperation,
59
- stats: context.stats
68
+ context: context || stats ? {
69
+ lastOperation: context?.lastOperation ?? null,
70
+ stats
60
71
  } : null,
61
72
  vocabulary: vocabulary && Object.keys(vocabulary.terms).length > 0 ? {
62
73
  size: Object.keys(vocabulary.terms).length,
@@ -362,6 +373,47 @@ function generateImportSnippet(model, stack, locale) {
362
373
  return snippets;
363
374
  }
364
375
  //#endregion
376
+ //#region src/git/errors.ts
377
+ /**
378
+ * Error normalization for git-backed MCP operations.
379
+ *
380
+ * `simple-git` surfaces a failed `git commit`/`push`/`merge` as a `GitError`
381
+ * whose `message` is the raw subprocess stderr — which, for hook tooling
382
+ * (commitlint, husky, lefthook), is colorized multi-line text. Returning that
383
+ * verbatim inside a JSON error produces an unreadable ANSI-escaped blob.
384
+ *
385
+ * This helper strips ANSI, preserves any structured fields the thrown error
386
+ * already carries (`code`/`agent_hint`/`developer_action`), and detects git
387
+ * hook rejections so the agent gets an actionable, structured envelope.
388
+ */
389
+ const ANSI_RE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
390
+ function stripAnsi(input) {
391
+ return input.replace(ANSI_RE, "");
392
+ }
393
+ /**
394
+ * Normalize any thrown error into a structured, ANSI-free envelope for MCP
395
+ * tool responses. `stage` labels where it happened (e.g. 'content_save').
396
+ */
397
+ function normalizeOperationError(err, stage) {
398
+ const e = err;
399
+ const message = stripAnsi(e?.message ?? String(err)).trim();
400
+ const out = { error: message };
401
+ if (stage) out.stage = stage;
402
+ if (e?.code) out.code = e.code;
403
+ if (e?.agent_hint) out.agent_hint = e.agent_hint;
404
+ if (e?.developer_action) out.developer_action = e.developer_action;
405
+ if (!out.code) {
406
+ const hookName = /\b(commit-msg|pre-commit|prepare-commit-msg|pre-push)\b/i.exec(message)?.[1];
407
+ const hookTool = /\b(commitlint|husky|lefthook)\b/i.exec(message)?.[1];
408
+ if (hookName || hookTool) {
409
+ out.stage = out.stage ?? "git-commit";
410
+ out.hook = hookName ?? hookTool;
411
+ out.agent_hint = out.agent_hint ?? "A git hook rejected the Contentrain commit. Contentrain commits with --no-verify, but a wrapping CLI or server-side hook may still run. Ask the developer to allow machine-generated \"[contentrain]\" commits or to exclude cr/* branches from the hook.";
412
+ }
413
+ }
414
+ return out;
415
+ }
416
+ //#endregion
365
417
  //#region src/tools/guards.ts
366
418
  /**
367
419
  * Emit a uniform "capability not available" response for tools that
@@ -413,7 +465,10 @@ function registerSetupTools(server, _provider, projectRoot) {
413
465
  "-n",
414
466
  "1",
415
467
  "--all"
416
- ]).then((out) => out.trim().length > 0).catch(() => false)) await git.commit("initial commit", { "--allow-empty": null });
468
+ ]).then((out) => out.trim().length > 0).catch(() => false)) await git.commit("initial commit", {
469
+ "--allow-empty": null,
470
+ "--no-verify": null
471
+ });
417
472
  const initHealth = await checkBranchHealth(projectRoot);
418
473
  if (initHealth.blocked) return {
419
474
  content: [{
@@ -513,7 +568,7 @@ function registerSetupTools(server, _provider, projectRoot) {
513
568
  return {
514
569
  content: [{
515
570
  type: "text",
516
- text: JSON.stringify({ error: `Init failed: ${error instanceof Error ? error.message : String(error)}` })
571
+ text: JSON.stringify({ ...normalizeOperationError(error, "init") })
517
572
  }],
518
573
  isError: true
519
574
  };
@@ -608,7 +663,7 @@ function registerSetupTools(server, _provider, projectRoot) {
608
663
  return {
609
664
  content: [{
610
665
  type: "text",
611
- text: JSON.stringify({ error: `Scaffold failed: ${error instanceof Error ? error.message : String(error)}` })
666
+ text: JSON.stringify({ ...normalizeOperationError(error, "scaffold") })
612
667
  }],
613
668
  isError: true
614
669
  };
@@ -836,7 +891,7 @@ function registerModelTools(server, provider, projectRoot) {
836
891
  return {
837
892
  content: [{
838
893
  type: "text",
839
- text: JSON.stringify({ error: `Model save failed: ${error instanceof Error ? error.message : String(error)}` })
894
+ text: JSON.stringify({ ...normalizeOperationError(error, "model_save") })
840
895
  }],
841
896
  isError: true
842
897
  };
@@ -959,7 +1014,7 @@ function registerModelTools(server, provider, projectRoot) {
959
1014
  return {
960
1015
  content: [{
961
1016
  type: "text",
962
- text: JSON.stringify({ error: `Delete failed: ${error instanceof Error ? error.message : String(error)}` })
1017
+ text: JSON.stringify({ ...normalizeOperationError(error, "model_delete") })
963
1018
  }],
964
1019
  isError: true
965
1020
  };
@@ -1064,7 +1119,7 @@ function registerContentTools(server, provider, projectRoot) {
1064
1119
  return {
1065
1120
  content: [{
1066
1121
  type: "text",
1067
- text: JSON.stringify({ error: `Content save failed: ${error instanceof Error ? error.message : String(error)}` })
1122
+ text: JSON.stringify({ ...normalizeOperationError(error, "content_save") })
1068
1123
  }],
1069
1124
  isError: true
1070
1125
  };
@@ -1095,12 +1150,12 @@ function registerContentTools(server, provider, projectRoot) {
1095
1150
  return {
1096
1151
  content: [{
1097
1152
  type: "text",
1098
- text: JSON.stringify({ error: `Content save failed: ${error instanceof Error ? error.message : String(error)}` })
1153
+ text: JSON.stringify({ ...normalizeOperationError(error, "content_save") })
1099
1154
  }],
1100
1155
  isError: true
1101
1156
  };
1102
1157
  }
1103
- const validationResult = projectRoot ? await validateProject(projectRoot, { model: input.model }) : await validateProject(new OverlayReader(provider, plan.changes), { model: input.model });
1158
+ const validationResult = await validateProject(new OverlayReader(provider, plan.changes), { model: input.model });
1104
1159
  const allAdvisories = plan.result.flatMap((r) => r.advisories ?? []);
1105
1160
  return { content: [{
1106
1161
  type: "text",
@@ -1181,7 +1236,7 @@ function registerContentTools(server, provider, projectRoot) {
1181
1236
  return {
1182
1237
  content: [{
1183
1238
  type: "text",
1184
- text: JSON.stringify({ error: `Delete failed: ${error instanceof Error ? error.message : String(error)}` })
1239
+ text: JSON.stringify({ ...normalizeOperationError(error, "content_delete") })
1185
1240
  }],
1186
1241
  isError: true
1187
1242
  };
@@ -1226,7 +1281,7 @@ function registerContentTools(server, provider, projectRoot) {
1226
1281
  return {
1227
1282
  content: [{
1228
1283
  type: "text",
1229
- text: JSON.stringify({ error: `Delete failed: ${error instanceof Error ? error.message : String(error)}` })
1284
+ text: JSON.stringify({ ...normalizeOperationError(error, "content_delete") })
1230
1285
  }],
1231
1286
  isError: true
1232
1287
  };
@@ -1273,7 +1328,7 @@ function registerContentTools(server, provider, projectRoot) {
1273
1328
  return {
1274
1329
  content: [{
1275
1330
  type: "text",
1276
- text: JSON.stringify({ error: `List failed: ${error instanceof Error ? error.message : String(error)}` })
1331
+ text: JSON.stringify({ ...normalizeOperationError(error, "content_list") })
1277
1332
  }],
1278
1333
  isError: true
1279
1334
  };
@@ -1282,6 +1337,49 @@ function registerContentTools(server, provider, projectRoot) {
1282
1337
  }
1283
1338
  //#endregion
1284
1339
  //#region src/tools/workflow.ts
1340
+ /**
1341
+ * Resolve a merge target from either an exact branch name or a
1342
+ * model/locale/latest selector. Returns the concrete branch, or an error with
1343
+ * candidate branches when the selector is ambiguous or matches nothing.
1344
+ */
1345
+ async function resolveMergeBranch(git, input) {
1346
+ const summary = await git.branchLocal();
1347
+ const crBranches = summary.all.filter((b) => b.startsWith("cr/") && b !== CONTENTRAIN_BRANCH);
1348
+ if (input.branch) {
1349
+ if (summary.all.includes(input.branch)) return { branch: input.branch };
1350
+ return {
1351
+ error: `Branch "${input.branch}" not found locally.`,
1352
+ candidates: crBranches
1353
+ };
1354
+ }
1355
+ if (!input.model) return {
1356
+ error: "Provide either \"branch\" (exact name) or \"model\" (to resolve the branch).",
1357
+ candidates: crBranches
1358
+ };
1359
+ const prefix = input.locale ? `cr/content/${input.model}/${input.locale}/` : `cr/content/${input.model}/`;
1360
+ let matches = crBranches.filter((b) => b.startsWith(prefix));
1361
+ if (matches.length === 0) matches = crBranches.filter((b) => b.split("/").includes(input.model));
1362
+ if (matches.length === 0) return {
1363
+ error: `No pending cr/* branch found for model "${input.model}".`,
1364
+ candidates: crBranches
1365
+ };
1366
+ if (matches.length === 1) return { branch: matches[0] };
1367
+ if (!input.latest) return {
1368
+ error: `Multiple branches match model "${input.model}". Pass latest:true or an exact branch.`,
1369
+ candidates: matches
1370
+ };
1371
+ return { branch: (await Promise.all(matches.map(async (b) => {
1372
+ return {
1373
+ b,
1374
+ t: await git.raw([
1375
+ "log",
1376
+ "-1",
1377
+ "--format=%ct",
1378
+ b
1379
+ ]).then((s) => Number(s.trim())).catch(() => 0)
1380
+ };
1381
+ }))).toSorted((left, right) => right.t - left.t)[0].b };
1382
+ }
1285
1383
  function registerWorkflowTools(server, provider, projectRoot) {
1286
1384
  server.tool("contentrain_validate", "Validate project content against model schemas. Detects required field violations, type mismatches, broken relations, secret leaks, i18n parity issues, and more. If fix:true, auto-fixes structural issues (canonical sort, orphan meta, missing locale files) — do NOT manually edit .contentrain/ files.", {
1287
1385
  model: z.string().optional().describe("Model ID to validate (omit for all models)"),
@@ -1311,7 +1409,7 @@ function registerWorkflowTools(server, provider, projectRoot) {
1311
1409
  isError: true
1312
1410
  };
1313
1411
  const branch = buildBranchName("fix", "validate");
1314
- const tx = await createTransaction(projectRoot, branch);
1412
+ const tx = await createTransaction(projectRoot, branch, { workflowOverride: "auto-merge" });
1315
1413
  try {
1316
1414
  await tx.write(async (wt) => {
1317
1415
  result = await validateProject(wt, {
@@ -1404,16 +1502,22 @@ function registerWorkflowTools(server, provider, projectRoot) {
1404
1502
  } catch {
1405
1503
  hasRemote = false;
1406
1504
  }
1407
- if (!hasRemote) return {
1408
- content: [{
1409
- type: "text",
1410
- text: JSON.stringify({
1411
- error: `No remote "${remoteName}" found. Configure a git remote first.`,
1412
- next_steps: [`git remote add ${remoteName} <url>`]
1413
- })
1414
- }],
1415
- isError: true
1416
- };
1505
+ if (!hasRemote) {
1506
+ const pending = (await git.branchLocal().catch(() => ({ all: [] }))).all.filter((b) => b.startsWith("cr/") && b !== CONTENTRAIN_BRANCH);
1507
+ return {
1508
+ content: [{
1509
+ type: "text",
1510
+ text: JSON.stringify({
1511
+ error: `No remote "${remoteName}" configured — contentrain_submit (push) is unavailable.`,
1512
+ stage: "submit",
1513
+ agent_hint: "This project has no git remote. In a local/solo workflow, land pending review branches with contentrain_merge instead of submit, or set workflow:\"auto-merge\" in config.json so saves merge locally without a remote.",
1514
+ pending_branches: pending,
1515
+ next_steps: [pending.length > 0 ? `Merge a pending branch locally: contentrain_merge { branch: "${pending[0]}", confirm: true }` : "Make changes with contentrain_content_save — auto-merge lands them locally without a remote", `Or add a remote to enable submit: git remote add ${remoteName} <url>`]
1516
+ }, null, 2)
1517
+ }],
1518
+ isError: true
1519
+ };
1520
+ }
1417
1521
  try {
1418
1522
  const baseBranch = config.repository?.default_branch ?? process.env["CONTENTRAIN_BRANCH"] ?? ((await git.raw(["branch", "--show-current"])).trim() || "main");
1419
1523
  const branchSummary = await git.branchLocal();
@@ -1496,8 +1600,11 @@ function registerWorkflowTools(server, provider, projectRoot) {
1496
1600
  };
1497
1601
  }
1498
1602
  });
1499
- server.tool("contentrain_merge", "Merge a review-mode branch into contentrain. Local git operation — no external platform needed. Merges the feature branch into the contentrain branch, advances the base branch via update-ref, and selectively syncs .contentrain/ files to the working tree.", {
1500
- branch: z.string().describe("Branch name to merge (e.g. cr/normalize/extract/...)"),
1603
+ server.tool("contentrain_merge", "Merge a review-mode branch into contentrain. Local git operation — no external platform needed. Merges the feature branch into the contentrain branch, advances the base branch via update-ref, selectively syncs .contentrain/ files to the working tree, and prunes the merged branch. Target by exact \"branch\" name, or resolve by \"model\" (+ optional \"locale\"/\"latest\").", {
1604
+ branch: z.string().optional().describe("Exact branch name to merge (e.g. cr/content/blog-post/...). Omit to resolve by model."),
1605
+ model: z.string().optional().describe("Resolve the branch by model id (e.g. \"blog-post\")."),
1606
+ locale: z.string().optional().describe("Narrow model resolution to a locale."),
1607
+ latest: z.boolean().optional().describe("When multiple branches match the model, merge the most recently committed one."),
1501
1608
  confirm: z.literal(true).describe("Must be true to confirm the merge")
1502
1609
  }, TOOL_ANNOTATIONS["contentrain_merge"], async (input) => {
1503
1610
  if (!provider.capabilities.localWorktree || !projectRoot) return capabilityError("contentrain_merge", "localWorktree");
@@ -1509,26 +1616,30 @@ function registerWorkflowTools(server, provider, projectRoot) {
1509
1616
  isError: true
1510
1617
  };
1511
1618
  try {
1512
- if (!(await simpleGit(projectRoot).branchLocal()).all.includes(input.branch)) return {
1619
+ const resolved = await resolveMergeBranch(simpleGit(projectRoot), input);
1620
+ if ("error" in resolved) return {
1513
1621
  content: [{
1514
1622
  type: "text",
1515
1623
  text: JSON.stringify({
1516
- error: `Branch "${input.branch}" not found locally.`,
1517
- next_steps: ["Check branch name with git branch -l", "Use contentrain_submit to push first if needed"]
1518
- })
1624
+ error: resolved.error,
1625
+ candidates: resolved.candidates,
1626
+ next_steps: ["Pass an exact { branch } or { model, latest: true }", "List pending branches with contentrain_branch_list"]
1627
+ }, null, 2)
1519
1628
  }],
1520
1629
  isError: true
1521
1630
  };
1522
- const result = await mergeBranch(projectRoot, input.branch);
1631
+ const targetBranch = resolved.branch;
1632
+ const result = await mergeBranch(projectRoot, targetBranch);
1523
1633
  return { content: [{
1524
1634
  type: "text",
1525
1635
  text: JSON.stringify({
1526
1636
  status: "merged",
1637
+ branch: targetBranch,
1527
1638
  action: result.action,
1528
1639
  commit: result.commit,
1529
1640
  sync: result.sync,
1530
1641
  next_steps: [
1531
- "Run `npx contentrain generate` to update SDK client",
1642
+ "Run `contentrain generate` (or `npx contentrain-query generate`) to update the SDK client",
1532
1643
  "Run contentrain_validate to verify content integrity",
1533
1644
  result.sync.skipped.length > 0 ? `${result.sync.skipped.length} file(s) skipped due to local changes — resolve manually` : ""
1534
1645
  ].filter(Boolean)
@@ -1538,7 +1649,102 @@ function registerWorkflowTools(server, provider, projectRoot) {
1538
1649
  return {
1539
1650
  content: [{
1540
1651
  type: "text",
1541
- text: JSON.stringify({ error: `Merge failed: ${error instanceof Error ? error.message : String(error)}` })
1652
+ text: JSON.stringify(normalizeOperationError(error, "merge"), null, 2)
1653
+ }],
1654
+ isError: true
1655
+ };
1656
+ }
1657
+ });
1658
+ server.tool("contentrain_branch_list", "List pending contentrain (cr/*) branches with their merge status against the contentrain branch. Use this to discover branch names for contentrain_merge / contentrain_branch_delete, and to monitor branch-health limits (warning at 50, blocked at 80 unmerged).", { unmerged_only: z.boolean().optional().describe("Only list branches not yet merged into contentrain. Default: false") }, TOOL_ANNOTATIONS["contentrain_branch_list"], async (input) => {
1659
+ if (!provider.capabilities.localWorktree || !projectRoot) return capabilityError("contentrain_branch_list", "localWorktree");
1660
+ try {
1661
+ const git = simpleGit(projectRoot);
1662
+ const summary = await git.branchLocal();
1663
+ const crBranches = summary.all.filter((b) => b.startsWith("cr/") && b !== CONTENTRAIN_BRANCH);
1664
+ const ordered = (await Promise.all(crBranches.map(async (name) => {
1665
+ const [merged, ts] = await Promise.all([isMerged(projectRoot, name, CONTENTRAIN_BRANCH), git.raw([
1666
+ "log",
1667
+ "-1",
1668
+ "--format=%cI",
1669
+ name
1670
+ ]).then((s) => s.trim()).catch(() => "")]);
1671
+ return {
1672
+ name,
1673
+ sha: summary.branches[name]?.commit ?? "",
1674
+ merged,
1675
+ lastCommit: ts
1676
+ };
1677
+ }))).toSorted((left, right) => right.lastCommit.localeCompare(left.lastCommit));
1678
+ const filtered = input.unmerged_only ? ordered.filter((b) => !b.merged) : ordered;
1679
+ const health = await checkBranchHealth(projectRoot);
1680
+ return { content: [{
1681
+ type: "text",
1682
+ text: JSON.stringify({
1683
+ total: ordered.length,
1684
+ unmerged: ordered.filter((b) => !b.merged).length,
1685
+ branches: filtered,
1686
+ health: {
1687
+ warning: health.warning,
1688
+ blocked: health.blocked,
1689
+ message: health.message
1690
+ },
1691
+ next_steps: ["Merge a branch: contentrain_merge { branch: \"...\", confirm: true }", "Delete a stale branch: contentrain_branch_delete { branch: \"...\", confirm: true }"]
1692
+ }, null, 2)
1693
+ }] };
1694
+ } catch (error) {
1695
+ return {
1696
+ content: [{
1697
+ type: "text",
1698
+ text: JSON.stringify(normalizeOperationError(error, "branch_list"), null, 2)
1699
+ }],
1700
+ isError: true
1701
+ };
1702
+ }
1703
+ });
1704
+ server.tool("contentrain_branch_delete", "Delete a pending contentrain (cr/*) branch that will not be merged — e.g. a branch left behind by a failed operation, or a superseded draft. Only cr/* branches can be deleted; the contentrain branch is protected. This is destructive: the branch and its unmerged commits are removed.", {
1705
+ branch: z.string().describe("The cr/* branch to delete"),
1706
+ confirm: z.literal(true).describe("Must be true to confirm deletion")
1707
+ }, TOOL_ANNOTATIONS["contentrain_branch_delete"], async (input) => {
1708
+ if (!provider.capabilities.localWorktree || !projectRoot) return capabilityError("contentrain_branch_delete", "localWorktree");
1709
+ if (!input.branch.startsWith("cr/") || input.branch === CONTENTRAIN_BRANCH) return {
1710
+ content: [{
1711
+ type: "text",
1712
+ text: JSON.stringify({ error: `Refusing to delete "${input.branch}". Only cr/* feature branches can be deleted (the contentrain branch is protected).` })
1713
+ }],
1714
+ isError: true
1715
+ };
1716
+ try {
1717
+ const git = simpleGit(projectRoot);
1718
+ if (!(await git.branchLocal()).all.includes(input.branch)) return {
1719
+ content: [{
1720
+ type: "text",
1721
+ text: JSON.stringify({
1722
+ error: `Branch "${input.branch}" not found locally.`,
1723
+ next_steps: ["List branches with contentrain_branch_list"]
1724
+ })
1725
+ }],
1726
+ isError: true
1727
+ };
1728
+ const merged = await isMerged(projectRoot, input.branch, CONTENTRAIN_BRANCH);
1729
+ await git.raw([
1730
+ "branch",
1731
+ "-D",
1732
+ input.branch
1733
+ ]);
1734
+ return { content: [{
1735
+ type: "text",
1736
+ text: JSON.stringify({
1737
+ status: "deleted",
1738
+ branch: input.branch,
1739
+ was_merged: merged,
1740
+ warning: merged ? void 0 : "Branch was not merged — its commits were discarded."
1741
+ }, null, 2)
1742
+ }] };
1743
+ } catch (error) {
1744
+ return {
1745
+ content: [{
1746
+ type: "text",
1747
+ text: JSON.stringify(normalizeOperationError(error, "branch_delete"), null, 2)
1542
1748
  }],
1543
1749
  isError: true
1544
1750
  };
@@ -1938,7 +2144,7 @@ function registerBulkTools(server, _provider, projectRoot) {
1938
2144
  return {
1939
2145
  content: [{
1940
2146
  type: "text",
1941
- text: JSON.stringify({ error: `copy_locale failed: ${error instanceof Error ? error.message : String(error)}` })
2147
+ text: JSON.stringify({ ...normalizeOperationError(error, "bulk_copy_locale") })
1942
2148
  }],
1943
2149
  isError: true
1944
2150
  };
@@ -2022,7 +2228,7 @@ function registerBulkTools(server, _provider, projectRoot) {
2022
2228
  return {
2023
2229
  content: [{
2024
2230
  type: "text",
2025
- text: JSON.stringify({ error: `update_status failed: ${error instanceof Error ? error.message : String(error)}` })
2231
+ text: JSON.stringify({ ...normalizeOperationError(error, "bulk_update_status") })
2026
2232
  }],
2027
2233
  isError: true
2028
2234
  };
@@ -2090,7 +2296,7 @@ function registerBulkTools(server, _provider, projectRoot) {
2090
2296
  return {
2091
2297
  content: [{
2092
2298
  type: "text",
2093
- text: JSON.stringify({ error: `delete_entries failed: ${error instanceof Error ? error.message : String(error)}` })
2299
+ text: JSON.stringify({ ...normalizeOperationError(error, "bulk_delete_entries") })
2094
2300
  }],
2095
2301
  isError: true
2096
2302
  };
@@ -2122,7 +2328,7 @@ function registerDoctorTools(server, _provider, projectRoot) {
2122
2328
  }
2123
2329
  //#endregion
2124
2330
  //#region package.json
2125
- var version = "1.3.0";
2331
+ var version = "1.5.0";
2126
2332
  //#endregion
2127
2333
  //#region src/server.ts
2128
2334
  function createServer(input) {
@@ -2163,4 +2369,4 @@ function resolveServerContext(input) {
2163
2369
  //#endregion
2164
2370
  export { createServer as t };
2165
2371
 
2166
- //# sourceMappingURL=server-B1OJrUQ9.mjs.map
2372
+ //# sourceMappingURL=server-BiiVUjgs.mjs.map