@contentrain/mcp 1.1.2 → 1.2.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 (34) hide show
  1. package/README.md +29 -20
  2. package/dist/{apply-manager-Cf5OWOJ7.mjs → apply-manager-BkPMfpsN.mjs} +8 -3
  3. package/dist/apply-manager-BkPMfpsN.mjs.map +1 -0
  4. package/dist/{astro-parser-RofKmKQi.mjs → astro-parser-BJ-54JGe.mjs} +3 -3
  5. package/dist/{astro-parser-RofKmKQi.mjs.map → astro-parser-BJ-54JGe.mjs.map} +1 -1
  6. package/dist/core/apply-manager.d.mts.map +1 -1
  7. package/dist/core/apply-manager.mjs +2 -2
  8. package/dist/core/scanner.d.mts +3 -9
  9. package/dist/core/scanner.d.mts.map +1 -1
  10. package/dist/core/scanner.mjs +3 -3
  11. package/dist/git/transaction.d.mts +6 -1
  12. package/dist/git/transaction.d.mts.map +1 -1
  13. package/dist/git/transaction.mjs +2 -2
  14. package/dist/index.mjs +5 -5
  15. package/dist/{scanner-DPrbU-oK.mjs → scanner-CQOxARUU.mjs} +275 -308
  16. package/dist/scanner-CQOxARUU.mjs.map +1 -0
  17. package/dist/{server-CjK63H6X.mjs → server-DfWRpzdP.mjs} +172 -20
  18. package/dist/server-DfWRpzdP.mjs.map +1 -0
  19. package/dist/server.mjs +5 -5
  20. package/dist/{svelte-parser-COTNG92w.mjs → svelte-parser-1bBcMx3T.mjs} +16 -6
  21. package/dist/{svelte-parser-COTNG92w.mjs.map → svelte-parser-1bBcMx3T.mjs.map} +1 -1
  22. package/dist/{transaction-B0V0_SE2.mjs → transaction-C_Pvux78.mjs} +102 -2
  23. package/dist/{transaction-B0V0_SE2.mjs.map → transaction-C_Pvux78.mjs.map} +1 -1
  24. package/dist/tsx-parser-BFLv292Y.mjs +2 -0
  25. package/dist/{tsx-parser-CYVyBWjn.mjs → tsx-parser-BWiMGAIR.mjs} +3 -2
  26. package/dist/tsx-parser-BWiMGAIR.mjs.map +1 -0
  27. package/dist/{vue-parser-CjS4YAoQ.mjs → vue-parser-DTPTN3Yb.mjs} +16 -5
  28. package/dist/{vue-parser-CjS4YAoQ.mjs.map → vue-parser-DTPTN3Yb.mjs.map} +1 -1
  29. package/package.json +2 -2
  30. package/dist/apply-manager-Cf5OWOJ7.mjs.map +0 -1
  31. package/dist/scanner-DPrbU-oK.mjs.map +0 -1
  32. package/dist/server-CjK63H6X.mjs.map +0 -1
  33. package/dist/tsx-parser-Bf8Firrq.mjs +0 -2
  34. package/dist/tsx-parser-CYVyBWjn.mjs.map +0 -1
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scanner-CQOxARUU.mjs","names":[],"sources":["../src/core/ast-scanner/pre-filter.ts","../src/core/ast-scanner/index.ts","../src/core/scanner.ts"],"sourcesContent":["import type { ExtractedString } from './types.js'\n\n// ─── Pre-filter Result ───\n\nexport interface PreFilterResult {\n /** Strings that passed the pre-filter (candidates for agent) */\n candidates: ExtractedString[]\n /** Total number of strings removed by shouldSkip */\n skipped: number\n /** Total number of strings removed by low content score */\n lowConfidence: number\n /** Breakdown: skip reason → count */\n skipReasons: Record<string, number>\n}\n\n// ─── Value-based regexes ───\n\nconst PURE_NUMBER_RE = /^-?\\d+(\\.\\d+)?$/\nconst HEX_COLOR_RE = /^#[0-9a-f]{3,8}$/i\nconst FILE_EXT_RE = /\\.(png|jpg|jpeg|gif|svg|webp|ico|css|scss|less|js|ts|tsx|jsx|json|md|html|xml|yaml|yml|woff|woff2|ttf|eot|mp4|webm|mp3|wav|pdf)$/i\nconst SVG_PATH_DATA_RE = /^[Mm][\\d\\s.,LHVCSQTAZlhvcsqtazmMzZ-]+$/\nconst SVG_VIEWBOX_RE = /^\\d+(\\.\\d+)?\\s+\\d+(\\.\\d+)?\\s+\\d+(\\.\\d+)?\\s+\\d+(\\.\\d+)?$/\nconst I18N_KEY_RE = /^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)+$/\nconst TECHNICAL_IDENTIFIER_RE = /^[a-z][a-z0-9_-]*$/\nconst ERROR_CODE_RE = /^[A-Z][A-Z0-9_]+$/\nconst PLACEHOLDER_RE = /^\\{\\d+\\}$|^\\.{2,}$/\nconst CAMEL_CASE_RE = /^[a-z]+[A-Z]/\n\n// ─── URL / path detection (consolidated from legacy isNonContent) ───\n\nfunction isURLLike(str: string): boolean {\n if (/^(https?|ftp|file|mailto|data):/.test(str)) return true\n if (/^(\\.\\.?\\/|\\/|[A-Za-z]:\\\\)/.test(str)) return true\n if (/^['\"]?[@a-z][\\w-]*/.test(str.toLowerCase()) && !str.includes(' ') && (str.includes('/') || str.includes('.'))) {\n return true\n }\n return false\n}\n\n// ─── CSS / Tailwind detection ───\n\nconst TAILWIND_SEGMENT_RE = /^(?:bg-|text-|border-|flex|grid|p-|px-|py-|pt-|pb-|pl-|pr-|m-|mx-|my-|mt-|mb-|ml-|mr-|rounded|shadow|w-|h-|min-|max-|gap-|space-|items-|justify-|self-|overflow-|z-|opacity-|transition|duration-|ease-|animate-|font-|leading-|tracking-|decoration-|underline|line-through|uppercase|lowercase|capitalize|truncate|whitespace-|break-|sr-only|not-sr-only|hover:|focus:|active:|disabled:|dark:|sm:|md:|lg:|xl:|2xl:|group-|peer-|ring-|outline-|divide-|table-|col-|row-|aspect-|object-|inset-|top-|right-|bottom-|left-|translate-|rotate-|scale-|skew-|origin-|cursor-|select-|resize-|fill-|stroke-|block|inline|absolute|relative|fixed|sticky|static|float-|clear-|isolate|visible|invisible|grow|shrink|basis-|order-|place-)/\n\nfunction isCssClassList(value: string): boolean {\n const segments = value.trim().split(/\\s+/)\n if (segments.length < 2) return false\n let matched = 0\n for (const seg of segments) {\n if (TAILWIND_SEGMENT_RE.test(seg)) matched++\n }\n return matched / segments.length >= 0.5\n}\n\nfunction isSingleCssUtility(value: string): boolean {\n const trimmed = value.trim()\n if (trimmed.includes(' ')) return false\n return TAILWIND_SEGMENT_RE.test(trimmed)\n}\n\n// ─── SVG technical attributes ───\n\nconst SVG_TECHNICAL_ATTRIBUTES = new Set([\n 'd', 'viewBox', 'points', 'transform', 'pathLength',\n 'xmlns', 'preserveAspectRatio',\n 'stroke-linecap', 'stroke-linejoin', 'stroke-width',\n 'stroke-dasharray', 'stroke-dashoffset', 'stroke-miterlimit',\n 'fill-rule', 'clip-rule',\n])\n\nconst SVG_GRAPHIC_ELEMENTS = new Set([\n 'svg', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon',\n 'ellipse', 'g', 'defs', 'use', 'symbol', 'clipPath', 'mask',\n 'pattern', 'linearGradient', 'radialGradient', 'stop',\n 'marker', 'animate', 'animateTransform', 'image',\n])\n\n// ─── Known function names ───\n\nconst I18N_FUNCTIONS = new Set([\n 't', '$t', 'i18n', 'translate', 'formatMessage', 'msg',\n])\n\nconst EMIT_FUNCTIONS = new Set([\n 'emit', '$emit',\n])\n\n// ─── Translatable attribute whitelist (i18next-cli compatible + extended) ───\n\nconst TRANSLATABLE_ATTRIBUTES = new Set([\n // Standard HTML content attributes\n 'title', 'alt', 'placeholder', 'label', 'summary', 'caption',\n 'abbr', 'accesskey', 'content', 'description',\n // ARIA content\n 'aria-label', 'aria-description', 'aria-placeholder',\n 'aria-roledescription', 'aria-valuetext',\n // React Native accessibility (equivalent to aria-label)\n 'accessibilityLabel', 'accessibilityHint', 'accessibilityValue',\n // Common component content props\n 'heading', 'subheading', 'message', 'hint', 'tooltip',\n 'helper-text', 'error-message', 'success-message',\n 'confirm-text', 'cancel-text', 'empty-text', 'loading-text',\n 'no-data-text', 'no-results-text',\n])\n\n// ─── Translatable object property whitelist (i18next-cli compatible) ───\n\nconst TRANSLATABLE_PROPERTIES = new Set([\n 'label', 'title', 'description', 'text', 'message', 'placeholder',\n 'caption', 'summary', 'heading', 'subheading', 'subtitle', 'tooltip',\n 'hint', 'helpText', 'errorMessage', 'successMessage', 'name',\n])\n\n// ─── shouldSkip: Binary non-content detection ───\n\n/**\n * Determines if a string is definitely NOT user-visible content.\n * Returns skip reason if it should be filtered, null if it should proceed to scoring.\n *\n * Conservative for template_text/jsx_text (tag-between text is almost always content).\n * Aggressive for everything else (technical tokens, config values, framework artifacts).\n */\nexport function shouldSkip(str: ExtractedString): string | null {\n // ── Context-based rules (AST-determined, 100% accurate) ──\n\n if (str.context === 'import_path') return 'import_path'\n if (str.context === 'type_annotation') return 'type_annotation'\n if (str.context === 'css_class') return 'css_class'\n if (str.context === 'css_utility_call') return 'css_utility_call'\n if (str.context === 'console_call') return 'console_call'\n if (str.context === 'test_assertion') return 'test_assertion'\n if (str.context === 'switch_case') return 'switch_case'\n\n const v = str.value\n\n // ── Value-based rules (structural patterns) ──\n\n if (v.length <= 1) return 'single_char'\n if (/^\\s+$/.test(v)) return 'whitespace'\n if (PURE_NUMBER_RE.test(v)) return 'pure_number'\n if (HEX_COLOR_RE.test(v)) return 'hex_color'\n if (FILE_EXT_RE.test(v)) return 'file_extension'\n if (v.startsWith('--')) return 'cli_flag'\n\n // ── i18n key paths (checked before URL — both contain dots, but i18n keys are more specific) ──\n\n if (I18N_KEY_RE.test(v)) return 'i18n_key'\n\n // ── URL/path patterns ──\n\n if (isURLLike(v)) return 'url_path'\n\n // ── CSS patterns ──\n\n if (isCssClassList(v)) return 'css_class_list'\n if (isSingleCssUtility(v)) return 'css_utility_token'\n\n // ── SVG patterns ──\n\n if (v.length > 3 && SVG_PATH_DATA_RE.test(v)) return 'svg_path_data'\n if (SVG_VIEWBOX_RE.test(v)) return 'svg_viewbox'\n if (str.parentProperty !== undefined && SVG_TECHNICAL_ATTRIBUTES.has(str.parentProperty)) return 'svg_technical_attr'\n if (str.context === 'template_attribute' && SVG_GRAPHIC_ELEMENTS.has(str.parent)) return 'svg_element_attr'\n\n // ── Framework event patterns ──\n\n if (v.startsWith('update:')) return 'vue_emit_event'\n\n // ── Placeholder / interpolation ──\n\n if (PLACEHOLDER_RE.test(v)) return 'placeholder'\n\n // ── Known function argument detection ──\n\n if (str.context === 'function_argument' && I18N_FUNCTIONS.has(str.parent)) {\n // i18n function args: filter lowercase identifiers (namespace/key), keep sentences\n if (/^[a-z][a-z0-9_.-]*$/.test(v)) return 'i18n_function_arg'\n }\n\n if (str.context === 'function_argument' && EMIT_FUNCTIONS.has(str.parent)) {\n return 'emit_event_arg'\n }\n\n // ── CRITICAL: Technical identifier detection (i18next-cli proven pattern) ──\n // Single lowercase ASCII word/kebab-case/snake_case < 30 chars → technical token\n // EXEMPT: template_text and jsx_text (tag-between text IS content, even lowercase)\n\n if (str.context !== 'template_text' && str.context !== 'jsx_text') {\n if (TECHNICAL_IDENTIFIER_RE.test(v) && v.length < 30) {\n return 'technical_identifier'\n }\n }\n\n // ── Error codes (SCREAMING_SNAKE_CASE with underscores) ──\n\n if (ERROR_CODE_RE.test(v) && v.includes('_') && v.length > 3) {\n return 'error_code'\n }\n\n return null\n}\n\n// ─── calculateContentScore: 0-1 confidence scoring ───\n\n/**\n * Calculates a content confidence score (0-1) for a string that passed shouldSkip.\n * Uses AST context metadata (our advantage over offset-based tools) combined with\n * value-based signals proven by i18next-cli.\n *\n * Base score: 0.5. Boosted/penalized by context and value characteristics.\n */\nexport function calculateContentScore(str: ExtractedString): number {\n let score = 0.5\n\n // ── Context signals (AST metadata advantage) ──\n\n // Template/JSX text = almost certainly user-visible content\n if (str.context === 'template_text' || str.context === 'jsx_text') {\n score += 0.3\n }\n\n // Content-bearing attribute (title, alt, placeholder, aria-label, etc.)\n if (str.context === 'template_attribute' || str.context === 'jsx_attribute') {\n if (str.parentProperty && TRANSLATABLE_ATTRIBUTES.has(str.parentProperty)) {\n score += 0.2\n } else {\n score -= 0.2 // Unknown/technical attribute\n }\n }\n\n // Content-bearing object property (message, label, description, etc.)\n if (str.context === 'object_property') {\n if (str.parentProperty && TRANSLATABLE_PROPERTIES.has(str.parentProperty)) {\n score += 0.25\n }\n }\n\n // ── Value signals (i18next-cli proven heuristics) ──\n\n // Multi-word strings are more likely content\n const wordCount = str.value.split(/\\s+/).length\n if (wordCount >= 3) score += 0.2\n else if (wordCount === 2) score += 0.1\n\n // Terminal punctuation suggests a sentence\n if (/[.!?:;]$/.test(str.value)) score += 0.1\n\n // Non-ASCII characters (Turkish, Chinese, Arabic, etc.) → almost certainly content\n if (/[\\u0080-\\uFFFF]/.test(str.value)) score += 0.15\n\n // Capitalized first letter with lowercase body (Dashboard, Kaydet, Settings)\n if (/^[A-Z]/.test(str.value) && /[a-z]/.test(str.value)) score += 0.1\n\n // camelCase → probably a technical identifier\n if (CAMEL_CASE_RE.test(str.value)) score -= 0.3\n\n // Contains slash without spaces → path-like\n if (str.value.includes('/') && !str.value.includes(' ')) score -= 0.2\n\n return Math.max(0, Math.min(1, score))\n}\n\n// ─── Public API ───\n\n/**\n * Two-phase pre-filter:\n * 1. shouldSkip(): Binary removal of definite non-content\n * 2. calculateContentScore(): 0-1 confidence scoring for ambiguous strings\n *\n * Returns candidates that passed both phases, with content scores attached.\n */\nexport function applyPreFilter(\n strings: ExtractedString[],\n minScore: number = 0.4,\n): PreFilterResult {\n const candidates: ExtractedString[] = []\n const skipReasons: Record<string, number> = {}\n let skipped = 0\n let lowConfidence = 0\n\n for (const str of strings) {\n // Phase 1: Binary skip\n const skipReason = shouldSkip(str)\n if (skipReason) {\n skipped++\n skipReasons[skipReason] = (skipReasons[skipReason] ?? 0) + 1\n continue\n }\n\n // Phase 2: Content scoring\n const contentScore = calculateContentScore(str)\n if (contentScore < minScore) {\n lowConfidence++\n skipReasons['low_confidence'] = (skipReasons['low_confidence'] ?? 0) + 1\n continue\n }\n\n // Attach score to the extraction for downstream use\n ;(str as ExtractedString & { contentScore: number }).contentScore = contentScore\n candidates.push(str)\n }\n\n return { candidates, skipped, lowConfidence, skipReasons }\n}\n","import type { ExtractedString } from './types.js'\nimport { applyPreFilter } from './pre-filter.js'\nimport { parseTsx } from './tsx-parser.js'\n\n// ─── Extension sets ───\n\nconst TSX_EXTENSIONS = new Set(['.tsx', '.jsx', '.ts', '.js', '.mjs'])\nconst VUE_EXTENSIONS = new Set(['.vue'])\nconst SVELTE_EXTENSIONS = new Set(['.svelte'])\nconst ASTRO_EXTENSIONS = new Set(['.astro'])\n\n// ─── Lazy-loaded parsers ───\n\n/**\n * Lazily import vue-parser. Returns undefined if not available\n * (@vue/compiler-sfc is an optional dependency).\n */\nasync function loadVueParser(): Promise<((content: string, fileName: string) => Promise<ExtractedString[]> | ExtractedString[]) | undefined> {\n try {\n const mod = await import('./vue-parser.js')\n return mod.parseVue\n } catch {\n return undefined\n }\n}\n\n/**\n * Lazily import svelte-parser. Returns undefined if not available\n * (svelte is an optional dependency).\n */\nasync function loadSvelteParser(): Promise<((content: string, fileName: string) => Promise<ExtractedString[]>) | undefined> {\n try {\n const mod = await import('./svelte-parser.js')\n return mod.parseSvelte\n } catch {\n return undefined\n }\n}\n\n/**\n * Lazily import astro-parser. Returns undefined if not available\n * (@astrojs/compiler is an optional dependency).\n */\nasync function loadAstroParser(): Promise<((content: string, fileName: string) => Promise<ExtractedString[]>) | undefined> {\n try {\n const mod = await import('./astro-parser.js')\n return mod.parseAstro\n } catch {\n return undefined\n }\n}\n\n// ─── Regex fallback for unknown extensions ───\n\nconst SURROUNDING_MAX = 120\n\n/**\n * Minimal regex-based extractor for file types without AST parsers.\n * Extracts quoted strings and tag text — conservative, low accuracy.\n * Used as fallback when dedicated parsers are unavailable.\n */\nfunction extractWithRegex(content: string, _filePath: string): ExtractedString[] {\n const lines = content.split('\\n')\n const results: ExtractedString[] = []\n\n // Simple quoted string extraction\n const stringRe = /(['\"`])(?:(?!\\1|\\\\).|\\\\.)*?\\1/g\n\n for (let i = 0; i < lines.length; i++) {\n const line = lines[i]!\n const trimmed = line.trim()\n\n // Skip comments, imports\n if (trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*')) continue\n if (/^\\s*(import|export)\\s/.test(line)) continue\n\n let match: RegExpExecArray | null\n stringRe.lastIndex = 0\n\n while ((match = stringRe.exec(line)) !== null) {\n // Remove surrounding quotes\n const raw = match[0]\n const value = raw.slice(1, -1)\n if (value.length === 0) continue\n\n const surrounding = buildSurrounding(lines, i)\n\n results.push({\n value,\n line: i + 1,\n column: match.index + 1,\n context: 'other',\n scope: 'script',\n parent: '',\n surrounding,\n })\n }\n\n // Tag text extraction: >text<\n const tagTextRe = />([^<>{]+)</g\n let tagMatch: RegExpExecArray | null\n tagTextRe.lastIndex = 0\n\n while ((tagMatch = tagTextRe.exec(line)) !== null) {\n const text = tagMatch[1]!.trim()\n if (text.length === 0) continue\n if (/^[\\s\\W]*$/.test(text) && !/[a-zA-Z]/.test(text)) continue\n\n results.push({\n value: text,\n line: i + 1,\n column: tagMatch.index + 1,\n context: 'template_text',\n scope: 'template',\n parent: '',\n surrounding: buildSurrounding(lines, i),\n })\n }\n }\n\n return results\n}\n\nfunction buildSurrounding(lines: string[], lineIdx: number): string {\n const start = Math.max(0, lineIdx - 1)\n const end = Math.min(lines.length - 1, lineIdx + 1)\n\n const parts: string[] = []\n for (let i = start; i <= end; i++) {\n const line = lines[i]\n if (line !== undefined) {\n parts.push(line)\n }\n }\n\n const joined = parts.join('\\n')\n if (joined.length > SURROUNDING_MAX) {\n return joined.slice(0, SURROUNDING_MAX)\n }\n return joined\n}\n\n// ─── Public API ───\n\n/**\n * Extract strings from a source file with structural context.\n *\n * Routes to the correct parser based on file extension:\n * - .tsx/.jsx/.ts/.js/.mjs -> tsx-parser (AST-based)\n * - .vue -> vue-parser (lazy-loaded, AST-based)\n * - .svelte -> svelte-parser (lazy-loaded, AST-based)\n * - .astro -> astro-parser (lazy-loaded, AST-based)\n * - unknown -> empty array\n *\n * Falls back to regex extraction when the dedicated parser's\n * optional dependency is not installed.\n *\n * Applies structural pre-filter to remove 100% non-content strings.\n * Returns only candidates that should be sent to the agent.\n */\nexport async function extractStrings(\n filePath: string,\n content: string,\n ext: string,\n): Promise<ExtractedString[]> {\n const normalizedExt = ext.startsWith('.') ? ext : `.${ext}`\n let rawStrings: ExtractedString[]\n\n if (TSX_EXTENSIONS.has(normalizedExt)) {\n rawStrings = parseTsx(content, filePath)\n } else if (VUE_EXTENSIONS.has(normalizedExt)) {\n const parseVue = await loadVueParser()\n if (parseVue) {\n rawStrings = await parseVue(content, filePath)\n } else {\n rawStrings = extractWithRegex(content, filePath)\n }\n } else if (SVELTE_EXTENSIONS.has(normalizedExt)) {\n const parseSvelte = await loadSvelteParser()\n if (parseSvelte) {\n try {\n rawStrings = await parseSvelte(content, filePath)\n } catch {\n // svelte/compiler not installed — fall back to regex\n rawStrings = extractWithRegex(content, filePath)\n }\n } else {\n rawStrings = extractWithRegex(content, filePath)\n }\n } else if (ASTRO_EXTENSIONS.has(normalizedExt)) {\n const parseAstro = await loadAstroParser()\n if (parseAstro) {\n try {\n rawStrings = await parseAstro(content, filePath)\n } catch {\n // @astrojs/compiler not installed — fall back to regex\n rawStrings = extractWithRegex(content, filePath)\n }\n } else {\n rawStrings = extractWithRegex(content, filePath)\n }\n } else {\n return []\n }\n\n // Apply structural pre-filter\n const { candidates } = applyPreFilter(rawStrings)\n return candidates\n}\n\n// Re-export types for convenience\nexport type { ExtractedString, StructuralContext, PreFilterRule } from './types.js'\nexport { applyPreFilter, shouldSkip, calculateContentScore } from './pre-filter.js'\nexport type { PreFilterResult } from './pre-filter.js'\n","import type {\n StringContext,\n ScanCandidate,\n DuplicateGroup,\n ScanCandidatesResult,\n ScanSummaryResult,\n} from '@contentrain/types'\nimport { join, extname } from 'node:path'\nimport { readText } from '../util/fs.js'\nimport {\n autoDetectSourceDirs,\n discoverFiles,\n} from './scan-config.js'\nimport { extractStrings } from './ast-scanner/index.js'\nimport { calculateContentScore } from './ast-scanner/pre-filter.js'\nimport type { StructuralContext } from './ast-scanner/types.js'\n\n// ─── Options ───\n\nexport interface ScanOptions {\n paths?: string[]\n include?: string[]\n exclude?: string[]\n limit?: number\n offset?: number\n min_length?: number\n max_length?: number\n /** Minimum content confidence score (0-1). Default: 0.4 */\n min_score?: number\n}\n\n// ─── Constants ───\n\nconst DEFAULT_LIMIT = 50\nconst DEFAULT_OFFSET = 0\nconst DEFAULT_MIN_LENGTH = 2\nconst DEFAULT_MAX_LENGTH = 500\nconst DEFAULT_MIN_SCORE = 0.4\nconst SUMMARY_SAMPLE_SIZE = 10\n\n// ─── Context mapping: StructuralContext → StringContext ───\n\nconst CONTEXT_MAP: Record<StructuralContext, StringContext> = {\n 'template_text': 'template_text',\n 'template_attribute': 'template_attribute',\n 'jsx_text': 'jsx_text',\n 'jsx_attribute': 'jsx_attribute',\n 'variable_assignment': 'variable_assignment',\n 'object_property': 'object_value',\n 'function_argument': 'function_argument',\n 'array_element': 'other',\n 'enum_value': 'other',\n 'template_literal': 'other',\n 'switch_case': 'other',\n 'other': 'other',\n // Pre-filtered contexts should not reach here, but map them just in case\n 'import_path': 'other',\n 'type_annotation': 'other',\n 'css_class': 'other',\n 'css_utility_call': 'other',\n 'console_call': 'other',\n 'test_assertion': 'other',\n}\n\n// ─── Main: scanCandidates ───\n\nexport async function scanCandidates(\n projectRoot: string,\n options?: ScanOptions,\n): Promise<ScanCandidatesResult> {\n const limit = options?.limit ?? DEFAULT_LIMIT\n const offset = options?.offset ?? DEFAULT_OFFSET\n const minLength = options?.min_length ?? DEFAULT_MIN_LENGTH\n const maxLength = options?.max_length ?? DEFAULT_MAX_LENGTH\n const minScore = options?.min_score ?? DEFAULT_MIN_SCORE\n\n const scanDirs = options?.paths ?? await autoDetectSourceDirs(projectRoot)\n const files = await discoverFiles(projectRoot, {\n paths: scanDirs,\n include: options?.include,\n exclude: options?.exclude,\n })\n\n // ─── Phase 1: Extract strings from all files (pre-filter applied inside extractStrings) ───\n\n const filePromises = files.map(async (relPath) => {\n const filePath = join(projectRoot, relPath)\n const content = await readText(filePath)\n if (!content) return { relPath, extractions: [] }\n\n const ext = extname(filePath)\n const extractions = await extractStrings(filePath, content, ext)\n return { relPath, extractions }\n })\n\n const fileResults = await Promise.all(filePromises)\n\n // ─── Phase 2: Score + length filter + deduplicate ───\n\n let rawStringsFound = 0\n let skippedCount = 0\n let lowConfidenceCount = 0\n const skipReasons: Record<string, number> = {}\n\n // Deduplication map: value → first candidate + all occurrences\n const uniqueMap = new Map<string, {\n candidate: ScanCandidate\n maxScore: number\n }>()\n\n // Also track duplicates for backward compatibility\n const dupeMap = new Map<string, Array<{ file: string; line: number }>>()\n\n for (const { relPath, extractions } of fileResults) {\n rawStringsFound += extractions.length\n\n for (const extraction of extractions) {\n // Length filter\n if (extraction.value.length < minLength || extraction.value.length > maxLength) {\n skippedCount++\n skipReasons['length_filter'] = (skipReasons['length_filter'] ?? 0) + 1\n continue\n }\n\n // shouldSkip was already applied inside extractStrings (via applyPreFilter).\n // But applyPreFilter uses default minScore. Here we apply the user-configured minScore\n // on the contentScore that was attached during pre-filtering.\n\n // Get the contentScore attached by applyPreFilter\n const contentScore: number = (extraction as unknown as { contentScore?: number }).contentScore ?? calculateContentScore(extraction)\n\n if (contentScore < minScore) {\n lowConfidenceCount++\n skipReasons['low_confidence'] = (skipReasons['low_confidence'] ?? 0) + 1\n continue\n }\n\n const mappedContext = CONTEXT_MAP[extraction.context]\n const loc = { file: relPath, line: extraction.line }\n\n // Track all occurrences for duplicates section\n if (!dupeMap.has(extraction.value)) {\n dupeMap.set(extraction.value, [])\n }\n dupeMap.get(extraction.value)!.push(loc)\n\n // Deduplication: keep first occurrence, accumulate locations\n if (!uniqueMap.has(extraction.value)) {\n uniqueMap.set(extraction.value, {\n candidate: {\n file: relPath,\n line: extraction.line,\n column: extraction.column,\n value: extraction.value,\n context: mappedContext,\n surrounding: extraction.surrounding,\n contentScore,\n occurrences: [loc],\n },\n maxScore: contentScore,\n })\n } else {\n const entry = uniqueMap.get(extraction.value)!\n entry.candidate.occurrences.push(loc)\n // Keep the highest score across occurrences\n if (contentScore > entry.maxScore) {\n entry.maxScore = contentScore\n entry.candidate.contentScore = contentScore\n entry.candidate.file = relPath\n entry.candidate.line = extraction.line\n entry.candidate.column = extraction.column\n entry.candidate.context = mappedContext\n entry.candidate.surrounding = extraction.surrounding\n }\n }\n }\n }\n\n // Build sorted unique candidates (highest score first)\n const allUniqueCandidates = [...uniqueMap.values()]\n .map(e => e.candidate)\n .toSorted((a, b) => b.contentScore - a.contentScore)\n\n // Build duplicate groups (only count >= 2), sorted by count descending\n const duplicates: DuplicateGroup[] = [...dupeMap.entries()]\n .filter(([, occurrences]) => occurrences.length >= 2)\n .map(([value, occurrences]) => ({ value, count: occurrences.length, occurrences }))\n .toSorted((a, b) => b.count - a.count)\n\n // Pagination on unique candidates\n const uniqueCount = allUniqueCandidates.length\n const paginated = allUniqueCandidates.slice(offset, offset + limit)\n const hasMore = uniqueCount > offset + limit\n\n return {\n candidates: paginated,\n duplicates,\n stats: {\n files_scanned: files.length,\n raw_strings_found: rawStringsFound,\n skipped: skippedCount,\n low_confidence: lowConfidenceCount,\n unique_candidates: uniqueCount,\n candidates_returned: paginated.length,\n has_more: hasMore,\n skip_reasons: skipReasons,\n },\n }\n}\n\n// ─── Main: scanSummary ───\n\nexport async function scanSummary(\n projectRoot: string,\n options?: ScanOptions,\n): Promise<ScanSummaryResult> {\n const minLength = options?.min_length ?? DEFAULT_MIN_LENGTH\n const maxLength = options?.max_length ?? DEFAULT_MAX_LENGTH\n const minScore = options?.min_score ?? DEFAULT_MIN_SCORE\n\n const scanDirs = options?.paths ?? await autoDetectSourceDirs(projectRoot)\n const files = await discoverFiles(projectRoot, {\n paths: scanDirs,\n include: options?.include,\n exclude: options?.exclude,\n })\n\n // Group files by directory\n const dirFiles = new Map<string, string[]>()\n const fileTypes: Record<string, number> = {}\n\n for (const relPath of files) {\n const parts = relPath.split('/')\n const dir = parts.slice(0, -1).join('/') || '.'\n const ext = extname(relPath)\n\n if (!dirFiles.has(dir)) dirFiles.set(dir, [])\n dirFiles.get(dir)!.push(relPath)\n\n fileTypes[ext] = (fileTypes[ext] ?? 0) + 1\n }\n\n // Sample files per directory and count candidates\n const byDirectory: Record<string, { files: number; candidates: number }> = {}\n const freqMap = new Map<string, number>()\n let totalCandidatesEstimate = 0\n\n for (const [dir, dirFileList] of dirFiles) {\n const totalInDir = dirFileList.length\n const sampleFiles = dirFileList.slice(0, SUMMARY_SAMPLE_SIZE)\n let sampleCandidates = 0\n\n const samplePromises = sampleFiles.map(async (relPath) => {\n const filePath = join(projectRoot, relPath)\n const content = await readText(filePath)\n if (!content) return []\n\n const ext = extname(filePath)\n return extractStrings(filePath, content, ext)\n })\n\n const sampleResults = await Promise.all(samplePromises)\n\n for (const extractions of sampleResults) {\n for (const extraction of extractions) {\n // Apply same filters as scanCandidates\n if (extraction.value.length < minLength || extraction.value.length > maxLength) continue\n\n const contentScore: number = (extraction as unknown as { contentScore?: number }).contentScore ?? calculateContentScore(extraction)\n if (contentScore < minScore) continue\n\n sampleCandidates++\n\n // Track frequencies for top_repeated\n const prev = freqMap.get(extraction.value) ?? 0\n freqMap.set(extraction.value, prev + 1)\n }\n }\n\n // Estimate for full directory\n const avgPerFile = sampleFiles.length > 0 ? sampleCandidates / sampleFiles.length : 0\n const estimatedCandidates = Math.round(avgPerFile * totalInDir)\n\n byDirectory[dir] = {\n files: totalInDir,\n candidates: estimatedCandidates,\n }\n\n totalCandidatesEstimate += estimatedCandidates\n }\n\n // Top repeated strings\n const topRepeated = [...freqMap.entries()]\n .filter(([, count]) => count >= 2)\n .toSorted((a, b) => b[1] - a[1])\n .slice(0, 20)\n .map(([value, count]) => ({ value, count }))\n\n return {\n total_files: files.length,\n total_candidates_estimate: totalCandidatesEstimate,\n by_directory: byDirectory,\n top_repeated: topRepeated,\n sampling_note: `Based on first ${SUMMARY_SAMPLE_SIZE} files per directory. Counts are from sampled subset, not project-wide.`,\n file_types: fileTypes,\n }\n}\n"],"mappings":"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"}
@@ -6,20 +6,124 @@ import { t as readContext } from "./context-DIDt_TxX.mjs";
6
6
  import { t as detectStack } from "./detect-DinOQAmK.mjs";
