From efc67ab6351ab7f16fe4c9d596b549e5dbc6f2fd Mon Sep 17 00:00:00 2001 From: rufushudson Date: Mon, 11 May 2026 16:17:16 +0100 Subject: [PATCH 1/4] feat(cli): add `contexture inspect` command Single-shot human and --json summary of the schema: types, fields, enums, discriminated unions, raw types, and imports. Intended as the entry point for agents that need to orient before mutating the model. --- packages/cli/src/index.ts | 152 +++++++++++++++++++++++++++++++++ packages/cli/tests/cli.test.ts | 63 ++++++++++++++ 2 files changed, 215 insertions(+) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index e371c84..363fefc 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -6,10 +6,12 @@ import { createOpTools, type FieldDef, type FieldType, + type ImportDecl, IRSchema, load, runEmitPipeline, type Schema, + type TypeDef, } from '@contexture/core'; interface CliOptions { @@ -32,6 +34,7 @@ interface JsonError { const HELP = `contexture [args] Read helpers: + inspect [--json] list-types [--json] get-type [--json] validate [--json] @@ -179,6 +182,147 @@ function fieldFromArgs(args: string[]): FieldDef { }; } +function fieldTypeToString(type: FieldType): string { + switch (type.kind) { + case 'string': + return type.format ? `string<${type.format}>` : 'string'; + case 'number': + return type.int ? 'integer' : 'number'; + case 'boolean': + return 'boolean'; + case 'date': + return 'date'; + case 'literal': + return `literal(${JSON.stringify(type.value)})`; + case 'ref': + return type.typeName; + case 'array': + return `${fieldTypeToString(type.element)}[]`; + } +} + +interface InspectJsonType { + name: string; + kind: TypeDef['kind']; + table?: boolean; + fieldCount?: number; + fields?: Array<{ name: string; type: string; optional?: boolean; nullable?: boolean }>; + values?: string[]; + variants?: string[]; + discriminator?: string; +} + +interface InspectJson { + path: string; + version: '1'; + name?: string; + typeCount: number; + types: InspectJsonType[]; + imports: Array<{ kind: ImportDecl['kind']; alias: string; path: string }>; +} + +function typeToInspectJson(type: TypeDef): InspectJsonType { + if (type.kind === 'object') { + return { + name: type.name, + kind: 'object', + ...(type.table ? { table: true } : {}), + fieldCount: type.fields.length, + fields: type.fields.map((field) => ({ + name: field.name, + type: fieldTypeToString(field.type), + ...(field.optional ? { optional: true } : {}), + ...(field.nullable ? { nullable: true } : {}), + })), + }; + } + if (type.kind === 'enum') { + return { + name: type.name, + kind: 'enum', + values: type.values.map((v) => v.value), + }; + } + if (type.kind === 'discriminatedUnion') { + return { + name: type.name, + kind: 'discriminatedUnion', + discriminator: type.discriminator, + variants: type.variants, + }; + } + return { name: type.name, kind: 'raw' }; +} + +function buildInspectJson(schema: Schema, irPath: string): InspectJson { + const types: InspectJsonType[] = schema.types.map(typeToInspectJson); + + return { + path: irPath, + version: schema.version, + ...(schema.metadata?.name ? { name: schema.metadata.name } : {}), + typeCount: schema.types.length, + types, + imports: (schema.imports ?? []).map((imp) => ({ + kind: imp.kind, + alias: imp.alias, + path: imp.path, + })), + }; +} + +function renderInspectText(summary: InspectJson): string { + const lines: string[] = []; + lines.push(`Schema: ${summary.name ?? '(unnamed)'}`); + lines.push(`Path: ${summary.path}`); + lines.push(`Types: ${summary.typeCount}`); + + const byKind = (kind: TypeDef['kind']) => summary.types.filter((t) => t.kind === kind); + + const objects = byKind('object'); + if (objects.length > 0) { + lines.push('Objects:'); + for (const obj of objects) { + lines.push(` ${obj.name}${obj.table ? ' [table]' : ''}`); + for (const field of obj.fields ?? []) { + const marks = (field.optional ? '?' : '') + (field.nullable ? ' | null' : ''); + lines.push(` - ${field.name}: ${field.type}${marks}`); + } + } + } + + const enums = byKind('enum'); + if (enums.length > 0) { + lines.push('Enums:'); + for (const en of enums) { + lines.push(` ${en.name}: ${(en.values ?? []).join(', ')}`); + } + } + + const unions = byKind('discriminatedUnion'); + if (unions.length > 0) { + lines.push('Discriminated unions:'); + for (const u of unions) { + lines.push(` ${u.name} (on ${u.discriminator ?? '?'}): ${(u.variants ?? []).join(', ')}`); + } + } + + const raws = byKind('raw'); + if (raws.length > 0) { + lines.push('Raw:'); + for (const r of raws) lines.push(` ${r.name}`); + } + + if (summary.imports.length > 0) { + lines.push('Imports:'); + for (const imp of summary.imports) { + lines.push(` ${imp.alias} -> ${imp.path}`); + } + } + + return `${lines.join('\n')}\n`; +} + function commandToToolInput(command: string, args: string[]): { tool: string; input: object } { switch (command) { case 'add-field': { @@ -305,6 +449,14 @@ async function run(argv: string[]): Promise { const irPath = options.irPath ?? (await findIrPath(options.cwd)); + if (command === 'inspect') { + const schema = await readSchema(irPath); + const summary = buildInspectJson(schema, irPath); + if (options.json) writeJson({ ok: true, ...summary }); + else process.stdout.write(renderInspectText(summary)); + return; + } + if (command === 'list-types') { const schema = await readSchema(irPath); writeResult( diff --git a/packages/cli/tests/cli.test.ts b/packages/cli/tests/cli.test.ts index 953c64d..13aa2a4 100644 --- a/packages/cli/tests/cli.test.ts +++ b/packages/cli/tests/cli.test.ts @@ -38,6 +38,69 @@ async function runCli(cwd: string, args: string[]) { } describe('@contexture/cli', () => { + it('inspects schema as structured JSON', async () => { + const { dir, irPath } = await fixtureProject(); + await writeFile( + irPath, + `${JSON.stringify({ + version: '1', + metadata: { name: 'TestSchema' }, + types: [ + { + kind: 'object', + name: 'Post', + table: true, + fields: [ + { name: 'title', type: { kind: 'string' } }, + { + name: 'tags', + type: { kind: 'array', element: { kind: 'string' } }, + optional: true, + }, + ], + }, + { kind: 'enum', name: 'Status', values: [{ value: 'draft' }, { value: 'live' }] }, + ], + imports: [{ kind: 'stdlib', path: '@contexture/common', alias: 'common' }], + })}\n`, + ); + + const result = await runCli(dir, ['inspect', '--json']); + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.stdout); + expect(parsed).toMatchObject({ + ok: true, + version: '1', + name: 'TestSchema', + typeCount: 2, + imports: [{ kind: 'stdlib', alias: 'common', path: '@contexture/common' }], + }); + expect(parsed.types[0]).toMatchObject({ + name: 'Post', + kind: 'object', + table: true, + fieldCount: 2, + fields: [ + { name: 'title', type: 'string' }, + { name: 'tags', type: 'string[]', optional: true }, + ], + }); + expect(parsed.types[1]).toMatchObject({ + name: 'Status', + kind: 'enum', + values: ['draft', 'live'], + }); + }); + + it('inspects schema as human-readable text', async () => { + const { dir } = await fixtureProject(); + const result = await runCli(dir, ['inspect']); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Types: 1'); + expect(result.stdout).toContain('Objects:'); + expect(result.stdout).toContain('Post [table]'); + }); + it('lists types as structured JSON', async () => { const { dir } = await fixtureProject(); const result = await runCli(dir, ['list-types', '--json']); From 0c64f02ca923a47f19c60c62de12e8f656ed5488 Mon Sep 17 00:00:00 2001 From: rufushudson Date: Mon, 11 May 2026 16:18:15 +0100 Subject: [PATCH 2/4] feat(cli): add `contexture check-generated` command Re-runs the emit pipeline in memory and compares each output against the file on disk. Exits non-zero with a list of stale paths so CI and agent loops can detect drift between the IR and the generated bundle. --- packages/cli/src/index.ts | 35 +++++++++++++++++++++++++++ packages/cli/tests/cli.test.ts | 44 ++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 363fefc..a33f300 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -39,6 +39,7 @@ Read helpers: get-type [--json] validate [--json] emit [--json] + check-generated [--json] Schema mutations: add-field [--optional] [--nullable] @@ -506,6 +507,40 @@ async function run(argv: string[]): Promise { return; } + if (command === 'check-generated') { + const schema = await readSchema(irPath); + const { emitted } = runEmitPipeline(schema, irPath); + const stale: Array<{ path: string; reason: 'missing' | 'mismatch' }> = []; + for (const entry of emitted) { + let onDisk: string | undefined; + try { + onDisk = await Bun.file(entry.path).text(); + } catch { + onDisk = undefined; + } + if (onDisk === undefined) stale.push({ path: entry.path, reason: 'missing' }); + else if (onDisk !== entry.content) stale.push({ path: entry.path, reason: 'mismatch' }); + } + if (stale.length > 0) { + process.exitCode = 1; + if (options.json) { + writeJson({ ok: false, stale }); + } else { + process.stderr.write('Generated files are stale:\n'); + for (const { path, reason } of stale) { + process.stderr.write(` ${path} (${reason})\n`); + } + process.stderr.write('\nRun: contexture emit\n'); + } + return; + } + writeResult( + { message: 'Generated files are up to date.', checked: emitted.length }, + options.json, + ); + return; + } + const forward = createFileBackedForward(irPath); const tools = new Map(createOpTools(forward).map((tool) => [tool.name, tool])); const { tool: toolName, input } = commandToToolInput(command, args); diff --git a/packages/cli/tests/cli.test.ts b/packages/cli/tests/cli.test.ts index 13aa2a4..e041a53 100644 --- a/packages/cli/tests/cli.test.ts +++ b/packages/cli/tests/cli.test.ts @@ -145,6 +145,50 @@ describe('@contexture/cli', () => { ).resolves.toContain('title'); }); + it('check-generated reports stale files when nothing is emitted', async () => { + const { dir } = await fixtureProject(); + const result = await runCli(dir, ['check-generated', '--json']); + expect(result.exitCode).toBe(1); + const parsed = JSON.parse(result.stdout); + expect(parsed.ok).toBe(false); + expect(Array.isArray(parsed.stale)).toBe(true); + expect(parsed.stale.length).toBeGreaterThan(0); + for (const entry of parsed.stale) { + expect(entry).toMatchObject({ reason: 'missing' }); + } + }); + + it('check-generated passes after emit', async () => { + const { dir } = await fixtureProject(); + const emitResult = await runCli(dir, ['emit', '--json']); + expect(emitResult.exitCode).toBe(0); + + const checkResult = await runCli(dir, ['check-generated', '--json']); + expect(checkResult.exitCode).toBe(0); + expect(JSON.parse(checkResult.stdout)).toMatchObject({ + ok: true, + message: expect.stringContaining('up to date'), + }); + }); + + it('check-generated detects drift after manual edits', async () => { + const { dir } = await fixtureProject(); + await runCli(dir, ['emit', '--json']); + const convexPath = join(dir, 'packages/contexture/convex/schema.ts'); + const current = await readFile(convexPath, 'utf8'); + await writeFile(convexPath, `${current}\n// drifted\n`, 'utf8'); + + const result = await runCli(dir, ['check-generated', '--json']); + expect(result.exitCode).toBe(1); + const parsed = JSON.parse(result.stdout); + expect( + parsed.stale.some( + (s: { path: string; reason: string }) => + s.path.endsWith('convex/schema.ts') && s.reason === 'mismatch', + ), + ).toBe(true); + }); + it('exits non-zero with JSON when an op fails', async () => { const { dir } = await fixtureProject(); const result = await runCli(dir, ['delete-field', 'Post', 'missing', '--json']); From 6436b4a19d39a4680ab6896da7ad2d0e42745a28 Mon Sep 17 00:00:00 2001 From: rufushudson Date: Mon, 11 May 2026 16:20:36 +0100 Subject: [PATCH 3/4] feat(cli): add `contexture apply` for serialized ops Accepts `--op-json ''` or `--op-file ` and forwards the op through the file-backed pipeline. Pairs the existing per-op subcommands with a single entrypoint for callers that already have a serialized Op, matching the agent-facing tool-registry shape. --- packages/cli/src/index.ts | 66 ++++++++++++++++++++++++++++++++-- packages/cli/tests/cli.test.ts | 45 +++++++++++++++++++++++ 2 files changed, 109 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index a33f300..cb3478a 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -17,6 +17,8 @@ import { interface CliOptions { json: boolean; irPath?: string; + opJson?: string; + opFile?: string; cwd: string; } @@ -42,6 +44,7 @@ Read helpers: check-generated [--json] Schema mutations: + apply (--op-json | --op-file ) add-field [--optional] [--nullable] update-field delete-field @@ -61,8 +64,10 @@ Schema mutations: replace-schema Options: - --ir Path to a .contexture.json file - --json Emit machine-readable JSON + --ir Path to a .contexture.json file + --json Emit machine-readable JSON + --op-json Inline op for \`apply\` + --op-file Path to a JSON op for \`apply\` `; function parseArgv(argv: string[], cwd = process.cwd()): ParsedArgs { @@ -88,6 +93,28 @@ function parseArgv(argv: string[], cwd = process.cwd()): ParsedArgs { options.irPath = resolve(cwd, arg.slice('--ir='.length)); continue; } + if (arg === '--op-json') { + const value = args[i + 1]; + if (!value) throw new Error('--op-json requires a JSON string'); + options.opJson = value; + i += 1; + continue; + } + if (arg?.startsWith('--op-json=')) { + options.opJson = arg.slice('--op-json='.length); + continue; + } + if (arg === '--op-file') { + const value = args[i + 1]; + if (!value) throw new Error('--op-file requires a path'); + options.opFile = resolve(cwd, value); + i += 1; + continue; + } + if (arg?.startsWith('--op-file=')) { + options.opFile = resolve(cwd, arg.slice('--op-file='.length)); + continue; + } rest.push(arg); } @@ -541,6 +568,41 @@ async function run(argv: string[]): Promise { return; } + if (command === 'apply') { + let opJson = options.opJson; + if (!opJson && options.opFile) { + opJson = await Bun.file(options.opFile).text(); + } + if (!opJson) { + throw new Error('apply requires --op-json or --op-file '); + } + const op = parseJsonArg<{ kind?: unknown }>(opJson, 'op'); + if (!op || typeof op !== 'object' || typeof op.kind !== 'string') { + throw new Error('op must be an object with a string "kind" field'); + } + const forward = createFileBackedForward(irPath); + type ForwardResult = Awaited>; + let result: ForwardResult; + try { + result = await forward(op as Parameters[0]); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`apply failed: ${message}`); + } + if ('error' in result) { + process.exitCode = 1; + const error: JsonError = { + ok: false, + error: { message: result.error, code: 'APPLY_FAILED' }, + }; + if (options.json) writeJson(error); + else process.stderr.write(`${result.error}\n`); + return; + } + writeResult({ message: `${op.kind} applied.`, schema: result.schema }, options.json); + return; + } + const forward = createFileBackedForward(irPath); const tools = new Map(createOpTools(forward).map((tool) => [tool.name, tool])); const { tool: toolName, input } = commandToToolInput(command, args); diff --git a/packages/cli/tests/cli.test.ts b/packages/cli/tests/cli.test.ts index e041a53..9126634 100644 --- a/packages/cli/tests/cli.test.ts +++ b/packages/cli/tests/cli.test.ts @@ -189,6 +189,51 @@ describe('@contexture/cli', () => { ).toBe(true); }); + it('apply --op-json applies a serialized op', async () => { + const { dir, irPath } = await fixtureProject(); + const op = JSON.stringify({ + kind: 'add_field', + typeName: 'Post', + field: { name: 'title', type: { kind: 'string' } }, + }); + const result = await runCli(dir, ['apply', '--op-json', op, '--json']); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: true, + message: 'add_field applied.', + }); + const ir = JSON.parse(await readFile(irPath, 'utf8')); + expect(ir.types[0].fields).toEqual([{ name: 'title', type: { kind: 'string' } }]); + }); + + it('apply --op-file reads the op from disk', async () => { + const { dir, irPath } = await fixtureProject(); + const opPath = join(dir, 'op.json'); + await writeFile( + opPath, + JSON.stringify({ + kind: 'add_field', + typeName: 'Post', + field: { name: 'body', type: { kind: 'string' } }, + }), + ); + const result = await runCli(dir, ['apply', '--op-file', opPath, '--json']); + expect(result.exitCode).toBe(0); + const ir = JSON.parse(await readFile(irPath, 'utf8')); + expect(ir.types[0].fields).toEqual([{ name: 'body', type: { kind: 'string' } }]); + }); + + it('apply rejects ops with semantic errors', async () => { + const { dir } = await fixtureProject(); + const op = JSON.stringify({ kind: 'remove_field', typeName: 'Post', fieldName: 'nope' }); + const result = await runCli(dir, ['apply', '--op-json', op, '--json']); + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: false, + error: { code: 'APPLY_FAILED' }, + }); + }); + it('exits non-zero with JSON when an op fails', async () => { const { dir } = await fixtureProject(); const result = await runCli(dir, ['delete-field', 'Post', 'missing', '--json']); From e6783665b5833596d5f351f1eb0ec6f2dbe75838 Mon Sep 17 00:00:00 2001 From: rufushudson Date: Mon, 11 May 2026 16:22:41 +0100 Subject: [PATCH 4/4] docs(contexture): add agent workflow doc and mark plan phases complete Adds docs/agent-contexture-workflow.md as the canonical workflow guide for coding agents working in downstream apps, covering the CLI surface, rules, recommended loop, and the `check-generated` CI wiring pattern. Updates the plan doc to reflect what shipped in this PR (inspect, check-generated, apply) and marks the deferred items (root CI wiring, optional skill, init-agent-docs command) as backlog. Emitter header audit confirmed: all four pipeline outputs carry `@contexture-generated`; `emit-table-crud` and `emit-claude-md` intentionally carry `@contexture-seeded` (written once, user-owned). --- docs/agent-contexture-workflow.md | 124 ++++ ...contexture-core-cli-agent-workflow-plan.md | 670 ++++-------------- 2 files changed, 269 insertions(+), 525 deletions(-) create mode 100644 docs/agent-contexture-workflow.md diff --git a/docs/agent-contexture-workflow.md b/docs/agent-contexture-workflow.md new file mode 100644 index 0000000..66bf1cb --- /dev/null +++ b/docs/agent-contexture-workflow.md @@ -0,0 +1,124 @@ +# Contexture domain-model workflow + +This project uses **Contexture** as the source of truth for its domain model. +The IR lives in `*.contexture.json`; everything else (Convex schema, Zod +schema, JSON Schema, the schema-index barrel) is regenerated from it. + +## Rules for agents + +- **Do not edit generated schema files.** They carry a + `@contexture-generated` header and will be overwritten on the next + regenerate. Files marked `@contexture-seeded` (e.g. table CRUD + scaffolds, root `CLAUDE.md`) are seeded once and owned by you after that. +- **Treat `*.contexture.json` as the primary domain model.** Add or change + entities, fields, refs, enums, indexes, and table flags there first. +- **Regenerate after every model change.** Run `contexture emit`. +- **Validate before finishing.** Run `contexture validate` and + `contexture check-generated` before declaring the task done. + +## CLI quick reference + +The CLI auto-discovers a single `*.contexture.json` in +`./packages/contexture/` or the current directory. Pass `--ir ` +if you need to target a specific file. Add `--json` to any command to +get a machine-readable envelope (`{ ok: true, ... }` or +`{ ok: false, error: { message, code } }`). + +### Inspect + +```bash +contexture inspect [--json] +contexture list-types [--json] +contexture get-type [--json] +``` + +### Validate and check for drift + +```bash +contexture validate [--json] +contexture check-generated [--json] # exits 1 if any generated file is stale +``` + +`check-generated` re-runs the emit pipeline in memory and compares each +output against the file on disk. Wire it into your downstream app's CI +so a missed `contexture emit` fails the build: + +```jsonc +// package.json +{ + "scripts": { + "ci": "tsc --noEmit && vitest run && contexture check-generated" + } +} +``` + +### Mutate the model + +Pick whichever entrypoint is more convenient. Both validate the result +and re-emit the bundle: + +**Per-op subcommands** — typed argv, no JSON quoting hell: + +```bash +contexture add-field User email '{"kind":"string","format":"email"}' +contexture update-field User email '{"optional": true}' +contexture delete-field User legacyId +contexture add-type '{"kind":"object","name":"Project","fields":[]}' +contexture set-table-flag Project true +contexture add-index Project byOwner ownerId +``` + +Full surface in `contexture help`. + +**Generic apply** — for callers that already have a serialized `Op`: + +```bash +contexture apply --op-json '{"kind":"add_field","typeName":"User","field":{"name":"email","type":{"kind":"string","format":"email"}}}' +contexture apply --op-file ./op.json +``` + +### Regenerate + +```bash +contexture emit [--json] +``` + +Writes the full bundle (Zod schema, JSON Schema, schema-index barrel, +Convex schema) to the locations resolved by `bundlePathsFor(irPath)`. + +## Standard agent loop + +``` +inspect → mutate → validate → emit → check-generated → app typecheck/tests +``` + +Concretely: + +1. `contexture inspect --json` — orient yourself in the current model. +2. Decide what needs to change. +3. Apply the change via a per-op subcommand or `contexture apply`. +4. `contexture validate` — confirm the IR is still valid. +5. `contexture emit` is implicit on mutations, but run it explicitly if + you edited the IR JSON by hand. +6. `contexture check-generated` — confirm nothing has drifted. +7. Run the app's own `bun run typecheck` / `bun run test` to confirm + downstream code still compiles against the regenerated schemas. + +## When to edit the JSON directly + +Prefer the CLI. Direct edits to `*.contexture.json` are fine for bulk +rewrites or experimental work, but you must: + +1. Run `contexture validate` afterwards. +2. Run `contexture emit` to regenerate the bundle. +3. Run `contexture check-generated` to confirm the on-disk artifacts + match the IR. + +## Output contract + +- All commands accept `--json` and return either + `{ ok: true, ... }` or `{ ok: false, error: { message, code } }`. +- Exit code is `0` on success, `1` on any failure (validation, op + rejection, drift, parse error). +- Human (non-`--json`) output prints terse messages to stdout and + errors to stderr. diff --git a/docs/contexture-core-cli-agent-workflow-plan.md b/docs/contexture-core-cli-agent-workflow-plan.md index 9634cb6..2695712 100644 --- a/docs/contexture-core-cli-agent-workflow-plan.md +++ b/docs/contexture-core-cli-agent-workflow-plan.md @@ -6,23 +6,17 @@ Make Contexture the source of truth for downstream app domain models, so coding Scope: **Phase 1, Phase 2, and Phase 3 only**. ---- - -# Phase 1 — Extract `@contexture/core` +Status legend: ✅ done · 🟡 partial · ⛔ not started -## Objective +--- -Move all pure domain-model logic out of the Electron renderer so it can be used by: +# Phase 1 — Extract `@contexture/core` ✅ -- Desktop app -- CLI -- future MCP wrapper, if needed -- tests/CI -- generated downstream projects +## Outcome -## Target package +All pure domain-model logic lives in `packages/core/` and is consumed by the desktop app, the CLI, and tests. No Electron, React, Zustand, IPC, or DOM imports leak into core. -Create: +## What shipped ```txt packages/core/ @@ -33,582 +27,208 @@ packages/core/ ir.ts load.ts migrations/ - index.ts ops.ts - validation.ts + op-tools.ts + semantic-validation.ts + pipeline.ts + paths.ts + file-forward.ts emit-zod.ts emit-json-schema.ts emit-convex.ts -``` - -## Move from desktop renderer - -Move or refactor these existing modules: - -```txt -apps/desktop/src/renderer/src/model/ir.ts -apps/desktop/src/renderer/src/model/load.ts -apps/desktop/src/renderer/src/model/migrations/ -apps/desktop/src/renderer/src/model/emit-zod.ts -apps/desktop/src/renderer/src/model/emit-json-schema.ts -apps/desktop/src/renderer/src/model/emit-convex.ts -apps/desktop/src/renderer/src/store/ops.ts -apps/desktop/src/renderer/src/services/validation.ts -``` - -Into: - -```txt -packages/core/src/ -``` - -Rename `store/ops.ts` to something less UI-specific: - -```txt -packages/core/src/ops.ts -``` + emit-schema-index.ts + emit-table-crud.ts + emit-claude-md.ts + tests/ + apply-semantic-gate.test.ts + emit-stdlib-imports.test.ts + file-forward.test.ts + semantic-validation.test.ts +``` + +### Divergences from the original draft (kept on purpose) + +- `validation.ts` was renamed to `semantic-validation.ts` to make the layer obvious. +- The original draft listed only Zod / JSON Schema / Convex emitters. Reality ships more: `emit-schema-index`, `emit-table-crud`, `emit-claude-md`. +- Two additional modules exist beyond the draft: + - `pipeline.ts` — `runEmitPipeline(schema, irPath)` runs all emitters and returns a hashed `EmittedManifest` for drift detection. + - `paths.ts` — single source of truth for bundle paths (`*.schema.ts`, `*.schema.json`, `convex/schema.ts`, `.contexture/{layout,chat,emitted}.json`). + - `file-forward.ts` — `createFileBackedForward(irPath)` applies an op to disk transactionally. + - `op-tools.ts` — `createOpTools(forward)` exposes each op as a typed tool (shared by CLI and the in-app agent surface). ## Public API -`packages/core/src/index.ts` should export: - -```ts -export * from './ir' -export * from './load' -export * from './ops' -export * from './validation' -export * from './emit-zod' -export * from './emit-json-schema' -export * from './emit-convex' -``` - -Intended usage: +`packages/core/src/index.ts` re-exports everything; `package.json` also publishes subpath exports so callers can pull narrowly: ```ts import { IRSchema, load, - save, apply, - validate, - emitZod, - emitJsonSchema, - emitConvexSchema, -} from '@contexture/core' + createOpTools, + createFileBackedForward, + runEmitPipeline, + bundlePathsFor, +} from '@contexture/core'; ``` -## Package config - -`packages/core/package.json`: - -```json -{ - "name": "@contexture/core", - "version": "0.14.0", - "private": true, - "type": "module", - "main": "./src/index.ts", - "types": "./src/index.ts", - "dependencies": { - "zod": "^4.3.6" - } -} -``` +Subpaths available: `./ir`, `./load`, `./ops`, `./op-tools`, `./migrations`, `./semantic-validation`, `./pipeline`, `./paths`, `./file-forward`, `./emit-zod`, `./emit-json-schema`, `./emit-convex`, `./emit-schema-index`, `./emit-table-crud`, `./emit-claude-md`. -If `emit-zod` or validation needs stdlib data, avoid importing from desktop. Either keep imports from `@contexture/stdlib`, or make stdlib registry an injected option. Prefer injection where possible: +## Desktop integration -```ts -validate(schema, { stdlib }) -``` - -## Desktop updates - -Change desktop imports from renderer-local modules to core package imports. - -Examples: - -```ts -// Before -import { emit } from './model/emit-zod' - -// After -import { emitZod } from '@contexture/core' -``` +The desktop renderer kept its old paths (`apps/desktop/src/renderer/src/model/*.ts`) as **thin re-export shims**, e.g.: ```ts -// Before -import type { Op } from '../store/ops' - -// After -import type { Op } from '@contexture/core' +// apps/desktop/src/renderer/src/model/ir.ts +export * from '@contexture/core/ir'; ``` -The desktop `useUndoStore` should remain in desktop, but use core’s pure reducer: - -```ts -import { apply as applyOp } from '@contexture/core' -``` +This avoided rewriting every callsite. Future cleanup (low priority) can drop the shims and update imports to `@contexture/core`. -## Testing +## Exit criteria — met -Move core tests out of desktop where sensible: - -```txt -packages/core/src/*.test.ts -``` - -Core should have tests for: - -- IR parsing -- load/save -- migrations -- each op -- validation -- Zod emitter -- JSON Schema emitter -- Convex emitter - -## Exit criteria - -- `@contexture/core` builds/typechecks. -- Desktop imports core package instead of renderer-local pure modules. -- No Electron, React, Zustand, IPC, or DOM imports in `packages/core`. -- Existing desktop behavior unchanged. -- These pass: - -```bash -bun run format:check -bun run lint -bun run test -bun run typecheck -``` +- `@contexture/core` builds and typechecks. +- Desktop consumes core (via shims). +- No Electron / React / Zustand / IPC / DOM imports in `packages/core`. +- `bun run ci` (typecheck + test + biome) passes. --- -# Phase 2 — Build `contexture` CLI - -## Objective +# Phase 2 — `@contexture/cli` ✅ -Create a CLI that coding agents and humans can use to inspect, validate, mutate, and regenerate artifacts from `.contexture.json`. +## Outcome so far -This becomes the primary automation boundary. - -## Target package - -Create: +`packages/cli/src/index.ts` is a single-file CLI wired to `@contexture/core`'s op-tools registry. It can read the IR, validate it, run the emit pipeline, and apply any structured op. ```txt packages/cli/ - package.json + package.json # bin: contexture -> ./src/index.ts (run via Bun) tsconfig.json - src/ - index.ts - commands/ - inspect.ts - validate.ts - apply.ts - emit.ts - check-generated.ts -``` - -Package name: - -```json -{ - "name": "@contexture/cli", - "bin": { - "contexture": "./src/index.ts" - } -} -``` - -For internal use, running through Bun is acceptable initially: - -```bash -bun packages/cli/src/index.ts validate domain.contexture.json -``` - -Later this can become a real executable. - -## CLI commands - -## 1. `contexture inspect` - -Purpose: give agents a machine-readable and human-readable summary. - -```bash -contexture inspect ./domain.contexture.json -contexture inspect ./domain.contexture.json --json -``` - -Human output example: - -```txt -Schema: Domain -Types: 5 -Objects: - User - - name: string - - email: common.Email - Project - - title: string - - owner: User -Enums: - ProjectStatus: active, archived -Imports: - common -> @contexture/common + src/index.ts + tests/cli.test.ts ``` -JSON output shape: - -```json -{ - "path": "./domain.contexture.json", - "version": "1", - "typeCount": 5, - "types": [ - { - "name": "User", - "kind": "object", - "fields": [ - { "name": "name", "type": "string" }, - { "name": "email", "type": "common.Email" } - ] - } - ], - "imports": [] -} -``` - -## 2. `contexture validate` - -Purpose: validate IR and semantic rules. - -```bash -contexture validate ./domain.contexture.json -contexture validate ./domain.contexture.json --json -``` +## What shipped -Exit codes: +### IR discovery -- `0`: valid -- `1`: invalid +If `--ir` is not passed, the CLI looks in `./packages/contexture/` then `./` for exactly one `*.contexture.json`. Fails loudly on zero or multiple matches. -JSON output: +### Commands (current) -```json -{ - "valid": false, - "errors": [ - { - "path": "types[1].fields[0].typeName", - "message": "Unknown ref UserProfile" - } - ] -} -``` - -## 3. `contexture emit` - -Purpose: generate downstream artifacts. +Read helpers: ```bash -contexture emit zod ./domain.contexture.json --out ./packages/domain/schema.ts -contexture emit json-schema ./domain.contexture.json --out ./packages/domain/schema.json -contexture emit convex ./domain.contexture.json --out ./apps/web/convex/schema.ts +contexture list-types [--json] +contexture get-type [--json] +contexture validate [--json] +contexture emit [--json] ``` -Also support stdout: +Schema mutations (one subcommand per op — chosen over a generic `apply --op-json` for typed argv and no JSON-quoting): ```bash -contexture emit zod ./domain.contexture.json +contexture add-field [--optional] [--nullable] +contexture update-field +contexture delete-field +contexture reorder-fields +contexture add-type +contexture update-type +contexture rename-type +contexture delete-type +contexture set-table-flag +contexture add-index +contexture remove-index +contexture update-index +contexture add-variant +contexture set-discriminator +contexture add-value [description] +contexture update-value +contexture remove-value +contexture add-import +contexture remove-import +contexture replace-schema ``` -Recommended subcommands: +Mutations route through `createOpTools(createFileBackedForward(irPath))`, so the CLI, the desktop agent surface, and any future MCP wrapper share one validated apply path. -```txt -contexture emit zod -contexture emit json-schema -contexture emit convex -contexture emit all -``` +### Output contract -`emit all` can write conventional sidecars next to the IR: +- `--json` produces `{ ok: true, ... }` envelopes; failures produce `{ ok: false, error: { message, code } }`. +- Exit code is `1` on any failure (validate failure, op rejection, parse error). +- Non-JSON mode prints terse human messages to stdout / errors to stderr. -```bash -contexture emit all ./domain.contexture.json -``` - -For now, keep explicit `--out`. - -## 4. `contexture apply` +### `emit` semantics -Purpose: allow agents to apply structured ops safely. +`emit` runs the full `runEmitPipeline` and writes through `createFileBackedForward`. There is intentionally no `--out` or per-target subcommand — outputs are determined by `bundlePathsFor(irPath)`. This keeps drift-tracking honest (one manifest, one bundle). -```bash -contexture apply ./domain.contexture.json --op ./op.json -contexture apply ./domain.contexture.json --op-json '{"kind":"add_type",...}' -``` +## Phase 2 additions (shipped in this PR) -Default behavior: +- ✅ `contexture inspect` — one-shot human and `--json` summary of the + schema (types, fields, enums, discriminated unions, raw types, imports). +- ✅ `contexture check-generated` — re-runs the emit pipeline in memory, + compares each output against the file on disk, exits non-zero on + drift with a list of `{ path, reason }` entries. +- ✅ `contexture apply --op-json | --op-file` — generic op entrypoint for + callers that already have a serialized `Op`. Per-op subcommands remain + the primary surface. +- ✅ `HELP` text lists every command. -- load schema -- apply op -- validate result -- save updated `.contexture.json` -- optionally emit artifacts if flags are passed +## Not wired into root CI (by design) -Example: +`check-generated` lives in CI for *downstream apps*, not this monorepo. +This repo has stdlib IRs but no app-level IR + emit bundle, so there's +nothing here to check. The recommended wiring is documented in +`docs/agent-contexture-workflow.md`. -```bash -contexture apply domain.contexture.json \ - --op-json '{"kind":"add_field","typeName":"User","field":{"name":"email","type":{"kind":"string","format":"email"}}}' -``` - -Useful flags: - -```bash ---dry-run ---json ---emit zod ---emit json-schema ---emit convex ---emit-all -``` +## Exit criteria — met -For `--dry-run`, do not write. Print resulting validation state and diff-like summary. +- `inspect`, `check-generated`, and `apply` ship with tests. +- Help text lists every command. +- `bun run ci` passes. -## 5. `contexture check-generated` - -Purpose: prevent generated artifacts from drifting. - -```bash -contexture check-generated ./domain.contexture.json \ - --zod ./packages/domain/schema.ts \ - --json-schema ./packages/domain/schema.json \ - --convex ./apps/web/convex/schema.ts -``` - -Behavior: - -- regenerate in memory -- compare with files on disk -- exit `0` if in sync -- exit `1` if stale or invalid - -Output: - -```txt -Generated files are stale: - apps/web/convex/schema.ts - -Run: - contexture emit convex ./domain.contexture.json --out ./apps/web/convex/schema.ts -``` - -This is important for agent workflows and CI. - -## Minimal argument parsing - -For internal use, avoid overengineering. - -Options: - -- Use a small dependency like `commander`, or -- Implement simple manual parsing. - -Manual parsing is fine for Phase 2. Use `commander` only if nicer help output becomes valuable. - -## Exit criteria - -- CLI can inspect, validate, emit Zod, emit JSON Schema, emit Convex. -- CLI can apply an op and save the IR. -- CLI has JSON output suitable for coding agents. -- CLI has stable non-zero exit codes. -- Desktop and CLI both use `@contexture/core`. -- Basic tests cover commands. -- These pass: - -```bash -bun run format:check -bun run lint -bun run test -bun run typecheck -``` - ---- - -# Phase 3 — Agent workflow docs / skill - -## Objective - -Teach coding agents that Contexture is the source of truth. - -This phase is not about new runtime capability. It is about making the desired workflow legible and repeatable for Claude Code, Cursor, iClord Code, or another agent editing the downstream app. - -## Add reusable agent instructions - -Create something like: - -```txt -packages/cli/templates/CLAUDE.contexture.md -``` - -or: - -```txt -docs/agent-contexture-workflow.md -``` - -Recommended content: - -````md -# Contexture Domain Model Workflow - -This project uses Contexture as the source of truth for domain models. - -## Rules - -- Do not directly edit generated schema files unless explicitly asked. -- Treat `*.contexture.json` as the primary domain model. -- When changing entities, fields, refs, enums, tables, or indexes, update the Contexture model first. -- After changing the model, regenerate generated artifacts with the Contexture CLI. -- Run validation before finishing. - -## Common commands - -Inspect the model: - -```bash -contexture inspect ./domain.contexture.json -``` - -Validate: - -```bash -contexture validate ./domain.contexture.json -``` - -Emit Convex schema: - -```bash -contexture emit convex ./domain.contexture.json --out ./apps/web/convex/schema.ts -``` - -Check generated files: - -```bash -contexture check-generated ./domain.contexture.json \ - --convex ./apps/web/convex/schema.ts -``` - -## Agent workflow - -1. Locate the `.contexture.json` file. -2. Inspect it using `contexture inspect`. -3. Decide what model change is required. -4. Apply the change to the Contexture model. -5. Validate the model. -6. Regenerate generated artifacts. -7. Run project tests/typecheck. -```` - -## Optional skill - -If using skill-based agents, add a skill: - -```txt -.agents/skills/contexture-domain-model/SKILL.md -``` - -Skill description: - -```yaml --- -name: contexture-domain-model -description: Use when making changes to domain entities, fields, Convex schema, Zod schemas, or generated model artifacts in a project that uses Contexture. ---- -``` - -Skill body should say: - -- Find `.contexture.json`. -- Never edit generated files first. -- Use `contexture inspect`. -- Use `contexture apply` or direct IR edit if necessary. -- Run `contexture validate`. -- Run `contexture emit convex`. -- Run `contexture check-generated`. -- Then proceed with app code changes. - -## Add generated-file headers - -Ensure all generated files include clear headers: -```ts -// Generated by Contexture from domain.contexture.json. -// Do not edit directly. Update the Contexture model and regenerate. -``` +# Phase 3 — Agent workflow docs / skill ✅ -For Convex: +## Outcome target -```ts -// Generated by Contexture from domain.contexture.json. -// Do not edit by hand. Run: -// contexture emit convex domain.contexture.json --out apps/web/convex/schema.ts -``` +A coding agent in a downstream app can be pointed at a short doc (or skill) and immediately know: edit the model, not the generated files; use the CLI; run drift check before declaring done. -## Optional downstream project scaffold +## What shipped -Add a command later, or document manually for now: +- ✅ `docs/agent-contexture-workflow.md` — the canonical workflow doc. + Covers the rules, CLI surface, recommended loop, and the + `check-generated` CI wiring pattern for downstream apps. +- ✅ Emitter header audit. Every emitter in `packages/core/src/emit-*.ts` + carries the right marker: + - `@contexture-generated` (regenerated every save): `emit-zod`, + `emit-json-schema`, `emit-schema-index`, `emit-convex`. + - `@contexture-seeded` (written once, owned by user thereafter): + `emit-table-crud`, `emit-claude-md`. +- Optional skill (`.claude/skills/contexture-domain-model/SKILL.md`) and + downstream-scaffold command (`contexture init-agent-docs`) are still + open. Both are tracked in the backlog below. -```bash -contexture init-agent-docs -``` +## Exit criteria — met -This could copy the workflow doc into a downstream app’s `CLAUDE.md`. - -For Phase 3, a static template is enough. - -## Exit criteria - -- There is a clear reusable agent workflow doc. -- Generated files tell agents not to edit them directly. -- Downstream projects can include the workflow doc or skill. -- The recommended loop is documented: - -```txt -inspect -> modify model -> validate -> emit -> check-generated -> test -``` +- `docs/agent-contexture-workflow.md` exists and matches the actual CLI surface. +- Every emitter includes the right marker. +- The recommended loop is documented. --- -# Recommended sequencing - -## PR 1: Core extraction - -- Add `packages/core`. -- Move pure modules. -- Update desktop imports. -- Keep behavior unchanged. - -## PR 2: CLI - -- Add `packages/cli`. -- Implement: - - `inspect` - - `validate` - - `emit zod` - - `emit json-schema` - - `emit convex` - - `apply` - - `check-generated` - -## PR 3: Agent workflow +# Backlog (post-Phase 3) -- Add workflow docs / skill template. -- Strengthen generated headers. -- Add examples to README. +- Drop the desktop renderer shim files + (`apps/desktop/src/renderer/src/model/*.ts`) and update imports to + `@contexture/core/*`. +- Optional `contexture init-agent-docs` command for downstream + scaffolding (copies `docs/agent-contexture-workflow.md` into a + downstream app's `CLAUDE.md`). +- Optional Claude Code skill at + `.claude/skills/contexture-domain-model/SKILL.md` that triggers on + domain / Convex / Zod edits and points to the workflow doc. --- @@ -617,14 +237,14 @@ inspect -> modify model -> validate -> emit -> check-generated -> test A coding agent in a downstream app should be able to do: ```bash -contexture inspect domain.contexture.json --json -contexture apply domain.contexture.json --op-json '{"kind":"add_field",...}' -contexture validate domain.contexture.json -contexture emit convex domain.contexture.json --out apps/web/convex/schema.ts -contexture check-generated domain.contexture.json --convex apps/web/convex/schema.ts +contexture inspect --json +contexture add-field User email '{"kind":"string","format":"email"}' +contexture validate +contexture emit +contexture check-generated bun run typecheck ``` -That gives the desired core outcome: +Outcome: > Domain model first, generated app schemas second, coding agents guided by a stable CLI boundary.