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
88 changes: 88 additions & 0 deletions .changeset/phase-14a-mcp-boundary-cli-commands.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
---
"@contentrain/mcp": minor
"@contentrain/types": minor
"contentrain": minor
---

feat: MCP boundary hardening + CLI command polish

Folds the P2 "MCP entrypoint owns a private provider contract" finding
into a single pass with CLI gap-filling — one cohesive PR because the
new CLI commands (`merge`, `describe`, `describe-format`, `scaffold`)
ride the very in-memory client helper that the boundary refactor
makes safe to commit to.

### `@contentrain/types` — `MergeResult.sync`

- `MergeResult` gains an optional `sync?: SyncResult` field. Remote
providers (GitHub, GitLab) omit it; `LocalProvider` populates it
so selective-sync bookkeeping survives the trip through the shared
`RepoProvider.mergeBranch()` boundary.

### `@contentrain/mcp` — provider boundary

- `LocalProvider` now implements the full `RepoProvider` surface:
`listBranches`, `createBranch`, `deleteBranch`, `getBranchDiff`,
`mergeBranch`, `isMerged`, `getDefaultBranch`. All seven wrap
existing simple-git / transaction helpers through a new
`providers/local/branch-ops.ts` module that mirrors the
`providers/github/branch-ops.ts` shape.
- `mergeBranch(branch, into)` asserts `into === CONTENTRAIN_BRANCH` —
the local flow merges feature branches into the content-tracking
branch and advances the base branch via `update-ref`, so arbitrary
targets would bypass that invariant.
- `server.ts`: the private `ToolProvider = RepoReader & RepoWriter &
{ capabilities }` alias collapses to `type ToolProvider =
RepoProvider`. Tool handlers now depend on the shared surface
directly; the alias is kept purely so existing `ToolProvider`
imports do not have to migrate.
- `providers/local/types.ts` — `LocalSelectiveSyncResult` is removed
in favour of the shared `SyncResult` from `@contentrain/types`.
`workflowOverride` is typed with the shared `WorkflowMode` union
instead of the duplicated `'review' | 'auto-merge'` literal.
Matching swap inside `git/transaction.ts` so the whole write path
speaks one union.

### `contentrain` — four new commands + shared MCP client

- `utils/mcp-client.ts` — new shared `openMcpSession(projectRoot)`
helper built on `InMemoryTransport.createLinkedPair()`. Used by
the new commands and available for future ones that wrap MCP
tools one-shot.
- `contentrain merge <branch>` — scriptable single-branch sibling
to `contentrain diff`. Delegates to the same `mergeBranch()` MCP
helper so dirty-file protections + selective-sync warnings are
preserved. `--yes` skips the confirmation prompt for CI use.
- `contentrain describe <model>` — wraps `contentrain_describe`.
Human-readable metadata + fields + stats + import snippet view,
with `--sample`, `--locale`, `--json`.
- `contentrain describe-format` — wraps `contentrain_describe_format`.
Useful for humans pairing with an agent that's asked for the
format primer.
- `contentrain scaffold --template <id>` — wraps
`contentrain_scaffold`. Interactive template picker when no flag
is passed; `--locales en,tr,de`, `--no-sample`, `--json`.
- `commands/status.ts` — branch-health thresholds (50/80) now come
from `checkBranchHealth()` instead of being duplicated inline. The
JSON output surfaces the full `branch_health` object so CI
consumers see the same warning/blocked state the text mode does.

### Verification

- `pnpm -r typecheck` across `@contentrain/types`,
`@contentrain/mcp`, and `contentrain` — 0 errors.
- `oxlint` across MCP + CLI + types src/tests — 0 warnings.
- `@contentrain/types` vitest — 110/110.
- `contentrain` vitest — 130/130. Includes the 11 new command tests
(`merge`, `describe`, `scaffold`) and the updated `status` branch-
health test against the new `checkBranchHealth()` mock.
- New `tests/providers/local/branch-ops.test.ts` — 7/7. Covers
contract shape, prefix-filtered branch listing, create/delete
round-trip, diff status mapping (added/modified), post-merge
`isMerged` flip, `mergeBranch` target guard, and config-driven
`getDefaultBranch`.

### Tool surface

