Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions .changeset/phase-14c-doctor-extraction.md
Original file line number Diff line number Diff line change
@@ -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.
76 changes: 76 additions & 0 deletions .changeset/phase-14d-serve-ui-integration.md
Original file line number Diff line number Diff line change
@@ -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 `<pre>` 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/<model>[/<entry>]/<locale>.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<string, string>` typing — new keys type-safe at lookup.

No backend changes. Everything here is UI wiring on top of 14b + 14c.
69 changes: 69 additions & 0 deletions .changeset/phase-14e-cli-flags.md
Original file line number Diff line number Diff line change
@@ -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.
40 changes: 40 additions & 0 deletions .contentrain/content/serve-ui/serve-ui-texts/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -49,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",
Expand Down Expand Up @@ -126,7 +135,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",
Expand All @@ -140,6 +174,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",
Expand Down Expand Up @@ -223,6 +261,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",
Expand Down
13 changes: 8 additions & 5 deletions .contentrain/context.json
Original file line number Diff line number Diff line change
@@ -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:26:34.900Z",
"tool": "contentrain_content_save"
},
"stats": {
"entries": 41,
"lastSync": "2026-03-29T17:42:45.825Z",
"entries": 49,
"lastSync": "2026-04-17T21:26:34.900Z",
"locales": [
"en",
"tr"
Expand Down
36 changes: 35 additions & 1 deletion packages/cli/src/commands/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('')
Expand Down
Loading
Loading