7
7
  import { n as cleanupMergedBranches, t as checkBranchHealth } from "./branch-lifecycle-wGJg25LT.mjs";
8
8
  import { n as listTemplates, t as getTemplate } from "./templates-bRPzYq4M.mjs";
9
- import { n as createTransaction, r as ensureContentBranch, t as buildBranchName } from "./transaction-B0V0_SE2.mjs";
9
+ import { i as mergeBranch, n as createTransaction, r as ensureContentBranch, t as buildBranchName } from "./transaction-C_Pvux78.mjs";
10
10
  import { t as validateProject } from "./validator-SQYUV2JP.mjs";
11
11
  import { t as buildGraph } from "./graph-builder-tM7zj-SX.mjs";
12
- import { n as scanCandidates, r as scanSummary } from "./scanner-DPrbU-oK.mjs";
13
- import { n as applyExtract, r as applyReuse } from "./apply-manager-Cf5OWOJ7.mjs";
12
+ import { n as scanSummary, t as scanCandidates } from "./scanner-CQOxARUU.mjs";
13
+ import { n as applyExtract, r as applyReuse } from "./apply-manager-BkPMfpsN.mjs";
14
14
  import { join } from "node:path";
15
15
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
16
16
  import { z } from "zod";
17
17
  import { appendFile, readFile, writeFile } from "node:fs/promises";
