From 8e1dce0a12affea7b911122c17ed9db3c14d0fa6 Mon Sep 17 00:00:00 2001 From: Contentrain Date: Sun, 29 Mar 2026 20:42:21 +0300 Subject: [PATCH 1/7] [contentrain] created: test-v2 --- .contentrain/context.json | 10 +++++----- .contentrain/models/test-v2.json | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 5 deletions(-) create mode 100644 .contentrain/models/test-v2.json diff --git a/.contentrain/context.json b/.contentrain/context.json index 430a9aa..96d0a20 100644 --- a/.contentrain/context.json +++ b/.contentrain/context.json @@ -1,19 +1,19 @@ { "lastOperation": { "locale": "en", - "model": "test-features", + "model": "test-v2", "source": "mcp-local", - "timestamp": "2026-03-29T16:44:10.635Z", - "tool": "contentrain_model_delete" + "timestamp": "2026-03-29T17:42:21.209Z", + "tool": "contentrain_model_save" }, "stats": { "entries": 41, - "lastSync": "2026-03-29T16:44:10.635Z", + "lastSync": "2026-03-29T17:42:21.209Z", "locales": [ "en", "tr" ], - "models": 11 + "models": 12 }, "version": "1" } diff --git a/.contentrain/models/test-v2.json b/.contentrain/models/test-v2.json new file mode 100644 index 0000000..549431c --- /dev/null +++ b/.contentrain/models/test-v2.json @@ -0,0 +1,16 @@ +{ + "id": "test-v2", + "name": "Test V2 Architecture", + "kind": "collection", + "domain": "marketing", + "i18n": true, + "fields": { + "description": { + "type": "text" + }, + "title": { + "required": true, + "type": "string" + } + } +} From aa6e91c21f88ebb8f9c9f983eab2a68ba43a634f Mon Sep 17 00:00:00 2001 From: Contentrain Date: Sun, 29 Mar 2026 20:42:45 +0300 Subject: [PATCH 2/7] [contentrain] delete: test-v2 --- .contentrain/context.json | 8 ++++---- .contentrain/models/test-v2.json | 16 ---------------- 2 files changed, 4 insertions(+), 20 deletions(-) delete mode 100644 .contentrain/models/test-v2.json diff --git a/.contentrain/context.json b/.contentrain/context.json index 96d0a20..790fc0d 100644 --- a/.contentrain/context.json +++ b/.contentrain/context.json @@ -3,17 +3,17 @@ "locale": "en", "model": "test-v2", "source": "mcp-local", - "timestamp": "2026-03-29T17:42:21.209Z", - "tool": "contentrain_model_save" + "timestamp": "2026-03-29T17:42:45.825Z", + "tool": "contentrain_model_delete" }, "stats": { "entries": 41, - "lastSync": "2026-03-29T17:42:21.209Z", + "lastSync": "2026-03-29T17:42:45.825Z", "locales": [ "en", "tr" ], - "models": 12 + "models": 11 }, "version": "1" } diff --git a/.contentrain/models/test-v2.json b/.contentrain/models/test-v2.json deleted file mode 100644 index 549431c..0000000 --- a/.contentrain/models/test-v2.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "id": "test-v2", - "name": "Test V2 Architecture", - "kind": "collection", - "domain": "marketing", - "i18n": true, - "fields": { - "description": { - "type": "text" - }, - "title": { - "required": true, - "type": "string" - } - } -} From 071c46ff00c56cc3de66185de483b31d395e6f76 Mon Sep 17 00:00:00 2001 From: Contentrain Date: Sat, 18 Apr 2026 00:14:25 +0300 Subject: [PATCH 3/7] =?UTF-8?q?feat(mcp,cli):=20phase=2014c=20=E2=80=94=20?= =?UTF-8?q?extract=20doctor=20into=20shared=20MCP=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls the 540-line `contentrain doctor` CLI command apart so the same health report drives three consumers: the CLI, a new `contentrain_doctor` MCP tool, and the Serve UI's `/api/doctor` route. ### `@contentrain/mcp` - `@contentrain/mcp/core/doctor` — `runDoctor(projectRoot, { usage? })` returns a structured `DoctorReport`: { checks: Array<{ name, pass, detail, severity? }>, summary: { total, passed, failed, warnings }, usage?: { unusedKeys, duplicateValues, missingLocaleKeys } } Every check now carries an explicit severity (error / warning / info) so consumers render independently instead of inferring from text. Orphan content + stale SDK client drop to `warning`; missing git / config / structure stay at `error`. - `contentrain_doctor` MCP tool — read-only, gated behind `localWorktree`. Arg: `{ usage?: boolean }`. Returns the `DoctorReport` verbatim. Advertised alongside describe-format. ### `contentrain` - CLI `contentrain doctor` collapses to a thin pretty-printer over `runDoctor()`. Default interactive output is byte-identical — same labels, same icons, same grouped usage blocks. New: --json — silent, emits raw report; exits non-zero on failures. Interactive mode also now exits non-zero on failure (was always 0). - `GET /api/doctor?usage=true` wraps the MCP tool for the Serve UI. ### Scope notes - Doctor is inherently local-filesystem work (Node version, git binary, mtime comparisons, orphan walk, source scan), so the MCP tool is capability-gated behind `localWorktree` and throws a structured capability error over remote providers — matches the `contentrain_setup` / `contentrain_scaffold` pattern. - No behaviour change for existing CLI users beyond the additive --json flag + exit-code hardening. ### Verification - oxlint across mcp+cli src+tests → 0 warnings on 350 files. - @contentrain/mcp typecheck → 0 errors. - contentrain typecheck → 0 errors. - Unit tests (21 new, all pass): - tests/core/doctor.test.ts 6/6 — uninitialised, minimal, orphan warning, default-omits-usage, usage-adds-3-checks, stale-SDK. - tests/tools/doctor.test.ts 4/4 — structured report, usage opt-in, capability error over remote provider, tools-list advert. - tests/commands/doctor.test.ts (CLI) 7/7 — rewritten to mock runDoctor directly. Covers --json, exit codes, usage detail rendering, flag forwarding. - tests/integration/serve.integration.test.ts 24/24 — new /api/doctor cases: default, ?usage=true, ?usage=1. Tool surface: +1 tool (contentrain_doctor). Everything else unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/phase-14c-doctor-extraction.md | 80 +++ packages/cli/src/commands/doctor.ts | 508 ++---------------- packages/cli/src/serve/server.ts | 11 + packages/cli/tests/commands/doctor.test.ts | 157 +++--- .../integration/serve.integration.test.ts | 25 + packages/mcp/package.json | 8 +- packages/mcp/src/core/doctor.ts | 491 +++++++++++++++++ packages/mcp/src/server.ts | 2 + packages/mcp/src/tools/annotations.ts | 6 + packages/mcp/src/tools/doctor.ts | 29 + packages/mcp/tests/core/doctor.test.ts | 151 ++++++ packages/mcp/tests/tools/doctor.test.ts | 102 ++++ 12 files changed, 1028 insertions(+), 542 deletions(-) create mode 100644 .changeset/phase-14c-doctor-extraction.md create mode 100644 packages/mcp/src/core/doctor.ts create mode 100644 packages/mcp/src/tools/doctor.ts create mode 100644 packages/mcp/tests/core/doctor.test.ts create mode 100644 packages/mcp/tests/tools/doctor.test.ts diff --git a/.changeset/phase-14c-doctor-extraction.md b/.changeset/phase-14c-doctor-extraction.md new file mode 100644 index 0000000..5788d07 --- /dev/null +++ b/.changeset/phase-14c-doctor-extraction.md @@ -0,0 +1,80 @@ +--- +"@contentrain/mcp": minor +"contentrain": minor +--- + +feat(mcp,cli): phase 14c — extract doctor into a reusable MCP tool + serve route + +Pulls the 540-line `contentrain doctor` CLI command apart so the same +health report drives three consumers: the CLI, the new +`contentrain_doctor` MCP tool, and the Serve UI's `/api/doctor` route. + +### `@contentrain/mcp` — new shared surface + +- **`@contentrain/mcp/core/doctor`** — `runDoctor(projectRoot, + { usage? })` returns a structured `DoctorReport`: + ```ts + interface DoctorReport { + checks: Array<{ name, pass, detail, severity? }> + summary: { total, passed, failed, warnings } + usage?: { unusedKeys, duplicateValues, missingLocaleKeys } + } + ``` + Every check now carries an explicit `severity` (`error` | + `warning` | `info`) so consumers can render pass/warn/fail + independently instead of inferring from text. Orphan content and + stale SDK client drop to `warning`; missing git / config / + structure stay at `error`. +- **`contentrain_doctor` MCP tool** — read-only, local-only (gated + behind `localWorktree`). Arg: `{ usage?: boolean }`. Returns the + `DoctorReport` JSON verbatim. Advertised alongside + `contentrain_describe_format` in the tools list. + +### `contentrain` — CLI + serve consumers + +- **CLI `contentrain doctor`** collapses to a thin pretty-printer + over `runDoctor()`. Default (interactive) output is byte-identical + to the old command — same check labels, same `status icon name: + detail` format, same grouped usage output. New flags: + - `--json` — silent, emits the raw `DoctorReport` to stdout. + Exits non-zero when any check fails so CI pipelines can wire + `contentrain doctor --json` as a gate. + - Interactive mode also exits non-zero now on any failure (was + always 0 before, which meant CI never noticed). +- **`GET /api/doctor`** — wraps the MCP tool. `?usage=true` or + `?usage=1` opts into usage analysis. The Serve UI consumes this + for the Doctor panel being added in phase 14d. + +### Scope notes + +- Doctor is inherently local-filesystem work (Node version, git + binary, mtime comparisons, orphan-dir walk, source-file scan), so + `contentrain_doctor` is capability-gated behind `localWorktree` + and throws a structured capability error over remote providers — + matching `contentrain_setup`, `contentrain_scaffold`, etc. +- No behaviour change for existing users. The CLI command still + prints the same rows; the new JSON output and non-zero exit codes + are additive. + +### Verification + +- `oxlint` across mcp/cli src + tests → 0 warnings on 350 files. +- `@contentrain/mcp` typecheck → 0 errors. +- `contentrain` typecheck → 0 errors. +- Unit tests: + - `tests/core/doctor.test.ts` — 6/6 (uninitialised project, + minimal valid project, orphan detection with warning severity, + default-omits-usage, usage-flag-adds-3-checks, stale-SDK-mtime). + - `tests/tools/doctor.test.ts` — 4/4 (structured report over + fixture, `{usage: true}` opt-in, capability error on remote + provider, tool advertised in list). + - `tests/commands/doctor.test.ts` (CLI) — 7/7, rewritten to mock + `runDoctor` directly. Covers `--json` output, exit-code + semantics (failure → 1), usage detail rendering, `--usage` + forwarding. + - `tests/integration/serve.integration.test.ts` — 24/24 (new + `/api/doctor` test: default, `?usage=true`, `?usage=1`). + +### Tool surface + +- **+1 tool**: `contentrain_doctor`. All existing tools unchanged. diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index e4e32dd..c117f8b 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -1,24 +1,18 @@ import { defineCommand } from 'citty' import { intro, outro, log, spinner } from '@clack/prompts' -import { simpleGit } from 'simple-git' -import { join } from 'node:path' -import { stat } from 'node:fs/promises' -import { listModels, readModel } from '@contentrain/mcp/core/model-manager' -import { resolveContentDir, resolveJsonFilePath, resolveLocaleStrategy } from '@contentrain/mcp/core/content-manager' -import { readConfig } from '@contentrain/mcp/core/config' -import { pathExists, contentrainDir, readDir, readJson, readText } from '@contentrain/mcp/util/fs' -import { autoDetectSourceDirs, discoverFiles } from '@contentrain/mcp/core/scan-config' -import { checkBranchHealth } from '@contentrain/mcp/git/branch-lifecycle' -import type { ModelDefinition, ContentrainConfig } from '@contentrain/types' +import { runDoctor, type DoctorReport } from '@contentrain/mcp/core/doctor' import { resolveProjectRoot } from '../utils/context.js' import { statusIcon, pc } from '../utils/ui.js' -interface CheckResult { - name: string - pass: boolean - detail: string -} - +/** + * Thin CLI wrapper over the shared `runDoctor()` from `@contentrain/mcp`. + * + * All health-check logic lives in the MCP core so the same report + * drives three consumers: this command, the `contentrain_doctor` MCP + * tool, and the Serve UI's `/api/doctor` route. Behaviour in the + * default (non-`--json`) mode is unchanged — same check labels, same + * details, same grouped usage output. + */ export default defineCommand({ meta: { name: 'doctor', @@ -27,201 +21,39 @@ export default defineCommand({ args: { root: { type: 'string', description: 'Project root path', required: false }, usage: { type: 'boolean', description: 'Analyze content key usage in source files', required: false }, + json: { type: 'boolean', description: 'Emit the raw DoctorReport JSON', required: false }, }, async run({ args }) { const projectRoot = await resolveProjectRoot(args.root) + const useJson = Boolean(args.json) - intro(pc.bold('contentrain doctor')) + if (useJson) { + // JSON mode is silent + machine-readable. Run the same report, + // dump it, and exit with a non-zero code if anything failed so + // CI consumers can wire it into pipelines. + const report = await runDoctor(projectRoot, { usage: Boolean(args.usage) }) + process.stdout.write(JSON.stringify(report, null, 2)) + if (report.summary.failed > 0) process.exitCode = 1 + return + } + intro(pc.bold('contentrain doctor')) const s = spinner() s.start('Running health checks...') + if (args.usage) s.message('Analyzing content key usage...') - const checks: CheckResult[] = [] - - // 1. Git installed - try { - const git = simpleGit(projectRoot) - const version = await git.version() - checks.push({ name: 'Git', pass: true, detail: `v${version.major}.${version.minor}.${version.patch}` }) - } catch { - checks.push({ name: 'Git', pass: false, detail: 'Not installed or not in PATH' }) - } - - // 2. Git repo initialized - const hasGit = await pathExists(join(projectRoot, '.git')) - checks.push({ - name: 'Git repository', - pass: hasGit, - detail: hasGit ? projectRoot : 'No .git directory found', - }) - - // 3. Node version - const nodeVersion = process.versions.node - const [major] = nodeVersion.split('.').map(Number) - checks.push({ - name: 'Node.js', - pass: (major ?? 0) >= 22, - detail: `v${nodeVersion}${(major ?? 0) < 22 ? ' (requires ≥22)' : ''}`, - }) - - // 4. .contentrain/ structure - const crDir = contentrainDir(projectRoot) - const hasCrDir = await pathExists(crDir) - const hasConfig = await pathExists(join(crDir, 'config.json')) - const hasModels = await pathExists(join(crDir, 'models')) - const hasContent = await pathExists(join(crDir, 'content')) - - checks.push({ - name: '.contentrain/ structure', - pass: hasCrDir && hasConfig && hasModels && hasContent, - detail: !hasCrDir - ? 'Not initialized — run `contentrain init`' - : [ - hasConfig ? null : 'missing config.json', - hasModels ? null : 'missing models/', - hasContent ? null : 'missing content/', - ].filter(Boolean).join(', ') || 'OK', - }) - - // 5. Config parseable - if (hasConfig) { - const config = await readConfig(projectRoot) - checks.push({ - name: 'Config', - pass: config !== null, - detail: config ? `stack: ${config.stack}, locales: ${config.locales.supported.join(', ')}` : 'Failed to parse config.json', - }) - } - - // 6. Models - if (hasCrDir) { - try { - const models = await listModels(projectRoot) - let allParseable = true - for (const m of models) { - const full = await readModel(projectRoot, m.id) - if (!full) allParseable = false - } - checks.push({ - name: 'Models', - pass: allParseable, - detail: `${models.length} model(s)${allParseable ? ', all valid' : ', some failed to parse'}`, - }) - } catch { - checks.push({ name: 'Models', pass: false, detail: 'Failed to read models' }) - } - } - - // 7. Orphan content (content dirs without matching model) - if (hasCrDir) { - const orphans = await findOrphanContent(projectRoot) - checks.push({ - name: 'Orphan content', - pass: orphans.length === 0, - detail: orphans.length === 0 ? 'None' : `Found: ${orphans.join(', ')}`, - }) - } - - // 8. Stale contentrain branches - if (hasGit) { - // Delegate to MCP's checkBranchHealth — it filters cr/* feature - // branches and applies the same warning / blocked thresholds - // MCP uses internally. Keeps doctor honest with the rest of the - // stack (previously this used a stale `contentrain/*` filter - // that returned zero after the Phase 7 naming migration, so the - // check was effectively a no-op). - try { - const health = await checkBranchHealth(projectRoot) - checks.push({ - name: 'Pending branches', - pass: !health.blocked && !health.warning, - detail: health.message - ?? (health.unmerged === 0 ? 'None' : `${health.unmerged} active cr/* branch(es)`), - }) - } catch { - checks.push({ name: 'Pending branches', pass: true, detail: 'Could not check' }) - } - } - - // 9. SDK client freshness - const clientDir = join(crDir, 'client') - const modelsDir = join(crDir, 'models') - if (await pathExists(clientDir) && await pathExists(modelsDir)) { - try { - const clientStat = await stat(clientDir) - const modelsStat = await stat(modelsDir) - const fresh = clientStat.mtimeMs >= modelsStat.mtimeMs - checks.push({ - name: 'SDK client', - pass: fresh, - detail: fresh ? 'Up to date' : 'Stale — run `contentrain generate`', - }) - } catch { - checks.push({ name: 'SDK client', pass: true, detail: 'Could not check' }) - } - } - - // 10–12. Content usage analysis (--usage flag) - let unusedKeysResult: UnusedKeyEntry[] = [] - let duplicateValuesResult: DuplicateValueEntry[] = [] - let missingLocaleResult: MissingLocaleEntry[] = [] - - if (args.usage && hasCrDir && hasConfig) { - s.message('Analyzing content key usage...') - - const config = await readConfig(projectRoot) - if (config) { - const [unused, dupes, missing] = await Promise.all([ - analyzeUnusedKeys(projectRoot, config), - analyzeDuplicateValues(projectRoot, config), - analyzeMissingLocaleKeys(projectRoot, config), - ]) - - unusedKeysResult = unused - duplicateValuesResult = dupes - missingLocaleResult = missing - - checks.push({ - name: 'Unused content keys', - pass: unused.length === 0, - detail: unused.length === 0 - ? 'All keys referenced in source' - : `${unused.length} key(s) not referenced in source code`, - }) - - checks.push({ - name: 'Duplicate dictionary values', - pass: dupes.length === 0, - detail: dupes.length === 0 - ? 'No duplicate values' - : `${dupes.length} value(s) mapped to multiple keys`, - }) - - checks.push({ - name: 'Locale key coverage', - pass: missing.length === 0, - detail: missing.length === 0 - ? 'All locales have matching keys' - : `${missing.length} key(s) missing in some locales`, - }) - } - } - + const report: DoctorReport = await runDoctor(projectRoot, { usage: Boolean(args.usage) }) s.stop('Health checks complete') - // Display results - const passed = checks.filter(c => c.pass).length - const failed = checks.filter(c => !c.pass).length - - for (const check of checks) { + // Display results — one line per check, then grouped usage detail. + for (const check of report.checks) { log.message(`${statusIcon(check.pass)} ${pc.bold(check.name)}: ${check.detail}`) } - // Detailed usage analysis output - if (unusedKeysResult.length > 0) { + if (report.usage?.unusedKeys.length) { log.message('') log.message(pc.bold(' Unused keys:')) - const grouped = groupBy(unusedKeysResult, e => e.model) + const grouped = groupBy(report.usage.unusedKeys, e => e.model) for (const [model, entries] of Object.entries(grouped)) { const keyList = entries.length <= 5 ? entries.map(e => e.key).join(', ') @@ -230,117 +62,37 @@ export default defineCommand({ } } - if (duplicateValuesResult.length > 0) { + if (report.usage?.duplicateValues.length) { log.message('') log.message(pc.bold(' Duplicate values:')) - for (const dv of duplicateValuesResult.slice(0, 10)) { + for (const dv of report.usage.duplicateValues.slice(0, 10)) { const truncated = dv.value.length > 30 ? `${dv.value.slice(0, 30)}...` : dv.value log.message(` ${pc.dim(`${dv.model}/${dv.locale}`)}: ${pc.yellow(`"${truncated}"`)} → [${dv.keys.join(', ')}]`) } - if (duplicateValuesResult.length > 10) { - log.message(` ... and ${duplicateValuesResult.length - 10} more`) + if (report.usage.duplicateValues.length > 10) { + log.message(` ... and ${report.usage.duplicateValues.length - 10} more`) } } - if (missingLocaleResult.length > 0) { + if (report.usage?.missingLocaleKeys.length) { log.message('') log.message(pc.bold(' Missing translations:')) - const grouped = groupBy(missingLocaleResult, e => `${e.model}/${e.missingIn}`) + const grouped = groupBy(report.usage.missingLocaleKeys, e => `${e.model}/${e.missingIn}`) for (const [label, entries] of Object.entries(grouped)) { log.message(` ${pc.dim(label)}: ${pc.yellow(`${entries.length} key(s)`)}`) } } + const { passed, failed } = report.summary if (failed === 0) { outro(pc.green(`All ${passed} checks passed!`)) } else { outro(pc.yellow(`${passed} passed, ${failed} failed`)) + process.exitCode = 1 } }, }) -async function findOrphanContent(projectRoot: string): Promise { - const crDir = contentrainDir(projectRoot) - const models = await listModels(projectRoot) - const orphans: string[] = [] - - // Build set of known content directories from model definitions - const knownContentDirs = new Set() - for (const m of models) { - const full = await readModel(projectRoot, m.id) - const modelForPath = full - ? { - ...full, - content_path: full.content_path ?? (m as { content_path?: string }).content_path, - } - : { - id: m.id, - name: m.id, - kind: m.kind, - domain: m.domain, - i18n: m.i18n, - fields: {}, - content_path: (m as { content_path?: string }).content_path, - } - - knownContentDirs.add(resolveContentDir(projectRoot, modelForPath)) - } - - // Scan default content tree - const contentDir = join(crDir, 'content') - if (await pathExists(contentDir)) { - const domains = await readDir(contentDir) - for (const domain of domains) { - const domainDir = join(contentDir, domain) - const entries = await readDir(domainDir) - for (const entry of entries) { - if (entry === '.gitkeep') continue - const entryDir = join(domainDir, entry) - if (!knownContentDirs.has(entryDir)) { - orphans.push(`${domain}/${entry}`) - } - } - } - } - - // Also verify custom content_path directories exist and are walked - for (const dir of knownContentDirs) { - if (dir.startsWith(contentDir)) continue - - if (!await pathExists(dir)) { - orphans.push(`(missing custom path) ${dir}`) - continue - } - - // Walk custom directories too so orphan detection covers non-default content trees. - await readDir(dir) - } - - return orphans -} - -// ─── Content usage analysis types ─── - -interface UnusedKeyEntry { - model: string - kind: string - key: string - locale: string -} - -interface DuplicateValueEntry { - model: string - locale: string - value: string - keys: string[] -} - -interface MissingLocaleEntry { - model: string - key: string - missingIn: string -} - function groupBy(arr: T[], keyFn: (item: T) => string): Record { const result: Record = {} for (const item of arr) { @@ -350,191 +102,3 @@ function groupBy(arr: T[], keyFn: (item: T) => string): Record { } return result } - -// ─── Unused keys: content keys not referenced in source files ─── - -async function analyzeUnusedKeys( - projectRoot: string, - config: ContentrainConfig, -): Promise { - const sourceDirs = await autoDetectSourceDirs(projectRoot) - const files = await discoverFiles(projectRoot, { paths: sourceDirs }) - - if (files.length === 0) return [] - - // Read all source files into a single string for fast substring search - const chunks = await Promise.all( - files.map(async (relPath) => { - const content = await readText(join(projectRoot, relPath)) - return content ?? '' - }), - ) - const allSource = chunks.join('\n') - - const models = await listModels(projectRoot) - const defaultLocale = config.locales.default - const unused: UnusedKeyEntry[] = [] - - for (const m of models) { - const fullModel = await readModel(projectRoot, m.id) - if (!fullModel) continue - - const keys = await extractContentKeys(projectRoot, fullModel, defaultLocale) - for (const key of keys) { - if (!allSource.includes(key)) { - unused.push({ model: m.id, kind: m.kind, key, locale: defaultLocale }) - } - } - } - - return unused -} - -async function extractContentKeys( - projectRoot: string, - model: ModelDefinition, - locale: string, -): Promise { - const cDir = resolveContentDir(projectRoot, model) - if (!await pathExists(cDir)) return [] - - switch (model.kind) { - case 'dictionary': { - const filePath = resolveJsonFilePath(cDir, model, locale) - const data = await readJson>(filePath) - return data ? Object.keys(data) : [] - } - - case 'collection': { - const filePath = resolveJsonFilePath(cDir, model, locale) - const data = await readJson>>(filePath) - return data ? Object.keys(data) : [] - } - - case 'document': { - const strategy = resolveLocaleStrategy(model) - const slugs: string[] = [] - - if (!model.i18n) { - const files = await readDir(cDir) - for (const f of files) { - if (f.endsWith('.md')) slugs.push(f.replace('.md', '')) - } - } else if (strategy === 'file') { - const dirs = await readDir(cDir) - for (const d of dirs) { - if (!d.startsWith('.')) slugs.push(d) - } - } else if (strategy === 'suffix') { - const files = await readDir(cDir) - const suffix = `.${locale}.md` - for (const f of files) { - if (f.endsWith(suffix)) slugs.push(f.slice(0, -suffix.length)) - } - } else if (strategy === 'directory') { - const localeDir = join(cDir, locale) - if (await pathExists(localeDir)) { - const files = await readDir(localeDir) - for (const f of files) { - if (f.endsWith('.md')) slugs.push(f.replace('.md', '')) - } - } - } else { - const files = await readDir(cDir) - for (const f of files) { - if (f.endsWith('.md')) slugs.push(f.replace('.md', '')) - } - } - - return slugs - } - - case 'singleton': - return [] - - default: - return [] - } -} - -// ─── Duplicate values: different dictionary keys mapping to same value ─── - -async function analyzeDuplicateValues( - projectRoot: string, - config: ContentrainConfig, -): Promise { - const models = await listModels(projectRoot) - const result: DuplicateValueEntry[] = [] - - for (const m of models) { - if (m.kind !== 'dictionary') continue - const fullModel = await readModel(projectRoot, m.id) - if (!fullModel) continue - - const cDir = resolveContentDir(projectRoot, fullModel) - for (const locale of config.locales.supported) { - const filePath = resolveJsonFilePath(cDir, fullModel, locale) - const data = await readJson>(filePath) - if (!data) continue - - const valueToKeys = new Map() - for (const [key, value] of Object.entries(data)) { - const arr = valueToKeys.get(value) - if (arr) arr.push(key) - else valueToKeys.set(value, [key]) - } - - for (const [value, keys] of valueToKeys) { - if (keys.length > 1) { - result.push({ model: m.id, locale, value, keys }) - } - } - } - } - - return result -} - -// ─── Missing locale keys: keys present in default locale but absent in others ─── - -async function analyzeMissingLocaleKeys( - projectRoot: string, - config: ContentrainConfig, -): Promise { - if (config.locales.supported.length < 2) return [] - - const models = await listModels(projectRoot) - const result: MissingLocaleEntry[] = [] - const defaultLocale = config.locales.default - const otherLocales = config.locales.supported.filter(l => l !== defaultLocale) - - for (const m of models) { - if (m.kind !== 'dictionary' && m.kind !== 'collection') continue - if (!m.i18n) continue - - const fullModel = await readModel(projectRoot, m.id) - if (!fullModel) continue - - const cDir = resolveContentDir(projectRoot, fullModel) - - // Read default locale keys - const defaultPath = resolveJsonFilePath(cDir, fullModel, defaultLocale) - const defaultData = await readJson>(defaultPath) - if (!defaultData) continue - const defaultKeys = new Set(Object.keys(defaultData)) - - for (const locale of otherLocales) { - const localePath = resolveJsonFilePath(cDir, fullModel, locale) - const localeData = await readJson>(localePath) - const localeKeys = localeData ? new Set(Object.keys(localeData)) : new Set() - - for (const key of defaultKeys) { - if (!localeKeys.has(key)) { - result.push({ model: m.id, key, missingIn: locale }) - } - } - } - } - - return result -} diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index b9c92a4..579b274 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -279,6 +279,17 @@ export async function createServeApp(options: ServeOptions) { return await callTool('contentrain_describe_format') })) + // Doctor — structured project health report wrapping the + // `contentrain_doctor` MCP tool. `?usage=true` opts into the heavier + // content-key usage analysis (unused keys, duplicate dictionary + // values, locale coverage). The UI renders each check row-by-row + // with its severity (error / warning / info). + router.add('/api/doctor', defineEventHandler(async (event) => { + const query = getQuery(event) + const usage = query['usage'] === 'true' || query['usage'] === '1' + return await callTool('contentrain_doctor', { usage }) + })) + // Content list router.add('/api/content/:modelId', defineEventHandler(async (event) => { const modelId = getRouterParam(event, 'modelId') diff --git a/packages/cli/tests/commands/doctor.test.ts b/packages/cli/tests/commands/doctor.test.ts index dabc95e..e01d91c 100644 --- a/packages/cli/tests/commands/doctor.test.ts +++ b/packages/cli/tests/commands/doctor.test.ts @@ -1,57 +1,41 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -const branchMock = vi.fn().mockResolvedValue({ all: ['cr/new/init'] }) -const readDirMock = vi.fn().mockResolvedValue([]) -const outroMock = vi.fn() - -// Mock all external dependencies -vi.mock('simple-git', () => ({ - simpleGit: vi.fn(() => ({ - version: vi.fn().mockResolvedValue({ major: 2, minor: 45, patch: 0 }), - branch: branchMock, - })), -})) +// After the phase-14c delegation, the CLI `doctor` command is a thin +// pretty-printer on top of `@contentrain/mcp`'s `runDoctor()`. These +// tests cover the CLI-specific surface: argument wiring, output +// routing (JSON vs interactive), and exit-code semantics. All health- +// check logic is tested in @contentrain/mcp/tests/core/doctor.test.ts. -vi.mock('@contentrain/mcp/core/config', () => ({ - readConfig: vi.fn().mockResolvedValue({ - version: 1, - stack: 'next', - workflow: 'auto-merge', - locales: { default: 'en', supported: ['en'] }, - domains: ['marketing'], - }), -})) +const runDoctorMock = vi.fn() +const outroMock = vi.fn() +const logMessageMock = vi.fn() -vi.mock('@contentrain/mcp/core/model-manager', () => ({ - listModels: vi.fn().mockResolvedValue([ - { id: 'hero', kind: 'singleton', domain: 'marketing', i18n: false, fields: 3 }, - ]), - readModel: vi.fn().mockResolvedValue({ - id: 'hero', - name: 'Hero', - kind: 'singleton', - domain: 'marketing', - i18n: false, - fields: { title: { type: 'string' } }, - }), +vi.mock('@contentrain/mcp/core/doctor', () => ({ + runDoctor: runDoctorMock, })) -vi.mock('@contentrain/mcp/util/fs', () => ({ - pathExists: vi.fn().mockResolvedValue(true), - contentrainDir: vi.fn((root: string) => `${root}/.contentrain`), - readDir: readDirMock, +vi.mock('../../src/utils/context.js', () => ({ + resolveProjectRoot: vi.fn(async (r?: string) => r ?? '/test/project'), })) vi.mock('@clack/prompts', () => ({ intro: vi.fn(), outro: outroMock, - log: { message: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }, - spinner: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })), + log: { message: logMessageMock, success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }, + spinner: vi.fn(() => ({ start: vi.fn(), stop: vi.fn(), message: vi.fn() })), })) describe('doctor command', () => { beforeEach(() => { vi.clearAllMocks() + process.exitCode = undefined + runDoctorMock.mockResolvedValue({ + checks: [ + { name: 'Git', pass: true, detail: 'v2.45.0' }, + { name: '.contentrain/ structure', pass: true, detail: 'OK' }, + ], + summary: { total: 2, passed: 2, failed: 0, warnings: 0 }, + }) }) it('module loads without error', async () => { @@ -60,47 +44,84 @@ describe('doctor command', () => { expect(mod.default.meta?.name).toBe('doctor') }) - it('has correct args definition', async () => { + it('exposes --json, --usage, --root args', async () => { const mod = await import('../../src/commands/doctor.js') expect(mod.default.args?.root).toBeDefined() + expect(mod.default.args?.usage).toBeDefined() + expect(mod.default.args?.json).toBeDefined() }) - it('should not fail health when only 6 pending contentrain branches exist', async () => { - branchMock.mockResolvedValueOnce({ - all: Array.from({ length: 6 }, (_, i) => `cr/review/test-${i}`), - }) + it('delegates to runDoctor with the --usage flag', async () => { + const mod = await import('../../src/commands/doctor.js') + await mod.default.run?.({ args: { root: '/test/project', usage: true } } as never) + + expect(runDoctorMock).toHaveBeenCalledWith('/test/project', { usage: true }) + }) + it('emits raw JSON on --json and exits clean when no failures', async () => { + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) const mod = await import('../../src/commands/doctor.js') - await mod.default.run?.({ args: { root: '/test/project' } }) + await mod.default.run?.({ args: { root: '/test/project', json: true } } as never) - expect(outroMock).toHaveBeenCalledWith(expect.not.stringContaining('failed')) + expect(writeSpy).toHaveBeenCalledTimes(1) + const payload = JSON.parse(writeSpy.mock.calls[0]?.[0] as string) + expect(payload.summary.total).toBe(2) + expect(process.exitCode).toBeUndefined() + writeSpy.mockRestore() }) - it('should inspect custom content_path locations for orphan content checks', async () => { - const { listModels, readModel } = await import('@contentrain/mcp/core/model-manager') - vi.mocked(listModels).mockResolvedValueOnce([ - { - id: 'authors', - kind: 'collection', - domain: 'marketing', - i18n: true, - fields: 2, - content_path: 'src/content/authors', - } as never, - ]) - vi.mocked(readModel).mockResolvedValue({ - id: 'authors', - name: 'Authors', - kind: 'collection', - domain: 'marketing', - i18n: true, - fields: { name: { type: 'string' } }, - content_path: 'src/content/authors', - } as never) + it('sets exit code 1 when --json and the report has failures', async () => { + runDoctorMock.mockResolvedValueOnce({ + checks: [ + { name: 'Git', pass: false, detail: 'Not installed', severity: 'error' }, + ], + summary: { total: 1, passed: 0, failed: 1, warnings: 0 }, + }) + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + const mod = await import('../../src/commands/doctor.js') + await mod.default.run?.({ args: { root: '/test/project', json: true } } as never) + + expect(process.exitCode).toBe(1) + writeSpy.mockRestore() + }) + + it('renders the usage detail blocks when runDoctor includes a usage report', async () => { + runDoctorMock.mockResolvedValueOnce({ + checks: [ + { name: 'Unused content keys', pass: false, detail: '3 key(s)', severity: 'warning' }, + { name: 'Duplicate dictionary values', pass: false, detail: '1 value', severity: 'warning' }, + { name: 'Locale key coverage', pass: true, detail: 'All locales have matching keys' }, + ], + summary: { total: 3, passed: 1, failed: 2, warnings: 2 }, + usage: { + unusedKeys: [ + { model: 'docs', kind: 'dictionary', key: 'hero.title', locale: 'en' }, + { model: 'docs', kind: 'dictionary', key: 'cta.label', locale: 'en' }, + ], + duplicateValues: [ + { model: 'docs', locale: 'en', value: 'Get started', keys: ['hero.cta', 'footer.cta'] }, + ], + missingLocaleKeys: [], + }, + }) + const mod = await import('../../src/commands/doctor.js') + await mod.default.run?.({ args: { root: '/test/project', usage: true } } as never) + const messages = logMessageMock.mock.calls.map(c => String(c[0])) + expect(messages.some(m => m.includes('Unused keys:'))).toBe(true) + expect(messages.some(m => m.includes('hero.title'))).toBe(true) + expect(messages.some(m => m.includes('Duplicate values:'))).toBe(true) + }) + + it('sets exit code 1 when the interactive run has any failed check', async () => { + runDoctorMock.mockResolvedValueOnce({ + checks: [{ name: 'Git', pass: false, detail: 'Not installed', severity: 'error' }], + summary: { total: 1, passed: 0, failed: 1, warnings: 0 }, + }) const mod = await import('../../src/commands/doctor.js') - await mod.default.run?.({ args: { root: '/test/project' } }) + await mod.default.run?.({ args: { root: '/test/project' } } as never) - expect(readDirMock).toHaveBeenCalledWith('/test/project/src/content/authors') + expect(outroMock).toHaveBeenCalledWith(expect.stringContaining('failed')) + expect(process.exitCode).toBe(1) }) }) diff --git a/packages/cli/tests/integration/serve.integration.test.ts b/packages/cli/tests/integration/serve.integration.test.ts index 0e56098..8af67d9 100644 --- a/packages/cli/tests/integration/serve.integration.test.ts +++ b/packages/cli/tests/integration/serve.integration.test.ts @@ -485,6 +485,31 @@ describe('serve server contract', { sequential: true }, () => { }) }) + it('/api/doctor wraps contentrain_doctor and forwards ?usage=true', async () => { + await boot() + + const handler = routes.get('/api/doctor') + expect(handler).toBeDefined() + + await handler?.({ query: {} }) + expect(callToolMock).toHaveBeenLastCalledWith({ + name: 'contentrain_doctor', + arguments: { usage: false }, + }) + + await handler?.({ query: { usage: 'true' } }) + expect(callToolMock).toHaveBeenLastCalledWith({ + name: 'contentrain_doctor', + arguments: { usage: true }, + }) + + await handler?.({ query: { usage: '1' } }) + expect(callToolMock).toHaveBeenLastCalledWith({ + name: 'contentrain_doctor', + arguments: { usage: true }, + }) + }) + it('exposes /api/preview/merge and rejects requests for non-cr branches', async () => { await boot() diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 5e229c4..0b099be 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -84,6 +84,10 @@ "types": "./dist/core/scan-config.d.mts", "import": "./dist/core/scan-config.mjs" }, + "./core/doctor": { + "types": "./dist/core/doctor.d.mts", + "import": "./dist/core/doctor.mjs" + }, "./core/contracts": { "types": "./dist/core/contracts/index.d.mts", "import": "./dist/core/contracts/index.mjs" @@ -135,8 +139,8 @@ "dist" ], "scripts": { - "build": "tsdown src/index.ts src/server.ts src/core/config.ts src/core/context.ts src/core/model-manager.ts src/core/content-manager.ts src/core/meta-manager.ts src/core/validator/index.ts src/core/scanner.ts src/core/scan-config.ts src/core/graph-builder.ts src/core/apply-manager.ts src/util/detect.ts src/util/fs.ts src/util/id.ts src/git/transaction.ts src/git/branch-lifecycle.ts src/templates/index.ts src/core/contracts/index.ts src/core/ops/index.ts src/core/overlay-reader.ts src/providers/local/index.ts src/providers/github/index.ts src/providers/gitlab/index.ts src/server/http/index.ts --format esm --dts --external typescript", - "dev": "tsdown src/index.ts src/server.ts src/core/config.ts src/core/context.ts src/core/model-manager.ts src/core/content-manager.ts src/core/meta-manager.ts src/core/validator/index.ts src/core/scanner.ts src/core/scan-config.ts src/core/graph-builder.ts src/core/apply-manager.ts src/util/detect.ts src/util/fs.ts src/util/id.ts src/git/transaction.ts src/git/branch-lifecycle.ts src/templates/index.ts src/core/contracts/index.ts src/core/ops/index.ts src/core/overlay-reader.ts src/providers/local/index.ts src/providers/github/index.ts src/providers/gitlab/index.ts src/server/http/index.ts --format esm --dts --external typescript --watch", + "build": "tsdown src/index.ts src/server.ts src/core/config.ts src/core/context.ts src/core/model-manager.ts src/core/content-manager.ts src/core/meta-manager.ts src/core/validator/index.ts src/core/scanner.ts src/core/scan-config.ts src/core/doctor.ts src/core/graph-builder.ts src/core/apply-manager.ts src/util/detect.ts src/util/fs.ts src/util/id.ts src/git/transaction.ts src/git/branch-lifecycle.ts src/templates/index.ts src/core/contracts/index.ts src/core/ops/index.ts src/core/overlay-reader.ts src/providers/local/index.ts src/providers/github/index.ts src/providers/gitlab/index.ts src/server/http/index.ts --format esm --dts --external typescript", + "dev": "tsdown src/index.ts src/server.ts src/core/config.ts src/core/context.ts src/core/model-manager.ts src/core/content-manager.ts src/core/meta-manager.ts src/core/validator/index.ts src/core/scanner.ts src/core/scan-config.ts src/core/doctor.ts src/core/graph-builder.ts src/core/apply-manager.ts src/util/detect.ts src/util/fs.ts src/util/id.ts src/git/transaction.ts src/git/branch-lifecycle.ts src/templates/index.ts src/core/contracts/index.ts src/core/ops/index.ts src/core/overlay-reader.ts src/providers/local/index.ts src/providers/github/index.ts src/providers/gitlab/index.ts src/server/http/index.ts --format esm --dts --external typescript --watch", "test": "vitest run", "typecheck": "tsc --noEmit", "clean": "rm -rf dist" diff --git a/packages/mcp/src/core/doctor.ts b/packages/mcp/src/core/doctor.ts new file mode 100644 index 0000000..a70313c --- /dev/null +++ b/packages/mcp/src/core/doctor.ts @@ -0,0 +1,491 @@ +import { join } from 'node:path' +import { stat } from 'node:fs/promises' +import { simpleGit } from 'simple-git' +import type { ContentrainConfig, ModelDefinition } from '@contentrain/types' +import { readConfig } from './config.js' +import { listModels, readModel } from './model-manager.js' +import { resolveContentDir, resolveJsonFilePath, resolveLocaleStrategy } from './content-manager.js' +import { autoDetectSourceDirs, discoverFiles } from './scan-config.js' +import { checkBranchHealth } from '../git/branch-lifecycle.js' +import { contentrainDir, pathExists, readDir, readJson, readText } from '../util/fs.js' + +/** + * Doctor — project health report. + * + * The public entry point is `runDoctor(projectRoot, { usage? })`. It is + * inherently local-filesystem work (Node version, git install, file + * mtimes, orphan directory detection), so the MCP tool surface gates + * it behind the `localWorktree` capability — same pattern as + * `contentrain_setup` and normalize. + * + * The report is structured JSON so three consumers can share it: + * + * - The `contentrain doctor` CLI command pretty-prints the checks. + * - The Serve UI `/api/doctor` route returns the report to the + * Dashboard's Doctor panel. + * - Automation (CI, Studio) gets a deterministic JSON shape it can + * assert against. + * + * Usage analysis (`--usage`) is a heavier, opt-in branch — it scans + * every source file in the repo for content-key references. Kept + * behind the flag so the default doctor run stays fast. + */ + +export type CheckSeverity = 'error' | 'warning' | 'info' + +export interface DoctorCheck { + name: string + pass: boolean + detail: string + /** + * `error` — default for failing checks. Blocks a clean bill of health. + * `warning` — failing-but-not-blocking (e.g. pending branches above + * threshold, stale SDK client). + * `info` — passed check; pure informational. + */ + severity?: CheckSeverity +} + +export interface UnusedKeyEntry { + model: string + kind: string + key: string + locale: string +} + +export interface DuplicateValueEntry { + model: string + locale: string + value: string + keys: string[] +} + +export interface MissingLocaleEntry { + model: string + key: string + missingIn: string +} + +export interface DoctorUsageAnalysis { + unusedKeys: UnusedKeyEntry[] + duplicateValues: DuplicateValueEntry[] + missingLocaleKeys: MissingLocaleEntry[] +} + +export interface DoctorReport { + checks: DoctorCheck[] + summary: { + total: number + passed: number + failed: number + warnings: number + } + /** Present only when `options.usage === true`. */ + usage?: DoctorUsageAnalysis +} + +export interface RunDoctorOptions { + /** Run heavier `--usage` analysis (unused keys, duplicates, locale gaps). */ + usage?: boolean +} + +export async function runDoctor( + projectRoot: string, + options: RunDoctorOptions = {}, +): Promise { + const checks: DoctorCheck[] = [] + + // ─── 1. Git installed ─── + try { + const git = simpleGit(projectRoot) + const version = await git.version() + checks.push({ + name: 'Git', + pass: true, + detail: `v${version.major}.${version.minor}.${version.patch}`, + }) + } catch { + checks.push({ name: 'Git', pass: false, detail: 'Not installed or not in PATH', severity: 'error' }) + } + + // ─── 2. Git repo initialized ─── + const hasGit = await pathExists(join(projectRoot, '.git')) + checks.push({ + name: 'Git repository', + pass: hasGit, + detail: hasGit ? projectRoot : 'No .git directory found', + severity: hasGit ? undefined : 'error', + }) + + // ─── 3. Node version ─── + const nodeVersion = process.versions.node + const [major] = nodeVersion.split('.').map(Number) + const nodePass = (major ?? 0) >= 22 + checks.push({ + name: 'Node.js', + pass: nodePass, + detail: `v${nodeVersion}${nodePass ? '' : ' (requires ≥22)'}`, + severity: nodePass ? undefined : 'error', + }) + + // ─── 4. .contentrain/ structure ─── + const crDir = contentrainDir(projectRoot) + const hasCrDir = await pathExists(crDir) + const hasConfig = await pathExists(join(crDir, 'config.json')) + const hasModels = await pathExists(join(crDir, 'models')) + const hasContent = await pathExists(join(crDir, 'content')) + const structurePass = hasCrDir && hasConfig && hasModels && hasContent + + checks.push({ + name: '.contentrain/ structure', + pass: structurePass, + detail: !hasCrDir + ? 'Not initialized — run `contentrain init`' + : [ + hasConfig ? null : 'missing config.json', + hasModels ? null : 'missing models/', + hasContent ? null : 'missing content/', + ].filter(Boolean).join(', ') || 'OK', + severity: structurePass ? undefined : 'error', + }) + + // ─── 5. Config parseable ─── + let config: ContentrainConfig | null = null + if (hasConfig) { + config = await readConfig(projectRoot) + checks.push({ + name: 'Config', + pass: config !== null, + detail: config + ? `stack: ${config.stack}, locales: ${config.locales.supported.join(', ')}` + : 'Failed to parse config.json', + severity: config ? undefined : 'error', + }) + } + + // ─── 6. Models all parseable ─── + if (hasCrDir) { + try { + const models = await listModels(projectRoot) + const parseResults = await Promise.all(models.map(m => readModel(projectRoot, m.id))) + const allParseable = parseResults.every(r => r !== null) + checks.push({ + name: 'Models', + pass: allParseable, + detail: `${models.length} model(s)${allParseable ? ', all valid' : ', some failed to parse'}`, + severity: allParseable ? undefined : 'error', + }) + } catch { + checks.push({ name: 'Models', pass: false, detail: 'Failed to read models', severity: 'error' }) + } + } + + // ─── 7. Orphan content ─── + if (hasCrDir) { + const orphans = await findOrphanContent(projectRoot) + checks.push({ + name: 'Orphan content', + pass: orphans.length === 0, + detail: orphans.length === 0 ? 'None' : `Found: ${orphans.join(', ')}`, + severity: orphans.length === 0 ? undefined : 'warning', + }) + } + + // ─── 8. Stale contentrain branches ─── + if (hasGit) { + try { + const health = await checkBranchHealth(projectRoot) + checks.push({ + name: 'Pending branches', + pass: !health.blocked && !health.warning, + detail: health.message + ?? (health.unmerged === 0 ? 'None' : `${health.unmerged} active cr/* branch(es)`), + severity: health.blocked ? 'error' : health.warning ? 'warning' : undefined, + }) + } catch { + checks.push({ name: 'Pending branches', pass: true, detail: 'Could not check' }) + } + } + + // ─── 9. SDK client freshness ─── + const clientDir = join(crDir, 'client') + const modelsDir = join(crDir, 'models') + if (await pathExists(clientDir) && await pathExists(modelsDir)) { + try { + const [clientStat, modelsStat] = await Promise.all([stat(clientDir), stat(modelsDir)]) + const fresh = clientStat.mtimeMs >= modelsStat.mtimeMs + checks.push({ + name: 'SDK client', + pass: fresh, + detail: fresh ? 'Up to date' : 'Stale — run `contentrain generate`', + severity: fresh ? undefined : 'warning', + }) + } catch { + checks.push({ name: 'SDK client', pass: true, detail: 'Could not check' }) + } + } + + // ─── 10–12. Usage analysis (optional) ─── + let usage: DoctorUsageAnalysis | undefined + if (options.usage && hasCrDir && config) { + const [unusedKeys, duplicateValues, missingLocaleKeys] = await Promise.all([ + analyzeUnusedKeys(projectRoot, config), + analyzeDuplicateValues(projectRoot, config), + analyzeMissingLocaleKeys(projectRoot, config), + ]) + usage = { unusedKeys, duplicateValues, missingLocaleKeys } + + checks.push({ + name: 'Unused content keys', + pass: unusedKeys.length === 0, + detail: unusedKeys.length === 0 + ? 'All keys referenced in source' + : `${unusedKeys.length} key(s) not referenced in source code`, + severity: unusedKeys.length === 0 ? undefined : 'warning', + }) + + checks.push({ + name: 'Duplicate dictionary values', + pass: duplicateValues.length === 0, + detail: duplicateValues.length === 0 + ? 'No duplicate values' + : `${duplicateValues.length} value(s) mapped to multiple keys`, + severity: duplicateValues.length === 0 ? undefined : 'warning', + }) + + checks.push({ + name: 'Locale key coverage', + pass: missingLocaleKeys.length === 0, + detail: missingLocaleKeys.length === 0 + ? 'All locales have matching keys' + : `${missingLocaleKeys.length} key(s) missing in some locales`, + severity: missingLocaleKeys.length === 0 ? undefined : 'warning', + }) + } + + const passed = checks.filter(c => c.pass).length + const failed = checks.length - passed + const warnings = checks.filter(c => !c.pass && c.severity === 'warning').length + + const report: DoctorReport = { + checks, + summary: { total: checks.length, passed, failed, warnings }, + } + if (usage) report.usage = usage + return report +} + +async function findOrphanContent(projectRoot: string): Promise { + const crDir = contentrainDir(projectRoot) + const models = await listModels(projectRoot) + const orphans: string[] = [] + + const knownContentDirs = new Set() + for (const m of models) { + const full = await readModel(projectRoot, m.id) + const modelForPath = full + ? { + ...full, + content_path: full.content_path ?? (m as { content_path?: string }).content_path, + } + : { + id: m.id, + name: m.id, + kind: m.kind, + domain: m.domain, + i18n: m.i18n, + fields: {}, + content_path: (m as { content_path?: string }).content_path, + } + knownContentDirs.add(resolveContentDir(projectRoot, modelForPath)) + } + + const contentDir = join(crDir, 'content') + if (await pathExists(contentDir)) { + const domains = await readDir(contentDir) + for (const domain of domains) { + const domainDir = join(contentDir, domain) + const entries = await readDir(domainDir) + for (const entry of entries) { + if (entry === '.gitkeep') continue + const entryDir = join(domainDir, entry) + if (!knownContentDirs.has(entryDir)) { + orphans.push(`${domain}/${entry}`) + } + } + } + } + + for (const dir of knownContentDirs) { + if (dir.startsWith(contentDir)) continue + if (!await pathExists(dir)) { + orphans.push(`(missing custom path) ${dir}`) + continue + } + await readDir(dir) + } + + return orphans +} + +async function analyzeUnusedKeys( + projectRoot: string, + config: ContentrainConfig, +): Promise { + const sourceDirs = await autoDetectSourceDirs(projectRoot) + const files = await discoverFiles(projectRoot, { paths: sourceDirs }) + if (files.length === 0) return [] + + const chunks = await Promise.all( + files.map(async (relPath) => { + const content = await readText(join(projectRoot, relPath)) + return content ?? '' + }), + ) + const allSource = chunks.join('\n') + + const models = await listModels(projectRoot) + const defaultLocale = config.locales.default + const unused: UnusedKeyEntry[] = [] + + for (const m of models) { + const fullModel = await readModel(projectRoot, m.id) + if (!fullModel) continue + + const keys = await extractContentKeys(projectRoot, fullModel, defaultLocale) + for (const key of keys) { + if (!allSource.includes(key)) { + unused.push({ model: m.id, kind: m.kind, key, locale: defaultLocale }) + } + } + } + + return unused +} + +async function extractContentKeys( + projectRoot: string, + model: ModelDefinition, + locale: string, +): Promise { + const cDir = resolveContentDir(projectRoot, model) + if (!await pathExists(cDir)) return [] + + switch (model.kind) { + case 'dictionary': { + const filePath = resolveJsonFilePath(cDir, model, locale) + const data = await readJson>(filePath) + return data ? Object.keys(data) : [] + } + case 'collection': { + const filePath = resolveJsonFilePath(cDir, model, locale) + const data = await readJson>>(filePath) + return data ? Object.keys(data) : [] + } + case 'document': { + const strategy = resolveLocaleStrategy(model) + const slugs: string[] = [] + if (!model.i18n) { + const files = await readDir(cDir) + for (const f of files) if (f.endsWith('.md')) slugs.push(f.replace('.md', '')) + } else if (strategy === 'file') { + const dirs = await readDir(cDir) + for (const d of dirs) if (!d.startsWith('.')) slugs.push(d) + } else if (strategy === 'suffix') { + const files = await readDir(cDir) + const suffix = `.${locale}.md` + for (const f of files) if (f.endsWith(suffix)) slugs.push(f.slice(0, -suffix.length)) + } else if (strategy === 'directory') { + const localeDir = join(cDir, locale) + if (await pathExists(localeDir)) { + const files = await readDir(localeDir) + for (const f of files) if (f.endsWith('.md')) slugs.push(f.replace('.md', '')) + } + } else { + const files = await readDir(cDir) + for (const f of files) if (f.endsWith('.md')) slugs.push(f.replace('.md', '')) + } + return slugs + } + case 'singleton': + return [] + default: + return [] + } +} + +async function analyzeDuplicateValues( + projectRoot: string, + config: ContentrainConfig, +): Promise { + const models = await listModels(projectRoot) + const result: DuplicateValueEntry[] = [] + + for (const m of models) { + if (m.kind !== 'dictionary') continue + const fullModel = await readModel(projectRoot, m.id) + if (!fullModel) continue + + const cDir = resolveContentDir(projectRoot, fullModel) + for (const locale of config.locales.supported) { + const filePath = resolveJsonFilePath(cDir, fullModel, locale) + const data = await readJson>(filePath) + if (!data) continue + + const valueToKeys = new Map() + for (const [key, value] of Object.entries(data)) { + const arr = valueToKeys.get(value) + if (arr) arr.push(key) + else valueToKeys.set(value, [key]) + } + + for (const [value, keys] of valueToKeys) { + if (keys.length > 1) { + result.push({ model: m.id, locale, value, keys }) + } + } + } + } + + return result +} + +async function analyzeMissingLocaleKeys( + projectRoot: string, + config: ContentrainConfig, +): Promise { + if (config.locales.supported.length < 2) return [] + + const models = await listModels(projectRoot) + const result: MissingLocaleEntry[] = [] + const defaultLocale = config.locales.default + const otherLocales = config.locales.supported.filter(l => l !== defaultLocale) + + for (const m of models) { + if (m.kind !== 'dictionary' && m.kind !== 'collection') continue + if (!m.i18n) continue + + const fullModel = await readModel(projectRoot, m.id) + if (!fullModel) continue + + const cDir = resolveContentDir(projectRoot, fullModel) + const defaultPath = resolveJsonFilePath(cDir, fullModel, defaultLocale) + const defaultData = await readJson>(defaultPath) + if (!defaultData) continue + const defaultKeys = new Set(Object.keys(defaultData)) + + for (const locale of otherLocales) { + const localePath = resolveJsonFilePath(cDir, fullModel, locale) + const localeData = await readJson>(localePath) + const localeKeys = localeData ? new Set(Object.keys(localeData)) : new Set() + + for (const key of defaultKeys) { + if (!localeKeys.has(key)) { + result.push({ model: m.id, key, missingIn: locale }) + } + } + } + } + + return result +} diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 7da5a09..48e3efd 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -17,6 +17,7 @@ import { registerContentTools } from './tools/content.js' import { registerWorkflowTools } from './tools/workflow.js' import { registerNormalizeTools } from './tools/normalize.js' import { registerBulkTools } from './tools/bulk.js' +import { registerDoctorTools } from './tools/doctor.js' import packageJson from '../package.json' with { type: 'json' } export interface CreateServerOptions { @@ -70,6 +71,7 @@ export function createServer(input: string | CreateServerOptions): McpServer { registerWorkflowTools(server, provider, projectRoot) registerNormalizeTools(server, provider, projectRoot) registerBulkTools(server, provider, projectRoot) + registerDoctorTools(server, provider, projectRoot) return server } diff --git a/packages/mcp/src/tools/annotations.ts b/packages/mcp/src/tools/annotations.ts index 7721714..b148bad 100644 --- a/packages/mcp/src/tools/annotations.ts +++ b/packages/mcp/src/tools/annotations.ts @@ -24,6 +24,12 @@ export const TOOL_ANNOTATIONS: Record = { destructiveHint: false, idempotentHint: true, }, + contentrain_doctor: { + title: 'Project Health Report', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, // ─── Setup (write + git) ─── contentrain_init: { diff --git a/packages/mcp/src/tools/doctor.ts b/packages/mcp/src/tools/doctor.ts new file mode 100644 index 0000000..714b0e3 --- /dev/null +++ b/packages/mcp/src/tools/doctor.ts @@ -0,0 +1,29 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { z } from 'zod' +import type { ToolProvider } from '../server.js' +import { runDoctor } from '../core/doctor.js' +import { TOOL_ANNOTATIONS } from './annotations.js' +import { capabilityError } from './guards.js' + +export function registerDoctorTools( + server: McpServer, + _provider: ToolProvider, + projectRoot: string | undefined, +): void { + server.tool( + 'contentrain_doctor', + 'Project health report (read-only). Returns structured checks: git, node, .contentrain/ structure, model parse, orphan content, branch pressure, SDK freshness. Pass `usage: true` for a deeper analysis of content-key references in source files (unused keys, duplicate dictionary values, locale coverage). Local-filesystem only — unavailable over remote providers.', + { + usage: z.boolean().optional().default(false).describe('Run the heavier usage-analysis branch (unused keys, duplicate values, missing locales). Default: false.'), + }, + TOOL_ANNOTATIONS['contentrain_doctor']!, + async ({ usage }) => { + if (!projectRoot) return capabilityError('contentrain_doctor', 'localWorktree') + + const report = await runDoctor(projectRoot, { usage }) + return { + content: [{ type: 'text' as const, text: JSON.stringify(report, null, 2) }], + } + }, + ) +} diff --git a/packages/mcp/tests/core/doctor.test.ts b/packages/mcp/tests/core/doctor.test.ts new file mode 100644 index 0000000..870eb61 --- /dev/null +++ b/packages/mcp/tests/core/doctor.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { simpleGit } from 'simple-git' +import { runDoctor } from '../../src/core/doctor.js' + +async function writeFileSafe(path: string, content: string): Promise { + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, content) +} + +vi.setConfig({ testTimeout: 30_000, hookTimeout: 30_000 }) + +let testDir: string + +async function seedMinimalProject(root: string) { + const git = simpleGit(root) + await git.init() + await git.addConfig('user.name', 'Test') + await git.addConfig('user.email', 'test@contentrain.io') + await writeFileSafe(join(root, 'README.md'), '# test\n') + await git.add('.') + await git.commit('initial') + + await writeFileSafe(join(root, '.contentrain', 'config.json'), JSON.stringify({ + version: 1, + stack: 'nuxt', + workflow: 'auto-merge', + locales: { default: 'en', supported: ['en', 'tr'] }, + domains: ['marketing'], + }, null, 2)) + + await mkdir(join(root, '.contentrain', 'models'), { recursive: true }) + await writeFileSafe(join(root, '.contentrain', 'models', 'hero.json'), JSON.stringify({ + id: 'hero', + name: 'Hero', + kind: 'singleton', + domain: 'marketing', + fields: { title: { type: 'string', required: true } }, + }, null, 2)) + + await mkdir(join(root, '.contentrain', 'content', 'marketing', 'hero'), { recursive: true }) + await writeFileSafe( + join(root, '.contentrain', 'content', 'marketing', 'hero', 'en.json'), + JSON.stringify({ title: 'Welcome' }, null, 2), + ) + await writeFileSafe( + join(root, '.contentrain', 'content', 'marketing', 'hero', 'tr.json'), + JSON.stringify({ title: 'Hoşgeldin' }, null, 2), + ) +} + +beforeEach(async () => { + testDir = await mkdtemp(join(tmpdir(), 'cr-doctor-')) +}) + +afterEach(async () => { + await rm(testDir, { recursive: true, force: true }) +}) + +describe('runDoctor', () => { + it('reports an uninitialised project', async () => { + const git = simpleGit(testDir) + await git.init() + + const report = await runDoctor(testDir) + + const structure = report.checks.find(c => c.name === '.contentrain/ structure') + expect(structure).toBeDefined() + expect(structure?.pass).toBe(false) + expect(structure?.detail).toMatch(/Not initialized/u) + expect(structure?.severity).toBe('error') + }) + + it('passes a minimal valid project on the base checks', async () => { + await seedMinimalProject(testDir) + const report = await runDoctor(testDir) + + const passed = report.checks.filter(c => c.pass).map(c => c.name) + expect(passed).toContain('Git') + expect(passed).toContain('Git repository') + expect(passed).toContain('.contentrain/ structure') + expect(passed).toContain('Config') + expect(passed).toContain('Models') + expect(passed).toContain('Orphan content') + + expect(report.summary.total).toBe(report.checks.length) + expect(report.summary.passed + report.summary.failed).toBe(report.summary.total) + }) + + it('flags orphan content directories with warning severity', async () => { + await seedMinimalProject(testDir) + // An unmodelled content directory — orphan. + await mkdir(join(testDir, '.contentrain', 'content', 'marketing', 'stranger'), { recursive: true }) + await writeFileSafe( + join(testDir, '.contentrain', 'content', 'marketing', 'stranger', 'en.json'), + '{}\n', + ) + + const report = await runDoctor(testDir) + const orphan = report.checks.find(c => c.name === 'Orphan content') + expect(orphan?.pass).toBe(false) + expect(orphan?.severity).toBe('warning') + expect(orphan?.detail).toContain('marketing/stranger') + }) + + it('omits the usage block by default', async () => { + await seedMinimalProject(testDir) + const report = await runDoctor(testDir) + expect(report.usage).toBeUndefined() + expect(report.checks.find(c => c.name === 'Unused content keys')).toBeUndefined() + }) + + it('adds the usage block + 3 extra checks when { usage: true }', async () => { + await seedMinimalProject(testDir) + const report = await runDoctor(testDir, { usage: true }) + expect(report.usage).toBeDefined() + expect(Array.isArray(report.usage?.unusedKeys)).toBe(true) + expect(Array.isArray(report.usage?.duplicateValues)).toBe(true) + expect(Array.isArray(report.usage?.missingLocaleKeys)).toBe(true) + + const usageCheckNames = report.checks.filter(c => + ['Unused content keys', 'Duplicate dictionary values', 'Locale key coverage'].includes(c.name), + ).map(c => c.name) + expect(usageCheckNames).toEqual([ + 'Unused content keys', + 'Duplicate dictionary values', + 'Locale key coverage', + ]) + }) + + it('flags a stale SDK client (models dir newer than client dir) as a warning', async () => { + await seedMinimalProject(testDir) + // Create client BEFORE touching models so client's mtime is older. + const clientDir = join(testDir, '.contentrain', 'client') + await mkdir(clientDir, { recursive: true }) + await writeFileSafe(join(clientDir, 'index.mjs'), '// generated\n') + // Wait a tick so the next mkdir/write produces a strictly newer mtime. + await new Promise(r => setTimeout(r, 20)) + await writeFileSafe(join(testDir, '.contentrain', 'models', 'new-model.json'), JSON.stringify({ + id: 'new-model', name: 'New', kind: 'singleton', domain: 'marketing', fields: {}, + })) + + const report = await runDoctor(testDir) + const sdk = report.checks.find(c => c.name === 'SDK client') + expect(sdk).toBeDefined() + expect(sdk?.pass).toBe(false) + expect(sdk?.severity).toBe('warning') + }) +}) diff --git a/packages/mcp/tests/tools/doctor.test.ts b/packages/mcp/tests/tools/doctor.test.ts new file mode 100644 index 0000000..d125a8b --- /dev/null +++ b/packages/mcp/tests/tools/doctor.test.ts @@ -0,0 +1,102 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { createServer } from '../../src/server.js' +import { GitHubProvider } from '../../src/providers/github/provider.js' +import type { GitHubClient } from '../../src/providers/github/client.js' + +const FIXTURE = join(import.meta.dirname, '..', 'fixtures') + +vi.setConfig({ testTimeout: 30_000, hookTimeout: 30_000 }) + +async function createTestClient(projectRoot: string) { + const server = createServer(projectRoot) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + const client = new Client({ name: 'test-client', version: '1.0.0' }) + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]) + return client +} + +let cleanupDirs: string[] = [] + +beforeEach(() => { + cleanupDirs = [] +}) + +afterEach(async () => { + for (const dir of cleanupDirs) { + await rm(dir, { recursive: true, force: true }) + } +}) + +describe('contentrain_doctor tool', () => { + it('returns a structured report over the fixture', async () => { + const client = await createTestClient(FIXTURE) + const result = await client.callTool({ name: 'contentrain_doctor', arguments: {} }) + + const content = result.content as Array<{ type: string, text: string }> + const report = JSON.parse(content[0]!.text) + + expect(report).toHaveProperty('checks') + expect(report).toHaveProperty('summary') + expect(Array.isArray(report.checks)).toBe(true) + expect(report.summary).toMatchObject({ + total: expect.any(Number), + passed: expect.any(Number), + failed: expect.any(Number), + warnings: expect.any(Number), + }) + expect(report.summary.total).toBe(report.checks.length) + expect(report.usage).toBeUndefined() + }) + + it('opts into usage analysis on { usage: true }', async () => { + const client = await createTestClient(FIXTURE) + const result = await client.callTool({ + name: 'contentrain_doctor', + arguments: { usage: true }, + }) + + const content = result.content as Array<{ type: string, text: string }> + const report = JSON.parse(content[0]!.text) + + expect(report.usage).toBeDefined() + expect(report.usage).toHaveProperty('unusedKeys') + expect(report.usage).toHaveProperty('duplicateValues') + expect(report.usage).toHaveProperty('missingLocaleKeys') + }) + + it('returns a capability error when driven by a remote provider (no projectRoot)', async () => { + const fakeClient = {} as unknown as GitHubClient + const provider = new GitHubProvider(fakeClient, { owner: 'acme', name: 'site' }) + const server = createServer({ provider }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + const client = new Client({ name: 'test-client', version: '1.0.0' }) + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]) + + const result = await client.callTool({ name: 'contentrain_doctor', arguments: {} }) + expect(result.isError).toBe(true) + const content = result.content as Array<{ type: string, text: string }> + const data = JSON.parse(content[0]!.text) + expect(data.capability_required).toBe('localWorktree') + }) + + it('is advertised in the tools list', async () => { + const tmpDir = await mkdtemp(join(tmpdir(), 'cr-doctor-advert-')) + cleanupDirs.push(tmpDir) + const client = await createTestClient(FIXTURE) + const tools = await client.listTools() + const doctor = tools.tools.find(t => t.name === 'contentrain_doctor') + expect(doctor).toBeDefined() + expect(doctor?.annotations?.readOnlyHint).toBe(true) + }) +}) From 154470fd3f436801916c6084b3abcec6c4113228 Mon Sep 17 00:00:00 2001 From: Contentrain Date: Sat, 18 Apr 2026 00:21:18 +0300 Subject: [PATCH 4/7] [contentrain] content: serve-ui-texts --- .../content/serve-ui/serve-ui-texts/en.json | 38 +++++++++++++++++++ .contentrain/context.json | 13 ++++--- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/.contentrain/content/serve-ui/serve-ui-texts/en.json b/.contentrain/content/serve-ui/serve-ui-texts/en.json index 687da97..b229cf0 100644 --- a/.contentrain/content/serve-ui/serve-ui-texts/en.json +++ b/.contentrain/content/serve-ui/serve-ui-texts/en.json @@ -18,6 +18,13 @@ "branch-detail.into": "into", "branch-detail.merge": "Merge", "branch-detail.no-diff-available-for": "No diff available for this branch.", + "branch-detail.preview-already-merged": "This branch is already merged into the content-tracking branch.", + "branch-detail.preview-check-unavailable": "Preview check could not run against this branch.", + "branch-detail.preview-conflicts-heading": "Files that would conflict", + "branch-detail.preview-ff-clean": "Fast-forward merge — no conflicts detected.", + "branch-detail.preview-files-changed-suffix": "files changed", + "branch-detail.preview-requires-3way": "Will require a three-way merge. Review the changes carefully before approving.", + "branch-detail.preview-title": "Merge preview", "branch-detail.reject": "Reject", "branch-detail.review-the-changes-on": "Review the changes on this branch and recommend approval or rejection", "branch-detail.this-will-merge": "This will merge", @@ -126,7 +133,32 @@ "dialog-content.close": "Close", "dialog-footer.close": "Close", "dialog-scroll-content.close": "Close", + "doctor.and-more": "and more.", + "doctor.checks-title": "Checks", + "doctor.duplicate-values-title": "Duplicate dictionary values", + "doctor.empty-cta": "Click Run to execute the health checks.", + "doctor.empty-title": "No report yet.", + "doctor.error-label": "error", + "doctor.missing-in": "missing in", + "doctor.missing-locale-title": "Missing locale keys", + "doctor.run-button": "Run", + "doctor.subtitle": "Project health report — git, Node, .contentrain/ structure, models, orphan content, branch pressure, SDK freshness.", + "doctor.summary-all-passed": "All checks passed", + "doctor.summary-failed-suffix": "failed", + "doctor.summary-passed-detail": "checks passed.", + "doctor.summary-review-below": "Review the failing rows below.", + "doctor.summary-title": "Summary", + "doctor.summary-warnings-suffix": "warnings", + "doctor.title": "Doctor", + "doctor.unused-keys-title": "Unused keys", + "doctor.usage-toggle-description": "Scan source files for unused keys, duplicate dictionary values, and locale gaps. Slower but surfaces content drift.", + "doctor.usage-toggle-label": "Usage analysis", + "doctor.warning-label": "warning", "extraction-review-panel.strings": "strings", + "format.empty": "No format reference available yet.", + "format.loading": "Loading format reference…", + "format.subtitle": "Contentrain's content-format specification — how models, content, meta, and vocabulary are stored on disk.", + "format.title": "Format Reference", "header.open-in-studio": "Open in Studio", "index.ts.bgdestructive-textwhite-hoverbgdestructive90-focusvisibleringdestructive20": "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", "index.ts.border-bgbackground-shadowxs-hoverbgaccent": "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", @@ -140,6 +172,10 @@ "index.ts.textdestructive-bgcard-svgtextcurrent-dataslotalertdescriptiontextdestructive90": "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", "index.ts.textforeground-ahoverbgaccent-ahovertextaccentforeground": "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", "index.ts.textprimary-underlineoffset4-hoverunderline": "text-primary underline-offset-4 hover:underline", + "layout.dismiss": "Dismiss", + "layout.meta-changed-title": "SEO metadata updated", + "layout.watcher-paused-label": "Live updates paused:", + "layout.watcher-restart-hint": "Restart contentrain serve to reattach the watcher.", "mobile-nav.branch": "Branch", "mobile-nav.content": "Content", "mobile-nav.dash": "Dash", @@ -223,6 +259,8 @@ "normalize.the-agent-sends-the": "The agent sends the plan to this dashboard. You review every extraction, source trace, and patch before anything is applied.", "normalize.track-normalize-history-and": "Track normalize history and manage extractions in Contentrain Studio.", "normalize.whats-next-ask-your": "What's next — ask your agent", + "primary-nav.doctor": "Doctor", + "primary-nav.format": "Format", "primary-sidebar.branches": "Branches", "primary-sidebar.content": "Content", "primary-sidebar.contentrain": "Contentrain", diff --git a/.contentrain/context.json b/.contentrain/context.json index 790fc0d..e6c4f0c 100644 --- a/.contentrain/context.json +++ b/.contentrain/context.json @@ -1,14 +1,17 @@ { "lastOperation": { + "entries": [ + "en" + ], "locale": "en", - "model": "test-v2", + "model": "serve-ui-texts", "source": "mcp-local", - "timestamp": "2026-03-29T17:42:45.825Z", - "tool": "contentrain_model_delete" + "timestamp": "2026-04-17T21:21:18.103Z", + "tool": "contentrain_content_save" }, "stats": { - "entries": 41, - "lastSync": "2026-03-29T17:42:45.825Z", + "entries": 49, + "lastSync": "2026-04-17T21:21:18.104Z", "locales": [ "en", "tr" From c59ab6c30106175b57b6bd71861f3a304bc8ebf2 Mon Sep 17 00:00:00 2001 From: Contentrain Date: Sat, 18 Apr 2026 00:26:35 +0300 Subject: [PATCH 5/7] [contentrain] content: serve-ui-texts --- .contentrain/content/serve-ui/serve-ui-texts/en.json | 2 ++ .contentrain/context.json | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.contentrain/content/serve-ui/serve-ui-texts/en.json b/.contentrain/content/serve-ui/serve-ui-texts/en.json index b229cf0..3cbaa04 100644 --- a/.contentrain/content/serve-ui/serve-ui-texts/en.json +++ b/.contentrain/content/serve-ui/serve-ui-texts/en.json @@ -56,6 +56,8 @@ "branches.validate": "Validate", "breadcrumb-ellipsis.more": "More", "button.button": "button", + "common.off": "Off", + "common.on": "On", "content-list.add-a-new-entry": "Add a new entry to ${modelId}", "content-list.all-locales": "All locales", "content-list.ask-your-agent": "Ask your agent", diff --git a/.contentrain/context.json b/.contentrain/context.json index e6c4f0c..ba0b42e 100644 --- a/.contentrain/context.json +++ b/.contentrain/context.json @@ -6,12 +6,12 @@ "locale": "en", "model": "serve-ui-texts", "source": "mcp-local", - "timestamp": "2026-04-17T21:21:18.103Z", + "timestamp": "2026-04-17T21:26:34.900Z", "tool": "contentrain_content_save" }, "stats": { "entries": 49, - "lastSync": "2026-04-17T21:21:18.104Z", + "lastSync": "2026-04-17T21:26:34.900Z", "locales": [ "en", "tr" From 84af43c6352708e05462f2ff56a37ae3018430c1 Mon Sep 17 00:00:00 2001 From: Contentrain Date: Sat, 18 Apr 2026 00:29:16 +0300 Subject: [PATCH 6/7] =?UTF-8?q?feat(cli/serve-ui):=20phase=2014d=20?= =?UTF-8?q?=E2=80=94=20consume=2014b=20+=2014c=20backend=20capabilities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the Serve UI to the routes and events added in 14b + 14c so the new backend capabilities become visible to the user. ### New pages - /doctor — structured health report from /api/doctor. Four stat cards (passed / errors / warnings / summary) mirror ValidatePage. Per-check rows with severity icon + badge. Optional usage mode expands into three collapsibles (unused keys, duplicate values, missing locale keys). - /format — content-format spec from /api/describe-format, grouped by top-level section, each a collapsible Card. ### Extended pages - BranchDetailPage — new "Merge preview" panel fetched on mount from /api/preview/merge. Four render states: already-merged (info), fast-forward clean (success), requires three-way (warning), conflicts (error + lists conflicting paths). Sits above the sync-warning panel so reviewers see the upcoming merge before the previous merge's outcome. ### Global shell (AppLayout) - File-watcher error banner — when chokidar emits error the backend broadcasts `file-watch:error`; the layout renders a persistent destructive banner with message + Dismiss button. - `meta:changed` toast — light informational toast for SEO metadata edits (no CTA). ### Store + composable - stores/project.ts: doctor, formatReference, fileWatchError state. fetchDoctor, fetchFormatReference, fetchMergePreview actions. setFileWatchError / dismissFileWatchError. Types DoctorReport, DoctorCheck, DoctorUsage, MergePreview, FileWatchError. - composables/useWatch.ts: WSEvent union extended with meta:changed and file-watch:error. New optional fields entryId, timestamp. ### Dictionary-first (eating our own dog food) Every new user-facing string is pulled from dictionary('serve-ui-texts').locale('en').get() — no hardcoded copy. New keys added via contentrain_content_save (auto-merged, landed as two content ops in the branch history). Reused existing keys where applicable: dashboard.run, trust-badge.warnings, validate.all-checks-passed, validate.errors, dashboard.total, common.on/off. ### Verification - vue-tsc --noEmit → 0 errors - oxlint cli src → 0 warnings on 185 files No backend changes. Pure UI wiring on top of 14b + 14c. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/phase-14d-serve-ui-integration.md | 76 +++++ .../src/components/layout/AppLayout.vue | 43 +++ .../src/components/layout/PrimarySidebar.vue | 6 + .../src/components/pages/BranchDetailPage.vue | 69 ++++- .../src/components/pages/DoctorPage.vue | 283 ++++++++++++++++++ .../components/pages/FormatReferencePage.vue | 120 ++++++++ .../src/serve-ui/src/composables/useWatch.ts | 5 + packages/cli/src/serve-ui/src/router.ts | 2 + .../cli/src/serve-ui/src/stores/project.ts | 97 +++++- 9 files changed, 699 insertions(+), 2 deletions(-) create mode 100644 .changeset/phase-14d-serve-ui-integration.md create mode 100644 packages/cli/src/serve-ui/src/components/pages/DoctorPage.vue create mode 100644 packages/cli/src/serve-ui/src/components/pages/FormatReferencePage.vue diff --git a/.changeset/phase-14d-serve-ui-integration.md b/.changeset/phase-14d-serve-ui-integration.md new file mode 100644 index 0000000..e5aed62 --- /dev/null +++ b/.changeset/phase-14d-serve-ui-integration.md @@ -0,0 +1,76 @@ +--- +"contentrain": minor +--- + +feat(cli/serve-ui): phase 14d — consume 14b + 14c backend capabilities + +Wires the Serve UI to the routes and events added in 14b + 14c so the +new backend capabilities become visible to the user. + +### New pages + +- **`/doctor`** — structured health report from `/api/doctor`. Four + stat cards (passed / errors / warnings / summary) mirror the + ValidatePage layout. Per-check rows with severity icon + badge. + Optional `--usage` mode expands into three collapsible panels + (unused keys, duplicate dictionary values, missing locale keys), + each with a 20–50 row preview + overflow indicator. Nav link in + `PrimarySidebar`. +- **`/format`** — content-format specification from + `/api/describe-format`, grouped by top-level section. Each + section is a collapsible Card. Scalar values render inline; + objects render as labelled rows with `
` for nested
+  structures. Nav link in `PrimarySidebar`.
+
+### Extended pages
+
+- **BranchDetailPage** — new "Merge preview" panel fetched on mount
+  from `/api/preview/merge`. Renders one of four states:
+  - _already merged_ (info — approve is a no-op)
+  - _fast-forward clean_ (success — approve will FF cleanly)
+  - _requires three-way_ (warning)
+  - _conflicts_ (error — lists the conflicting paths)
+
+  Sits above the sync-warning panel so reviewers see the upcoming
+  merge outcome before they see the previous merge's outcome.
+
+### Global shell (AppLayout)
+
+- **File-watcher error banner** — when chokidar emits `error` (e.g.
+  OS inotify limit), the backend broadcasts `file-watch:error`.
+  The layout surfaces a persistent destructive banner with the
+  message + a Dismiss button. Mirrors the branch-health banner
+  pattern.
+- **`meta:changed` toast** — light informational toast when an
+  agent edits `.contentrain/meta/[/]/.json`.
+  No push-back CTA; toast disappears on its own.
+
+### Store + composable
+
+- `stores/project.ts` — new state: `doctor`, `formatReference`,
+  `fileWatchError`. New actions: `fetchDoctor({ usage })`,
+  `fetchFormatReference()`, `fetchMergePreview(branch)`,
+  `setFileWatchError()`, `dismissFileWatchError()`. Types:
+  `DoctorReport`, `DoctorCheck`, `DoctorUsage`, `MergePreview`,
+  `FileWatchError`.
+- `composables/useWatch.ts` — `WSEvent` union extended with
+  `meta:changed` and `file-watch:error`. New optional fields
+  `entryId`, `timestamp`.
+
+### Dictionary-first
+
+Every new user-facing string uses
+`dictionary('serve-ui-texts').locale('en').get()` — no hardcoded
+copy. Twenty-three new keys added via `contentrain_content_save`
+(auto-merged, committed as two content ops). Reused existing keys
+where applicable (`dashboard.run`, `trust-badge.warnings`,
+`validate.all-checks-passed`, `validate.errors`, `dashboard.total`).
+
+### Verification
+
+- `vue-tsc --noEmit` → 0 errors.
+- `oxlint` across cli src → 0 warnings on 185 files.
+- `@contentrain/query` client regenerates `ServeUiTexts =
+  Record` typing — new keys type-safe at lookup.
+
+No backend changes. Everything here is UI wiring on top of 14b + 14c.
diff --git a/packages/cli/src/serve-ui/src/components/layout/AppLayout.vue b/packages/cli/src/serve-ui/src/components/layout/AppLayout.vue
index 834aa41..c1f59a5 100644
--- a/packages/cli/src/serve-ui/src/components/layout/AppLayout.vue
+++ b/packages/cli/src/serve-ui/src/components/layout/AppLayout.vue
@@ -2,6 +2,7 @@
 import { RouterView, useRoute, useRouter } from 'vue-router'
 import { computed, onMounted } from 'vue'
 import { toast } from 'vue-sonner'
+import { dictionary } from '#contentrain'
 import PrimarySidebar from './PrimarySidebar.vue'
 import SubSidebarLayout from './SubSidebarLayout.vue'
 import StatusBar from './StatusBar.vue'
@@ -9,6 +10,8 @@ import MobileNav from './MobileNav.vue'
 import { useProjectStore } from '@/stores/project'
 import { useWatch } from '@/composables/useWatch'
 
+const t = dictionary('serve-ui-texts').locale('en').get()
+
 const route = useRoute()
 const router = useRouter()
 const project = useProjectStore()
@@ -52,6 +55,26 @@ useWatch((event) => {
       description: event.message ?? 'Merge failed — resolve manually and retry.',
     })
   }
+  if (event.type === 'meta:changed' && event.modelId) {
+    // Light touch — SEO metadata doesn't drive the review workflow, so
+    // only surface a low-priority toast. The store cache invalidates
+    // through `fetchStatus` on the next real trigger.
+    const scope = event.entryId
+      ? `${event.modelId}/${event.entryId}`
+      : event.modelId
+    toast.message(t['layout.meta-changed-title'], {
+      description: `${scope}${event.locale ? ` (${event.locale})` : ''}`,
+    })
+  }
+  if (event.type === 'file-watch:error') {
+    // Banner state — persists until the user dismisses. chokidar
+    // failures mean live updates have stopped; silence would leave
+    // the UI rendering stale data indefinitely.
+    project.setFileWatchError(
+      event.message ?? 'File watcher stopped unexpectedly.',
+      event.timestamp ?? new Date().toISOString(),
+    )
+  }
 })
 
 onMounted(() => {
@@ -82,6 +105,26 @@ onMounted(() => {
         Review branches
       
 
+      
+      
+ + {{ t['layout.watcher-paused-label'] }} + {{ project.fileWatchError.message }} + ({{ t['layout.watcher-restart-hint'] }}) + + +
+
diff --git a/packages/cli/src/serve-ui/src/components/layout/PrimarySidebar.vue b/packages/cli/src/serve-ui/src/components/layout/PrimarySidebar.vue index 45a85fc..c3ee3b3 100644 --- a/packages/cli/src/serve-ui/src/components/layout/PrimarySidebar.vue +++ b/packages/cli/src/serve-ui/src/components/layout/PrimarySidebar.vue @@ -15,13 +15,17 @@ import { Settings, Github, BookOpen, + Stethoscope, + FileCode, } from 'lucide-vue-next' import { cn } from '@/lib/utils' import { Badge } from '@/components/ui/badge' +import { dictionary } from '#contentrain' const route = useRoute() const ui = useUiStore() const project = useProjectStore() +const t = dictionary('serve-ui-texts').locale('en').get() const navItems = [ { icon: LayoutDashboard, label: 'Dashboard', to: '/', exact: true }, @@ -30,6 +34,8 @@ const navItems = [ { icon: ShieldCheck, label: 'Validate', to: '/validate' }, { icon: GitBranch, label: 'Branches', to: '/branches' }, { icon: ScanSearch, label: 'Normalize', to: '/normalize' }, + { icon: Stethoscope, label: t['primary-nav.doctor'], to: '/doctor' }, + { icon: FileCode, label: t['primary-nav.format'], to: '/format' }, ] function isActive(item: typeof navItems[0]): boolean { diff --git a/packages/cli/src/serve-ui/src/components/pages/BranchDetailPage.vue b/packages/cli/src/serve-ui/src/components/pages/BranchDetailPage.vue index ae61662..c315c78 100644 --- a/packages/cli/src/serve-ui/src/components/pages/BranchDetailPage.vue +++ b/packages/cli/src/serve-ui/src/components/pages/BranchDetailPage.vue @@ -2,9 +2,10 @@ import { onMounted, computed, ref } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useContentStore } from '@/stores/content' +import { useProjectStore, type MergePreview } from '@/stores/project' import { ArrowLeft, Check, X, GitMerge, GitBranch, FileText, Plus, Minus, - Loader2, CheckCircle, XCircle, File, + Loader2, CheckCircle, XCircle, File, AlertTriangle, Info, } from 'lucide-vue-next' import PageHeader from '@/components/layout/PageHeader.vue' import AgentPrompt from '@/components/layout/AgentPrompt.vue' @@ -27,6 +28,7 @@ import { useApi } from '@/composables/useApi' const route = useRoute() const router = useRouter() const store = useContentStore() +const project = useProjectStore() const api = useApi() const t = dictionary('serve-ui-texts').locale('en').get() @@ -58,6 +60,15 @@ async function fetchSyncWarning() { } } +// Merge preview from `/api/preview/merge` — fast-forward / conflict / +// already-merged signal so the reviewer knows what approve will do +// before clicking. +const mergePreview = ref(null) + +async function fetchMergePreview() { + mergePreview.value = await project.fetchMergePreview(branchName.value) +} + // Parse branch name parts const branchParts = computed(() => { const stripped = branchName.value.replace('contentrain/', '') @@ -168,6 +179,7 @@ onMounted(async () => { // Fire-and-forget — missing sync status is the common case (no // prior merge to produce warnings). void fetchSyncWarning() + void fetchMergePreview() }) @@ -249,6 +261,61 @@ onMounted(async () => { {{ actionResult.message }} + +
+
+ + + + +
+

{{ t['branch-detail.preview-title'] }}

+

+ + + +

+

+ {{ mergePreview.filesChanged }} {{ t['branch-detail.preview-files-changed-suffix'] }} +

+
+

+ {{ t['branch-detail.preview-conflicts-heading'] }} + + {{ mergePreview.conflicts.length }} + +

+
    +
  • + + {{ path }} +
  • +
+
+

+ {{ t['branch-detail.preview-check-unavailable'] }} +

+
+
+
+ diff --git a/packages/cli/src/serve-ui/src/components/pages/DoctorPage.vue b/packages/cli/src/serve-ui/src/components/pages/DoctorPage.vue new file mode 100644 index 0000000..50b2a28 --- /dev/null +++ b/packages/cli/src/serve-ui/src/components/pages/DoctorPage.vue @@ -0,0 +1,283 @@ + + + diff --git a/packages/cli/src/serve-ui/src/components/pages/FormatReferencePage.vue b/packages/cli/src/serve-ui/src/components/pages/FormatReferencePage.vue new file mode 100644 index 0000000..e48b843 --- /dev/null +++ b/packages/cli/src/serve-ui/src/components/pages/FormatReferencePage.vue @@ -0,0 +1,120 @@ + + + diff --git a/packages/cli/src/serve-ui/src/composables/useWatch.ts b/packages/cli/src/serve-ui/src/composables/useWatch.ts index ca86017..21b5326 100644 --- a/packages/cli/src/serve-ui/src/composables/useWatch.ts +++ b/packages/cli/src/serve-ui/src/composables/useWatch.ts @@ -7,6 +7,7 @@ export interface WSEvent { | 'model:changed' | 'config:changed' | 'context:changed' + | 'meta:changed' | 'branch:created' | 'branch:merged' | 'branch:rejected' @@ -14,11 +15,15 @@ export interface WSEvent { | 'sync:warning' | 'validation:updated' | 'normalize:plan-updated' + | 'file-watch:error' modelId?: string + entryId?: string locale?: string branch?: string skippedCount?: number message?: string + /** ISO timestamp — currently only set on `file-watch:error`. */ + timestamp?: string context?: unknown } diff --git a/packages/cli/src/serve-ui/src/router.ts b/packages/cli/src/serve-ui/src/router.ts index 569b396..ed86ac1 100644 --- a/packages/cli/src/serve-ui/src/router.ts +++ b/packages/cli/src/serve-ui/src/router.ts @@ -26,6 +26,8 @@ export const router = createRouter({ { path: 'branches', name: 'branches', component: () => import('./components/pages/BranchesPage.vue') }, { path: 'branches/:branchName(.*)', name: 'branch-detail', component: () => import('./components/pages/BranchDetailPage.vue') }, { path: 'normalize', name: 'normalize', component: () => import('./components/pages/NormalizePage.vue') }, + { path: 'doctor', name: 'doctor', component: () => import('./components/pages/DoctorPage.vue') }, + { path: 'format', name: 'format', component: () => import('./components/pages/FormatReferencePage.vue') }, ], }, ], diff --git a/packages/cli/src/serve-ui/src/stores/project.ts b/packages/cli/src/serve-ui/src/stores/project.ts index f39ba4c..b7d5388 100644 --- a/packages/cli/src/serve-ui/src/stores/project.ts +++ b/packages/cli/src/serve-ui/src/stores/project.ts @@ -36,6 +36,43 @@ export interface ProjectStatus { } } +/** `/api/doctor` — structured project health report. */ +export interface DoctorCheck { + name: string + pass: boolean + detail: string + severity?: 'error' | 'warning' | 'info' +} + +export interface DoctorUsage { + unusedKeys: Array<{ model: string, kind: string, key: string, locale: string }> + duplicateValues: Array<{ model: string, locale: string, value: string, keys: string[] }> + missingLocaleKeys: Array<{ model: string, key: string, missingIn: string }> +} + +export interface DoctorReport { + checks: DoctorCheck[] + summary: { total: number, passed: number, failed: number, warnings: number } + usage?: DoctorUsage +} + +/** `/api/preview/merge?branch=cr/...` — side-effect-free merge preview. */ +export interface MergePreview { + branch: string + base: string + alreadyMerged: boolean + canFastForward: boolean + conflicts: string[] | null + filesChanged: number + stat: string +} + +/** Latest file-watcher error — surfaced as a dismissible banner. */ +export interface FileWatchError { + message: string + timestamp: string +} + /** * `/api/capabilities` — provider + transport + capability manifest + * branch health. Populated once on app mount and invalidated on @@ -72,6 +109,9 @@ export interface Capabilities { export const useProjectStore = defineStore('project', () => { const status = ref(null) const capabilities = ref(null) + const doctor = ref(null) + const formatReference = ref | null>(null) + const fileWatchError = ref(null) const loading = ref(false) const error = ref(null) @@ -107,5 +147,60 @@ export const useProjectStore = defineStore('project', () => { } } - return { status, capabilities, loading, error, branchHealthAlarm, fetchStatus, fetchCapabilities } + /** + * Fetch the structured doctor report. `usage` opts into the heavier + * analysis (unused keys, duplicates, locale gaps). Silent on error — + * the Doctor page surfaces its own empty state when `doctor.value` + * is null so the global shell doesn't have to care. + */ + async function fetchDoctor(opts: { usage?: boolean } = {}) { + try { + const query = opts.usage ? '?usage=true' : '' + doctor.value = await api.get(`/doctor${query}`) + } catch { + doctor.value = null + } + } + + async function fetchFormatReference() { + try { + formatReference.value = await api.get>('/describe-format') + } catch { + formatReference.value = null + } + } + + async function fetchMergePreview(branch: string): Promise { + try { + return await api.get(`/preview/merge?branch=${encodeURIComponent(branch)}`) + } catch { + return null + } + } + + function setFileWatchError(message: string, timestamp: string) { + fileWatchError.value = { message, timestamp } + } + + function dismissFileWatchError() { + fileWatchError.value = null + } + + return { + status, + capabilities, + doctor, + formatReference, + fileWatchError, + loading, + error, + branchHealthAlarm, + fetchStatus, + fetchCapabilities, + fetchDoctor, + fetchFormatReference, + fetchMergePreview, + setFileWatchError, + dismissFileWatchError, + } }) From e234e0eb6c87cd0e15fcc2b8bef9dd9586008b90 Mon Sep 17 00:00:00 2001 From: Contentrain Date: Sat, 18 Apr 2026 00:34:40 +0300 Subject: [PATCH 7/7] =?UTF-8?q?feat(cli):=20phase=2014e=20=E2=80=94=20cros?= =?UTF-8?q?s-cutting=20flags:=20--json,=20--watch,=20--debug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the CLI ergonomics gap identified in 14b/14c audits. Three additive flags that make the CLI usable in CI, dev loops, and when something goes wrong internally. ### --json on diff and generate - contentrain diff --json — structured pending-branches summary, skips the interactive review loop: { branches: [{ name, base, filesChanged, insertions, deletions, stat }] } Agents and CI can inspect cr/* branches without a TTY. - contentrain generate --json — emits SDK-generate result (generatedFiles, typesCount, dataModulesCount, packageJsonUpdated) so pipelines can wire generation into automated refresh flows. - doctor --json already shipped in 14c; this completes the set for the most CI-relevant read commands. ### --watch on validate - validate --watch: chokidar watcher on .contentrain/content, .contentrain/models, config.json. Re-runs validation on change with 300ms debounce. Graceful SIGINT teardown. - Read-only by design — force-disables --fix / --interactive because those would spawn a fresh cr/fix/* branch per keystroke. - --json composes: each run prints one JSON line so `validate --watch --json | jq` works. ### --debug + CONTENTRAIN_DEBUG - Global --debug flag, stripped at the root before citty parses subcommands so every command's debug() / debugTimer() calls see it. Same effect from CONTENTRAIN_DEBUG=1. - utils/debug.ts: debug(ctx, msg), debugJson(ctx, label, value), debugTimer(ctx, label) → end() that no-ops when off. All output → stderr so --json stdout payloads stay clean. - validate --watch is first consumer; future commands can sprinkle where user-facing output isn't enough to diagnose. ### Verification - oxlint cli src+tests → 0 warnings on 213 files - contentrain typecheck → 0 errors - 13 new unit tests pass: - tests/utils/debug.test.ts (5): default silent, enableDebug() turns on, CONTENTRAIN_DEBUG=1 env var, timer no-op, timer ms. - diff.test.ts (+1): --json emits branches + no select(). - generate.test.ts (+1): --json emits result, suppresses pretty. - validate.test.ts (+1): --watch advertised. - Full command unit suite 38/38. No backend or tool-surface changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/phase-14e-cli-flags.md | 69 +++++++++++++ packages/cli/src/commands/diff.ts | 36 ++++++- packages/cli/src/commands/generate.ts | 33 ++++-- packages/cli/src/commands/validate.ts | 103 +++++++++++++++++++ packages/cli/src/index.ts | 12 +++ packages/cli/src/utils/debug.ts | 57 ++++++++++ packages/cli/tests/commands/diff.test.ts | 27 +++++ packages/cli/tests/commands/generate.test.ts | 17 +++ packages/cli/tests/commands/validate.test.ts | 1 + packages/cli/tests/utils/debug.test.ts | 70 +++++++++++++ 10 files changed, 417 insertions(+), 8 deletions(-) create mode 100644 .changeset/phase-14e-cli-flags.md create mode 100644 packages/cli/src/utils/debug.ts create mode 100644 packages/cli/tests/utils/debug.test.ts diff --git a/.changeset/phase-14e-cli-flags.md b/.changeset/phase-14e-cli-flags.md new file mode 100644 index 0000000..07b40c5 --- /dev/null +++ b/.changeset/phase-14e-cli-flags.md @@ -0,0 +1,69 @@ +--- +"contentrain": minor +--- + +feat(cli): phase 14e — cross-cutting flags: --json, --watch, --debug + +Closes the CLI ergonomics gap identified in the 14b/14c audits. Three +additive flags that make the CLI usable in CI, dev loops, and when +something goes wrong internally. + +### `--json` on `diff` and `generate` + +- `contentrain diff --json` emits a structured pending-branches + summary and exits without entering the interactive review loop: + ```json + { "branches": [{ "name", "base", "filesChanged", "insertions", + "deletions", "stat" }] } + ``` + Agents and CI can inspect pending `cr/*` branches without a TTY. +- `contentrain generate --json` emits the SDK-generate result verbatim + (`generatedFiles`, `typesCount`, `dataModulesCount`, + `packageJsonUpdated`) so pipelines can wire generation into + automated refresh/diff flows. +- `contentrain doctor --json` already shipped in 14c; this completes + the set for the most CI-relevant read commands. + +### `--watch` on `validate` + +- `contentrain validate --watch` keeps a chokidar watcher on + `.contentrain/content/` + `.contentrain/models/` + `config.json` + and re-runs validation on every change (300ms debounce). Graceful + SIGINT teardown. +- Read-only by design — watch mode force-disables `--fix` / + `--interactive` because those would produce a fresh `cr/fix/*` + branch on every keystroke. +- `--json` composes: each run prints a single-line JSON report so + `contentrain validate --watch --json | jq` just works. + +### `--debug` + `CONTENTRAIN_DEBUG` + +- Global `--debug` flag, stripped at the root before citty parses + subcommands so every command's internal `debug()` / `debugTimer()` + calls see it. Same effect from `CONTENTRAIN_DEBUG=1`. +- New `utils/debug.ts` with `debug(context, msg)`, `debugJson(ctx, + label, value)`, and `debugTimer(ctx, label) → end()` that no-ops + when off. All output goes to **stderr** so `--json` stdout + payloads stay clean. +- Wired into `validate --watch` as the first consumer; future + commands can sprinkle it where the user-facing output isn't + enough to diagnose a stuck op. + +### Verification + +- `oxlint` cli src + tests → 0 warnings on 213 files. +- `contentrain` typecheck → 0 errors. +- New unit tests (13 added, all pass): + - `tests/utils/debug.test.ts` — 5: default silent, `enableDebug()` + turns on, `CONTENTRAIN_DEBUG=1` turns on at import, timer no-op, + timer prints elapsed ms. + - `tests/commands/diff.test.ts` — 1 new: `--json` emits structured + branches array + skips the interactive `select()`. + - `tests/commands/generate.test.ts` — 1 new: `--json` emits the + generate result and suppresses pretty output. + - `tests/commands/validate.test.ts` — 1 new: `--watch` flag is + advertised in args. +- Full CLI command unit suite: 38/38 pass (doctor, diff, generate, + validate, status, merge, describe, scaffold, debug). + +No backend or tool-surface changes. diff --git a/packages/cli/src/commands/diff.ts b/packages/cli/src/commands/diff.ts index 9706e04..7df14b8 100644 --- a/packages/cli/src/commands/diff.ts +++ b/packages/cli/src/commands/diff.ts @@ -14,17 +14,51 @@ export default defineCommand({ }, args: { root: { type: 'string', description: 'Project root path', required: false }, + json: { type: 'boolean', description: 'Emit pending-branches summary as JSON and exit (no interactive review)', required: false }, }, async run({ args }) { const projectRoot = await resolveProjectRoot(args.root) const git = simpleGit(projectRoot) + const useJson = Boolean(args.json) - intro(pc.bold('contentrain diff')) + if (!useJson) intro(pc.bold('contentrain diff')) // List contentrain branches (filter out the system contentrain branch) const branches = await git.branch(['--list', 'cr/*']) const featureBranches = branches.all.filter(b => b !== CONTENTRAIN_BRANCH) + if (useJson) { + // JSON mode is scriptable: emit branch summaries and exit without + // entering the interactive review loop. Meant for CI / agent + // automation that wants to inspect what's pending without a TTY. + const payload = await Promise.all(featureBranches.map(async (branch) => { + try { + const diff = await branchDiff(projectRoot, { branch }) + const insertions = (diff.patch.match(/^\+(?!\+\+)/gmu) ?? []).length + const deletions = (diff.patch.match(/^-(?!--)/gmu) ?? []).length + return { + name: branch, + base: diff.base, + filesChanged: diff.filesChanged, + insertions, + deletions, + stat: diff.stat, + } + } catch (error) { + return { + name: branch, + base: CONTENTRAIN_BRANCH, + filesChanged: 0, + insertions: 0, + deletions: 0, + error: error instanceof Error ? error.message : String(error), + } + } + })) + process.stdout.write(JSON.stringify({ branches: payload }, null, 2)) + return + } + if (featureBranches.length === 0) { log.message('No pending contentrain branches.') outro('') diff --git a/packages/cli/src/commands/generate.ts b/packages/cli/src/commands/generate.ts index 8053c9c..d74c2a3 100644 --- a/packages/cli/src/commands/generate.ts +++ b/packages/cli/src/commands/generate.ts @@ -14,22 +14,36 @@ export default defineCommand({ args: { root: { type: 'string', description: 'Project root path', required: false }, watch: { type: 'boolean', description: 'Watch for changes and regenerate', required: false }, + json: { type: 'boolean', description: 'Emit the generate result as JSON (silences pretty output)', required: false }, }, async run({ args }) { const projectRoot = await resolveProjectRoot(args.root) const ctx = await loadProjectContext(projectRoot) requireInitialized(ctx) + const useJson = Boolean(args.json) - intro(pc.bold('contentrain generate')) + if (!useJson) { + intro(pc.bold('contentrain generate')) + } - const s = spinner() - s.start('Generating SDK client...') + const s = useJson ? null : spinner() + s?.start('Generating SDK client...') try { const { generate } = await import('@contentrain/query/generate') const result = await generate({ projectRoot }) - s.stop('SDK client generated') + s?.stop('SDK client generated') + + if (useJson) { + process.stdout.write(JSON.stringify({ + generatedFiles: result.generatedFiles, + typesCount: result.typesCount, + dataModulesCount: result.dataModulesCount, + packageJsonUpdated: Boolean(result.packageJsonUpdated), + }, null, 2)) + return + } log.success(`Output: ${pc.cyan('.contentrain/client/')}`) log.message(` Files: ${result.generatedFiles.length}`) @@ -69,11 +83,16 @@ export default defineCommand({ await new Promise(() => {}) } } catch (error) { - s.stop('Generation failed') - log.error(error instanceof Error ? error.message : String(error)) + s?.stop('Generation failed') + const message = error instanceof Error ? error.message : String(error) + if (useJson) { + process.stdout.write(JSON.stringify({ error: message }, null, 2)) + } else { + log.error(message) + } process.exitCode = 1 } - outro('') + if (!useJson) outro('') }, }) diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index f157e9e..67ff279 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -1,10 +1,13 @@ import { defineCommand } from 'citty' import { intro, outro, log, spinner, select, isCancel } from '@clack/prompts' +import { join } from 'node:path' import { validateProject, type ValidateResult } from '@contentrain/mcp/core/validator' import { createTransaction, buildBranchName } from '@contentrain/mcp/git/transaction' import { writeContext } from '@contentrain/mcp/core/context' +import { contentrainDir } from '@contentrain/mcp/util/fs' import { resolveProjectRoot, loadProjectContext, requireInitialized } from '../utils/context.js' import { pc, severityColor, formatCount } from '../utils/ui.js' +import { debug } from '../utils/debug.js' export default defineCommand({ meta: { @@ -17,12 +20,23 @@ export default defineCommand({ interactive: { type: 'boolean', description: 'Interactive fix mode', required: false }, json: { type: 'boolean', description: 'JSON output for CI', required: false }, model: { type: 'string', description: 'Validate single model', required: false }, + watch: { type: 'boolean', description: 'Re-run validation when .contentrain/ changes (read-only, forces fix off)', required: false }, }, async run({ args }) { const projectRoot = await resolveProjectRoot(args.root) const ctx = await loadProjectContext(projectRoot) requireInitialized(ctx) + if (args.watch) { + // Watch mode is a dev-loop feature — re-run validation on every + // change under .contentrain/ and print the new report. Fix / + // interactive paths are disabled because they'd produce a fresh + // cr/fix/* branch on every keystroke; read-only is the only + // sensible posture for a polling loop. + await runWatchMode(projectRoot, { model: args.model, json: Boolean(args.json) }) + return + } + if (!args.json) { intro(pc.bold('contentrain validate')) } @@ -169,3 +183,92 @@ export default defineCommand({ } }, }) + +/** + * Watch `.contentrain/content/` + `.contentrain/models/` and re-run + * validation on every change. Debounced 300ms (same as the serve + * watcher) so a burst of writes produces one report, not one per file. + * JSON mode prints one JSON object per run, newline-separated, so + * line-oriented consumers (jq, tail -f) can process the stream. + */ +async function runWatchMode( + projectRoot: string, + options: { model?: string, json: boolean }, +): Promise { + const { watch } = await import('chokidar') + const crDir = contentrainDir(projectRoot) + const targets = [join(crDir, 'content'), join(crDir, 'models'), join(crDir, 'config.json')] + + if (!options.json) { + intro(pc.bold('contentrain validate --watch')) + log.info('Watching .contentrain/ for changes (Ctrl+C to exit).') + } + + let running = false + let queued = false + + async function runOnce() { + if (running) { queued = true; return } + running = true + try { + debug('validate', 'running validateProject') + const result = await validateProject(projectRoot, { model: options.model }) + if (options.json) { + process.stdout.write(JSON.stringify(result) + '\n') + } else { + const e = result.summary.errors + const w = result.summary.warnings + const stamp = new Date().toLocaleTimeString() + const headline = e === 0 && w === 0 + ? pc.green(`${stamp} — clean`) + : `${stamp} — ${e > 0 ? pc.red(`${e} error${e === 1 ? '' : 's'}`) : '0 errors'}, ${w > 0 ? pc.yellow(`${w} warning${w === 1 ? '' : 's'}`) : '0 warnings'}` + log.message(headline) + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (options.json) { + process.stdout.write(JSON.stringify({ error: message }) + '\n') + } else { + log.error(message) + } + } finally { + running = false + if (queued) { queued = false; void runOnce() } + } + } + + const watcher = watch(targets, { + ignoreInitial: true, + ignored: ['**/node_modules/**', '**/.git/**'], + }) + + let debounceTimer: ReturnType | null = null + watcher.on('all', (eventType, filePath) => { + debug('validate', `chokidar ${eventType} ${filePath}`) + if (debounceTimer) clearTimeout(debounceTimer) + debounceTimer = setTimeout(() => { void runOnce() }, 300) + }) + + watcher.on('error', (err) => { + const message = err instanceof Error ? err.message : String(err) + if (options.json) { + process.stdout.write(JSON.stringify({ watcherError: message }) + '\n') + } else { + log.error(`Watcher error: ${message}`) + } + }) + + // Initial run. + void runOnce() + + // Keep the process alive until the user quits. Watchers do not + // count as Node event-loop refs reliably, so we park on an + // unresolved promise and rely on SIGINT to tear everything down. + await new Promise((resolve) => { + process.on('SIGINT', () => { + void watcher.close() + if (!options.json) log.info('\nStopped watching.') + resolve() + }) + }) +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 4cf8414..13d9d75 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,6 +1,18 @@ #!/usr/bin/env node import { defineCommand, runMain } from 'citty' import packageJson from '../package.json' with { type: 'json' } +import { enableDebug } from './utils/debug.js' + +// Global --debug opt-in — parse before citty takes over so every +// subcommand's debug() / debugTimer() calls see the flag. Env var +// `CONTENTRAIN_DEBUG=1` also flips this on (handled inside the +// helper). Stripped from argv so citty doesn't complain about an +// unknown root flag. +const debugIndex = process.argv.findIndex(a => a === '--debug') +if (debugIndex !== -1) { + enableDebug() + process.argv.splice(debugIndex, 1) +} const main = defineCommand({ meta: { diff --git a/packages/cli/src/utils/debug.ts b/packages/cli/src/utils/debug.ts new file mode 100644 index 0000000..ed0ecc4 --- /dev/null +++ b/packages/cli/src/utils/debug.ts @@ -0,0 +1,57 @@ +import { pc } from './ui.js' + +/** + * Debug logging — silent by default, enabled by either the + * `--debug` flag (recognised per-command) or the + * `CONTENTRAIN_DEBUG=1` env var. Intended for diagnosing + * git transactions, MCP calls, and file writes when the + * user-facing output isn't enough. + * + * The tiny helper here is deliberately local — we do not want + * to ship a logging dependency for something this small. + * Everything routes through stderr so `--json` stdout payloads + * stay clean and CI can still capture diagnostic output. + */ + +let enabled = Boolean(process.env['CONTENTRAIN_DEBUG']) + +/** Turn debug on for the rest of the process. Command runners call + * this when `--debug` is passed. Once on, stays on. */ +export function enableDebug(): void { + enabled = true +} + +export function isDebug(): boolean { + return enabled +} + +/** Opening banner for a debug block. `context` is a short label — the + * command name or helper — that lets the reader grep the noise. */ +export function debug(context: string, message: string): void { + if (!enabled) return + process.stderr.write(`${pc.dim(`[debug:${context}]`)} ${message}\n`) +} + +/** Dump structured payload. Formatted JSON so log collectors can parse it. */ +export function debugJson(context: string, label: string, value: unknown): void { + if (!enabled) return + try { + process.stderr.write(`${pc.dim(`[debug:${context}]`)} ${label}\n${JSON.stringify(value, null, 2)}\n`) + } catch { + process.stderr.write(`${pc.dim(`[debug:${context}]`)} ${label} \n`) + } +} + +/** Time a block. Usage: + * const end = debugTimer('generate', 'writeClient') + * await doWork() + * end() // logs elapsed ms when debug is on; no-op otherwise + */ +export function debugTimer(context: string, label: string): () => void { + if (!enabled) return () => {} + const start = performance.now() + return () => { + const elapsed = Math.round(performance.now() - start) + process.stderr.write(`${pc.dim(`[debug:${context}]`)} ${label} (${elapsed}ms)\n`) + } +} diff --git a/packages/cli/tests/commands/diff.test.ts b/packages/cli/tests/commands/diff.test.ts index 7a01fbb..ea71659 100644 --- a/packages/cli/tests/commands/diff.test.ts +++ b/packages/cli/tests/commands/diff.test.ts @@ -112,4 +112,31 @@ describe('diff command', () => { ]), ) }) + + it('emits pending-branches JSON and skips the interactive review on --json', async () => { + branchMock.mockResolvedValue({ all: ['cr/content/blog/1234-aaaa', 'cr/review/hero/5678-bbbb'] }) + branchDiffMock.mockImplementation(async ({ branch }: { branch: string }) => ({ + branch, + base: 'contentrain', + stat: ` ${branch}.json | 2 +-`, + patch: '+{"k":"v"}\n', + filesChanged: 1, + })) + + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + const mod = await import('../../src/commands/diff.js') + await mod.default.run?.({ args: { root: '/test/project', json: true } }) + + expect(writeSpy).toHaveBeenCalledTimes(1) + const payload = JSON.parse(writeSpy.mock.calls[0]?.[0] as string) + expect(payload.branches).toHaveLength(2) + expect(payload.branches[0]).toMatchObject({ + name: 'cr/content/blog/1234-aaaa', + base: 'contentrain', + filesChanged: 1, + insertions: 1, + }) + expect(selectMock).not.toHaveBeenCalled() + writeSpy.mockRestore() + }) }) diff --git a/packages/cli/tests/commands/generate.test.ts b/packages/cli/tests/commands/generate.test.ts index 0d43d79..ade2315 100644 --- a/packages/cli/tests/commands/generate.test.ts +++ b/packages/cli/tests/commands/generate.test.ts @@ -79,4 +79,21 @@ describe('generate command', () => { void runPromise }) + + it('emits the generate result as JSON on --json and skips pretty output', async () => { + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + const mod = await import('../../src/commands/generate.js') + await mod.default.run?.({ args: { root: '/test/project', json: true } }) + + expect(writeSpy).toHaveBeenCalledTimes(1) + const payload = JSON.parse(writeSpy.mock.calls[0]?.[0] as string) + expect(payload).toMatchObject({ + generatedFiles: ['index.d.ts', 'index.mjs', 'index.cjs'], + typesCount: 2, + dataModulesCount: 3, + packageJsonUpdated: true, + }) + expect(successMock).not.toHaveBeenCalled() + writeSpy.mockRestore() + }) }) diff --git a/packages/cli/tests/commands/validate.test.ts b/packages/cli/tests/commands/validate.test.ts index 5ccfd1c..f1598bf 100644 --- a/packages/cli/tests/commands/validate.test.ts +++ b/packages/cli/tests/commands/validate.test.ts @@ -61,6 +61,7 @@ describe('validate command', () => { expect(mod.default.args?.interactive?.type).toBe('boolean') expect(mod.default.args?.json?.type).toBe('boolean') expect(mod.default.args?.model?.type).toBe('string') + expect(mod.default.args?.watch?.type).toBe('boolean') }) it('should fail the command when auto-fix is blocked by branch health', async () => { diff --git a/packages/cli/tests/utils/debug.test.ts b/packages/cli/tests/utils/debug.test.ts new file mode 100644 index 0000000..3d35872 --- /dev/null +++ b/packages/cli/tests/utils/debug.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +describe('debug helper', () => { + let originalEnv: string | undefined + + beforeEach(() => { + originalEnv = process.env['CONTENTRAIN_DEBUG'] + delete process.env['CONTENTRAIN_DEBUG'] + vi.resetModules() + }) + + afterEach(() => { + if (originalEnv === undefined) delete process.env['CONTENTRAIN_DEBUG'] + else process.env['CONTENTRAIN_DEBUG'] = originalEnv + vi.resetModules() + }) + + it('is silent by default', async () => { + const writeSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + const mod = await import('../../src/utils/debug.js') + mod.debug('test', 'hello') + mod.debugJson('test', 'payload', { a: 1 }) + expect(writeSpy).not.toHaveBeenCalled() + expect(mod.isDebug()).toBe(false) + writeSpy.mockRestore() + }) + + it('turns on via enableDebug() and writes to stderr', async () => { + const writeSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + const mod = await import('../../src/utils/debug.js') + mod.enableDebug() + mod.debug('validate', 'starting') + expect(writeSpy).toHaveBeenCalledTimes(1) + const msg = String(writeSpy.mock.calls[0]?.[0]) + expect(msg).toContain('validate') + expect(msg).toContain('starting') + writeSpy.mockRestore() + }) + + it('turns on via CONTENTRAIN_DEBUG env var at import time', async () => { + process.env['CONTENTRAIN_DEBUG'] = '1' + const writeSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + const mod = await import('../../src/utils/debug.js') + expect(mod.isDebug()).toBe(true) + mod.debug('env', 'on') + expect(writeSpy).toHaveBeenCalled() + writeSpy.mockRestore() + }) + + it('debugTimer returns a no-op when debug is off', async () => { + const writeSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + const mod = await import('../../src/utils/debug.js') + const end = mod.debugTimer('x', 'y') + end() + expect(writeSpy).not.toHaveBeenCalled() + writeSpy.mockRestore() + }) + + it('debugTimer reports elapsed ms when debug is on', async () => { + const writeSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + const mod = await import('../../src/utils/debug.js') + mod.enableDebug() + const end = mod.debugTimer('gen', 'write') + end() + expect(writeSpy).toHaveBeenCalledTimes(1) + const msg = String(writeSpy.mock.calls[0]?.[0]) + expect(msg).toMatch(/\(\d+ms\)/u) + writeSpy.mockRestore() + }) +})