No changes. Same 16 MCP tools, same arg schemas, same response
shapes. The boundary changes are purely internal.
6 changes: 5 additions & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,11 @@ Requirements:
| `contentrain doctor` | Check setup health, SDK freshness, orphan content, and branch limits |
| `contentrain validate` | Validate content against schemas, optionally create review-branch fixes |
| `contentrain generate` | Generate `.contentrain/client/` and `#contentrain` package imports |
| `contentrain diff` | Review and merge or reject pending `cr/*` branches |
| `contentrain diff` | Review and merge or reject pending `cr/*` branches interactively |
| `contentrain merge <branch>` | Merge one pending `cr/*` branch non-interactively (CI/agents) |
| `contentrain describe <model>` | Inspect a model's schema, stats, and import snippet |
| `contentrain describe-format` | Print the Contentrain content-format specification |
| `contentrain scaffold --template` | Apply a template (`blog`, `landing`, `docs`, `ecommerce`, `saas`, `i18n`, `mobile`) |
| `contentrain serve` | Start the local review UI, the MCP stdio server (`--stdio`), or the MCP HTTP server (`--mcpHttp`) |
| `contentrain studio connect` | Connect a repository to a Studio project |
| `contentrain studio login` | Authenticate with Contentrain Studio |
Expand Down
44 changes: 44 additions & 0 deletions packages/cli/src/commands/describe-format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { defineCommand } from 'citty'
import { intro, outro, log } from '@clack/prompts'
import { resolveProjectRoot } from '../utils/context.js'
import { openMcpSession } from '../utils/mcp-client.js'
import { pc } from '../utils/ui.js'

/**
* Dumps the Contentrain content-format specification (what
* `contentrain_describe_format` returns). Mostly useful as a
* copy/paste primer for humans pairing with an agent, or for a quick
* `--json` handoff into another tool.
*/
export default defineCommand({
meta: {
name: 'describe-format',
description: 'Print the Contentrain content-format specification',
},
args: {
root: { type: 'string', description: 'Project root path', required: false },
json: { type: 'boolean', description: 'Emit raw JSON for scripts', required: false },
},
async run({ args }) {
const projectRoot = await resolveProjectRoot(args.root)
const session = await openMcpSession(projectRoot)

try {
const result = await session.call<Record<string, unknown>>('contentrain_describe_format', {})

if (args.json) {
process.stdout.write(JSON.stringify(result, null, 2))
return
}

intro(pc.bold('contentrain describe-format'))
log.message(pc.dim(JSON.stringify(result, null, 2)))
outro('')
} catch (error) {
log.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
} finally {
await session.close()
}
},
})
100 changes: 100 additions & 0 deletions packages/cli/src/commands/describe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { defineCommand } from 'citty'
import { intro, outro, log } from '@clack/prompts'
import { resolveProjectRoot } from '../utils/context.js'
import { openMcpSession } from '../utils/mcp-client.js'
import { pc } from '../utils/ui.js'