18
18
  import { CONTENTRAIN_BRANCH } from "@contentrain/types";
19
19
  import { simpleGit } from "simple-git";
20
+ //#region src/tools/annotations.ts
21
+ /**
22
+ * Centralized tool annotations registry.
23
+ * MCP clients use these hints to distinguish read-only vs. write vs. destructive tools.
24
+ */
25
+ const TOOL_ANNOTATIONS = {
26
+ contentrain_status: {
27
+ title: "Project Status",
28
+ readOnlyHint: true,
29
+ destructiveHint: false,
30
+ idempotentHint: true
31
+ },
32
+ contentrain_describe: {
33
+ title: "Describe Model",
34
+ readOnlyHint: true,
35
+ destructiveHint: false,
36
+ idempotentHint: true
37
+ },
38
+ contentrain_describe_format: {
39
+ title: "Describe Format",
40
+ readOnlyHint: true,
41
+ destructiveHint: false,
42
+ idempotentHint: true
43
+ },
44
+ contentrain_init: {
45
+ title: "Initialize Project",
46
+ readOnlyHint: false,
47
+ destructiveHint: false,
48
+ idempotentHint: false
49
+ },
50
+ contentrain_scaffold: {
51
+ title: "Scaffold Template",
52
+ readOnlyHint: false,
53
+ destructiveHint: false,
54
+ idempotentHint: false
55
+ },
56
+ contentrain_model_save: {
57
+ title: "Save Model",
58
+ readOnlyHint: false,
59
+ destructiveHint: false,
60
+ idempotentHint: true
61
+ },
62
+ contentrain_model_delete: {
63
+ title: "Delete Model",
64
+ readOnlyHint: false,
65
+ destructiveHint: true,
66
+ idempotentHint: false
67
+ },
68
+ contentrain_content_save: {
69
+ title: "Save Content",
70
+ readOnlyHint: false,
71
+ destructiveHint: false,
72
+ idempotentHint: true
73
+ },
74
+ contentrain_content_delete: {
75
+ title: "Delete Content",
76
+ readOnlyHint: false,
77
+ destructiveHint: true,
78
+ idempotentHint: false
79
+ },
80
+ contentrain_content_list: {
81
+ title: "List Content",
82
+ readOnlyHint: true,
83
+ destructiveHint: false,
84
+ idempotentHint: true
85
+ },
86
+ contentrain_validate: {
87
+ title: "Validate Project",
88
+ readOnlyHint: false,
89
+ destructiveHint: false,
90
+ idempotentHint: true
91
+ },
92
+ contentrain_submit: {
93
+ title: "Submit Branches",
94
+ readOnlyHint: false,
95
+ destructiveHint: false,
96
+ idempotentHint: true
97
+ },
98
+ contentrain_merge: {
99
+ title: "Merge Branch",
100
+ readOnlyHint: false,
101
+ destructiveHint: false,
102
+ idempotentHint: false
103
+ },
104
+ contentrain_scan: {
105
+ title: "Scan Source Code",
106
+ readOnlyHint: true,
107
+ destructiveHint: false,
108
+ idempotentHint: true
109
+ },
110
+ contentrain_apply: {
111
+ title: "Apply Normalize",
112
+ readOnlyHint: false,
113
+ destructiveHint: false,
114
+ idempotentHint: false
115
+ },
116
+ contentrain_bulk: {
117
+ title: "Bulk Operations",
118
+ readOnlyHint: false,
119
+ destructiveHint: false,
120
+ idempotentHint: false
121
+ }
122
+ };
123
+ //#endregion
20
124
  //#region src/tools/context.ts
21
125
  function registerContextTools(server, projectRoot) {
22
- server.tool("contentrain_status", "Get full project status (read-only). Returns config, models, context. Do NOT manually edit .contentrain/ based on this output.", {}, async () => {
126
+ server.tool("contentrain_status", "Get full project status (read-only). Returns config, models, context. Do NOT manually edit .contentrain/ based on this output.", {}, TOOL_ANNOTATIONS["contentrain_status"], async () => {
23
127
  if (!await pathExists(join(contentrainDir(projectRoot), "config.json"))) {
24
128
  const detectedStack = await detectStack(projectRoot);
25
129
  return { content: [{
@@ -85,7 +189,7 @@ function registerContextTools(server, projectRoot) {
85
189
  model: z.string().describe("Model ID (e.g. \"blog-post\", \"hero\")"),
86
190
  include_sample: z.boolean().optional().default(false).describe("Include one sample entry"),
87
191
  locale: z.string().optional().describe("Locale for sample content (default: config default locale)")
88
- }, async ({ model: modelId, include_sample, locale }) => {
192
+ }, TOOL_ANNOTATIONS["contentrain_describe"], async ({ model: modelId, include_sample, locale }) => {
89
193
  const modelDef = await readModel(projectRoot, modelId);
90
194
  if (!modelDef) return {
91
195
  content: [{
@@ -120,7 +224,7 @@ function registerContextTools(server, projectRoot) {
120
224
  text: JSON.stringify(result, null, 2)
121
225
  }] };
122
226
  });
123
- server.tool("contentrain_describe_format", "Describes the Contentrain content file format for any language/platform. Returns a comprehensive specification of the file structure, JSON formats, markdown conventions, meta files, and locale strategies.", {}, async () => {
227
+ server.tool("contentrain_describe_format", "Describes the Contentrain content file format for any language/platform. Returns a comprehensive specification of the file structure, JSON formats, markdown conventions, meta files, and locale strategies.", {}, TOOL_ANNOTATIONS["contentrain_describe_format"], async () => {
124
228
  return { content: [{
125
229
  type: "text",
126
230
  text: JSON.stringify({
@@ -331,7 +435,7 @@ function registerSetupTools(server, projectRoot) {
331
435
  stack: z.string().optional().describe("Framework stack (nuxt, next, astro, svelte, react-vite, other). Auto-detected if omitted."),
332
436
  locales: z.array(z.string()).optional().describe("Supported locales. Default: [\"en\"]"),
333
437
  domains: z.array(z.string()).optional().describe("Content domains. Default: auto-suggested")
334
- }, async ({ stack, locales, domains }) => {
438
+ }, TOOL_ANNOTATIONS["contentrain_init"], async ({ stack, locales, domains }) => {
335
439
  if (await pathExists(join(contentrainDir(projectRoot), "config.json"))) return {
336
440
  content: [{
337
441
  type: "text",
@@ -457,7 +561,7 @@ function registerSetupTools(server, projectRoot) {
457
561
  template: z.string().describe("Template ID: blog, landing, docs, ecommerce, saas, i18n, mobile"),
458
562
  locales: z.array(z.string()).optional().describe("Override locales"),
459
563
  with_sample_content: z.boolean().optional().default(true).describe("Include sample content (default: true)")
460
- }, async ({ template: templateId, locales, with_sample_content }) => {
564
+ }, TOOL_ANNOTATIONS["contentrain_scaffold"], async ({ template: templateId, locales, with_sample_content }) => {
461
565
  const config = await readConfig(projectRoot);
462
566
  if (!config) return {
463
567
  content: [{
@@ -636,7 +740,7 @@ function registerModelTools(server, projectRoot) {
636
740
  "directory",
637
741
  "none"
638
742
  ]).optional().describe("How locale is encoded in file names. Default: \"file\"")
639
- }, async (input) => {
743
+ }, TOOL_ANNOTATIONS["contentrain_model_save"], async (input) => {
640
744
  const config = await readConfig(projectRoot);
641
745
  if (!config) return {
642
746
  content: [{
@@ -747,7 +851,7 @@ function registerModelTools(server, projectRoot) {
747
851
  server.tool("contentrain_model_delete", "Delete a model and its content/meta. Changes are auto-committed to git — do NOT manually edit .contentrain/ files.", {
748
852
  model: z.string().describe("Model ID to delete"),
749
853
  confirm: z.literal(true).describe("Must be true to confirm deletion")
750
- }, async ({ model: modelId }) => {
854
+ }, TOOL_ANNOTATIONS["contentrain_model_delete"], async ({ model: modelId }) => {
751
855
  if (!await readConfig(projectRoot)) return {
752
856
  content: [{
753
857
  type: "text",
@@ -840,7 +944,7 @@ function registerContentTools(server, projectRoot) {
840
944
  publish_at: z.string().optional().describe("ISO 8601 date for scheduled publishing (stored in meta)"),
841
945
  expire_at: z.string().optional().describe("ISO 8601 date for scheduled expiry (stored in meta, must be after publish_at)")
842
946
  })).describe("Content entries to save")
843
- }, async (input) => {
947
+ }, TOOL_ANNOTATIONS["contentrain_content_save"], async (input) => {
844
948
  const config = await readConfig(projectRoot);
845
949
  if (!config) return {
846
950
  content: [{
@@ -966,7 +1070,7 @@ function registerContentTools(server, projectRoot) {
966
1070
  locale: z.string().optional().describe("Locale code"),
967
1071
  keys: z.array(z.string()).optional().describe("Dictionary only: specific keys to remove. Omit to delete entire locale file."),
968
1072
  confirm: z.literal(true).describe("Must be true to confirm deletion")
969
- }, async (input) => {
1073
+ }, TOOL_ANNOTATIONS["contentrain_content_delete"], async (input) => {
970
1074
  if (!await readConfig(projectRoot)) return {
971
1075
  content: [{
972
1076
  type: "text",
@@ -1048,7 +1152,7 @@ function registerContentTools(server, projectRoot) {
1048
1152
  resolve: z.boolean().optional().describe("Resolve relation fields to actual data"),
1049
1153
  limit: z.number().optional().describe("Max entries to return"),
1050
1154
  offset: z.number().optional().describe("Skip N entries")
1051
- }, async (input) => {
1155
+ }, TOOL_ANNOTATIONS["contentrain_content_list"], async (input) => {
1052
1156
  const config = await readConfig(projectRoot);
1053
1157
  if (!config) return {
1054
1158
  content: [{
@@ -1094,7 +1198,7 @@ function registerWorkflowTools(server, projectRoot) {
1094
1198
  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.", {
1095
1199
  model: z.string().optional().describe("Model ID to validate (omit for all models)"),
1096
1200
  fix: z.boolean().optional().describe("Auto-fix structural issues (canonical sort, orphan meta, missing locale files). Default: false")
1097
- }, async (input) => {
1201
+ }, TOOL_ANNOTATIONS["contentrain_validate"], async (input) => {
1098
1202
  if (!await readConfig(projectRoot)) return {
1099
1203
  content: [{
1100
1204
  type: "text",
@@ -1190,7 +1294,7 @@ function registerWorkflowTools(server, projectRoot) {
1190
1294
  server.tool("contentrain_submit", "Push contentrain/* branches to remote. MCP is push-only — PR creation is handled by the platform. Do NOT manually push or create PRs.", {
1191
1295
  branches: z.array(z.string()).optional().describe("Specific branch names to push (omit for all contentrain/* branches)"),
1192
1296
  message: z.string().optional().describe("Optional message for the push operation")
1193
- }, async (input) => {
1297
+ }, TOOL_ANNOTATIONS["contentrain_submit"], async (input) => {
1194
1298
  const config = await readConfig(projectRoot);
1195
1299
  if (!config) return {
1196
1300
  content: [{
@@ -1271,6 +1375,7 @@ function registerWorkflowTools(server, projectRoot) {
1271
1375
  const cleanup = await cleanupMergedBranches(projectRoot);
1272
1376
  const nextSteps = [];
1273
1377
  if (pushed.length > 0) nextSteps.push("Create PRs on your git platform for review");
1378
+ if (pushed.length > 0) nextSteps.push("For merge: use contentrain_merge or direct user to http://localhost:3333/branches");
1274
1379
  if (errors.length > 0) nextSteps.push("Fix push errors and retry");
1275
1380
  if (cleanup.remaining >= 50) nextSteps.push(`Warning: ${cleanup.remaining} active contentrain branches. Consider reviewing old branches.`);
1276
1381
  return { content: [{
@@ -1298,6 +1403,53 @@ function registerWorkflowTools(server, projectRoot) {
1298
1403
  };
1299
1404
  }
1300
1405
  });
1406
+ 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.", {
1407
+ branch: z.string().describe("Branch name to merge (e.g. cr/normalize/extract/...)"),
1408
+ confirm: z.literal(true).describe("Must be true to confirm the merge")
1409
+ }, TOOL_ANNOTATIONS["contentrain_merge"], async (input) => {
1410
+ if (!await readConfig(projectRoot)) return {
1411
+ content: [{
1412
+ type: "text",
1413
+ text: JSON.stringify({ error: "Project not initialized. Run contentrain_init first." })
1414
+ }],
1415
+ isError: true
1416
+ };
1417
+ try {
1418
+ if (!(await simpleGit(projectRoot).branchLocal()).all.includes(input.branch)) return {
1419
+ content: [{
1420
+ type: "text",
1421
+ text: JSON.stringify({
1422
+ error: `Branch "${input.branch}" not found locally.`,
1423
+ next_steps: ["Check branch name with git branch -l", "Use contentrain_submit to push first if needed"]
1424
+ })
1425
+ }],
1426
+ isError: true
1427
+ };
1428
+ const result = await mergeBranch(projectRoot, input.branch);
1429
+ return { content: [{
1430
+ type: "text",
1431
+ text: JSON.stringify({
1432
+ status: "merged",
1433
+ action: result.action,
1434
+ commit: result.commit,
1435
+ sync: result.sync,
1436
+ next_steps: [
1437
+ "Run `npx contentrain generate` to update SDK client",
1438
+ "Run contentrain_validate to verify content integrity",
1439
+ result.sync.skipped.length > 0 ? `${result.sync.skipped.length} file(s) skipped due to local changes — resolve manually` : ""
1440
+ ].filter(Boolean)
1441
+ }, null, 2)
1442
+ }] };
1443
+ } catch (error) {
1444
+ return {
1445
+ content: [{
1446
+ type: "text",
1447
+ text: JSON.stringify({ error: `Merge failed: ${error instanceof Error ? error.message : String(error)}` })
1448
+ }],
1449
+ isError: true
1450
+ };
1451
+ }
1452
+ });
1301
1453
  }
1302
1454
  //#endregion
1303
1455
  //#region src/tools/normalize.ts
@@ -1315,7 +1467,7 @@ function registerNormalizeTools(server, projectRoot) {
1315
1467
  offset: z.number().optional().describe("Candidates mode: pagination offset. Default: 0"),
1316
1468
  min_length: z.number().optional().describe("Candidates mode: minimum string length. Default: 2"),
1317
1469
  max_length: z.number().optional().describe("Candidates mode: maximum string length. Default: 500")
1318
- }, async (input) => {
1470
+ }, TOOL_ANNOTATIONS["contentrain_scan"], async (input) => {
1319
1471
  if (!await readConfig(projectRoot)) return {
1320
1472
  content: [{
1321
1473
  type: "text",
@@ -1441,7 +1593,7 @@ function registerNormalizeTools(server, projectRoot) {
1441
1593
  new_expression: z.string().describe("Replacement expression (agent-determined)"),
1442
1594
  import_statement: z.string().optional().describe("Import to add if needed")
1443
1595
  })).optional().describe("Reuse mode: patches to apply (max 100)")
1444
- }, async (input) => {
1596
+ }, TOOL_ANNOTATIONS["contentrain_apply"], async (input) => {
1445
1597
  if (!await readConfig(projectRoot)) return {
1446
1598
  content: [{
1447
1599
  type: "text",
@@ -1562,7 +1714,7 @@ function registerBulkTools(server, projectRoot) {
1562
1714
  "archived"
1563
1715
  ]).optional().describe("New status for update_status"),
1564
1716
  confirm: z.boolean().optional().describe("Must be true for delete_entries")
1565
- }, async (input) => {
1717
+ }, TOOL_ANNOTATIONS["contentrain_bulk"], async (input) => {
1566
1718
  const config = await readConfig(projectRoot);
1567
1719
  if (!config) return {
1568
1720
  content: [{
@@ -1860,7 +2012,7 @@ function registerBulkTools(server, projectRoot) {
1860
2012
  }
1861
2013
  //#endregion
1862
2014
  //#region package.json
1863
- var version = "1.1.2";
2015
+ var version = "1.2.0";
1864
2016
  //#endregion
1865
2017
  //#region src/server.ts
1866
2018
  function createServer(projectRoot) {
@@ -1880,4 +2032,4 @@ function createServer(projectRoot) {
1880
2032
  //#endregion
1881
2033
  export { createServer as t };
1882
2034
 
1883
- //# sourceMappingURL=server-CjK63H6X.mjs.map
2035
+ //# sourceMappingURL=server-DfWRpzdP.mjs.map