/**
* Read-only inspector for a single model, wrapping `contentrain_describe`.
*
* Useful for humans driving the CLI and for agents that want to sanity-
* check a model's fields, stats, and import snippet without committing
* anything. `--json` mirrors the MCP tool response verbatim.
*/
export default defineCommand({
meta: {
name: 'describe',
description: 'Show the schema, stats, and import snippet for a model',
},
args: {
model: { type: 'positional', description: 'Model ID (e.g. "blog-post", "hero")', required: true },
root: { type: 'string', description: 'Project root path', required: false },
sample: { type: 'boolean', description: 'Include one sample entry', required: false },
locale: { type: 'string', description: 'Locale for the sample entry', required: false },
json: { type: 'boolean', description: 'Emit raw JSON for scripts', required: false },
},
async run({ args }) {
const projectRoot = await resolveProjectRoot(args.root)
const modelId = String(args.model)
const session = await openMcpSession(projectRoot)

try {
const result = await session.call<Record<string, unknown>>('contentrain_describe', {
model: modelId,
include_sample: Boolean(args.sample),
...(args.locale ? { locale: String(args.locale) } : {}),
})

if (args.json) {
process.stdout.write(JSON.stringify(result, null, 2))
return
}

intro(pc.bold(`contentrain describe: ${modelId}`))

log.info(pc.bold('Metadata'))
log.message(` Name: ${String(result['name'] ?? '—')}`)
log.message(` Kind: ${pc.cyan(String(result['kind'] ?? '—'))}`)
log.message(` Domain: ${String(result['domain'] ?? '—')}`)
log.message(` i18n: ${result['i18n'] ? pc.green('yes') : pc.dim('no')}`)
if (result['description']) log.message(` About: ${String(result['description'])}`)

const stats = result['stats'] as { total_entries?: number, locales?: Record<string, number> } | undefined
if (stats) {
log.info(pc.bold('\nStats'))
log.message(` Total entries: ${stats.total_entries ?? 0}`)
if (stats.locales) {
for (const [loc, n] of Object.entries(stats.locales)) {
log.message(` ${loc}: ${n}`)
}
}
}

const fields = result['fields'] as Record<string, unknown> | undefined
if (fields) {
log.info(pc.bold('\nFields'))
for (const [name, spec] of Object.entries(fields)) {
const s = spec as { type?: string, required?: boolean }
const req = s.required ? pc.yellow(' *') : ''
log.message(` ${pc.bold(name)}${req} ${pc.dim(s.type ?? '')}`)
}
}

if (result['import_snippet']) {
log.info(pc.bold('\nImport snippet'))
log.message(pc.dim(String(result['import_snippet'])))
}

const vocab = result['vocabulary_hint'] as { note?: string, terms?: Record<string, unknown> } | undefined
if (vocab?.terms && Object.keys(vocab.terms).length > 0) {
log.info(pc.bold('\nVocabulary hint'))
if (vocab.note) log.message(pc.dim(` ${vocab.note}`))
for (const term of Object.keys(vocab.terms)) {
log.message(` • ${term}`)
}
}

if (result['sample']) {
log.info(pc.bold('\nSample entry'))
log.message(pc.dim(JSON.stringify(result['sample'], null, 2)))
}

outro('')
} catch (error) {
log.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
} finally {
await session.close()
}
},
})
82 changes: 82 additions & 0 deletions packages/cli/src/commands/merge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { defineCommand } from 'citty'
import { intro, outro, log, confirm, isCancel } from '@clack/prompts'
import { simpleGit } from 'simple-git'
import { mergeBranch } from '@contentrain/mcp/git/transaction'
import { CONTENTRAIN_BRANCH } from '@contentrain/types'
import { resolveProjectRoot } from '../utils/context.js'
import { pc } from '../utils/ui.js'

/**
* One-shot merge of a single contentrain feature branch.
*
* `contentrain diff` drives an interactive review over every pending
* branch; this command is the scriptable, one-branch sibling —
* shell-friendly for agents, CI, and manual triage. It reuses the exact
* same MCP helper (`mergeBranch`) that the interactive command
* delegates to, so the worktree transaction + selective-sync + dirty-
* file protections stay on one code path.
*/
export default defineCommand({
meta: {
name: 'merge',
description: 'Merge one contentrain feature branch into the content branch',
},
args: {
branch: { type: 'positional', description: 'Feature branch name (e.g. cr/content/blog/...)', required: true },
root: { type: 'string', description: 'Project root path', required: false },
yes: { type: 'boolean', description: 'Skip confirmation prompt', required: false },
},
async run({ args }) {
const projectRoot = await resolveProjectRoot(args.root)
const branch = String(args.branch)

intro(pc.bold('contentrain merge'))

if (branch === CONTENTRAIN_BRANCH) {
log.error(`Cannot merge the content-tracking branch (${CONTENTRAIN_BRANCH}) into itself.`)
outro('')
process.exitCode = 1
return
}

const git = simpleGit(projectRoot)
const localBranches = await git.branchLocal()
if (!localBranches.all.includes(branch)) {
log.error(`Branch "${branch}" does not exist locally.`)
outro('')
process.exitCode = 1
return
}

if (!args.yes) {
const ok = await confirm({ message: `Merge ${branch} into ${CONTENTRAIN_BRANCH} and fast-forward the base branch?` })
if (isCancel(ok) || !ok) {
outro('')
return
}
}

try {
const result = await mergeBranch(projectRoot, branch)
log.success(`Merged ${branch} (commit ${result.commit.slice(0, 8)})`)
if (result.sync?.synced?.length) {
log.message(pc.dim(` Synced ${result.sync.synced.length} file(s) to working tree.`))
}
if (result.sync?.skipped?.length) {
log.warning(`${result.sync.skipped.length} file(s) skipped during sync — you have uncommitted changes:`)
for (const f of result.sync.skipped) {
log.message(pc.dim(` ${f}`))
}
if (result.sync.warning) {
log.message(pc.dim(` ${result.sync.warning}`))
}
}
} catch (error) {
log.error(`Merge failed: ${error instanceof Error ? error.message : String(error)}`)
process.exitCode = 1
return
}

outro('')
},
})
Loading
Loading