From 10085e3e8cfc5fd052a0e609f69bf5341989d2ea Mon Sep 17 00:00:00 2001 From: Karol Chudzik Date: Thu, 16 Apr 2026 18:32:30 +0200 Subject: [PATCH 1/3] feat(cms-189): add installable MDCMS skill pack + non-interactive `mdcms init` Ships a 9-skill installable pack under `skills//SKILL.md`, distributable via `npx skills add Blazity/mdcms` (skills.sh CLI). A master orchestrator (`mdcms-setup`) detects repo state and routes to focused skills for self-hosting, brownfield/greenfield init, schema refinement, Studio embed, SDK integration, custom MDX components, and day-to-day sync. To make skills drivable without TTY prompts, `mdcms init` gains a non-interactive mode: `-y` / `--yes` / `--non-interactive`, plus `--directory`, `--directories`, `--default-locale`, `--no-import`, `--no-git-cleanup`, `--no-example-post`. Missing required inputs surface as `INIT_MISSING_INPUT` instead of hanging on a prompt. Existing interactive behavior is unchanged. - apps/cli/src/lib/init.ts: flag parser + value resolution order - apps/cli/src/lib/init.test.ts: +11 tests covering parser and flow - docs/specs/SPEC-008: documents new flags and value resolution order - skills/: new top-level distributable pack with README + 9 SKILL.md files --- apps/cli/src/lib/init.test.ts | 367 +++++++++++++++++++- apps/cli/src/lib/init.ts | 315 ++++++++++++++--- docs/specs/SPEC-008-cli-and-sdk.md | 65 +++- skills/README.md | 117 +++++++ skills/mdcms-brownfield-init/SKILL.md | 120 +++++++ skills/mdcms-content-sync-workflow/SKILL.md | 152 ++++++++ skills/mdcms-greenfield-init/SKILL.md | 100 ++++++ skills/mdcms-mdx-components/SKILL.md | 133 +++++++ skills/mdcms-schema-refine/SKILL.md | 169 +++++++++ skills/mdcms-sdk-integration/SKILL.md | 181 ++++++++++ skills/mdcms-self-host-setup/SKILL.md | 104 ++++++ skills/mdcms-setup/SKILL.md | 106 ++++++ skills/mdcms-studio-embed/SKILL.md | 107 ++++++ 13 files changed, 1963 insertions(+), 73 deletions(-) create mode 100644 skills/README.md create mode 100644 skills/mdcms-brownfield-init/SKILL.md create mode 100644 skills/mdcms-content-sync-workflow/SKILL.md create mode 100644 skills/mdcms-greenfield-init/SKILL.md create mode 100644 skills/mdcms-mdx-components/SKILL.md create mode 100644 skills/mdcms-schema-refine/SKILL.md create mode 100644 skills/mdcms-sdk-integration/SKILL.md create mode 100644 skills/mdcms-self-host-setup/SKILL.md create mode 100644 skills/mdcms-setup/SKILL.md create mode 100644 skills/mdcms-studio-embed/SKILL.md diff --git a/apps/cli/src/lib/init.test.ts b/apps/cli/src/lib/init.test.ts index 80725bf..6b08d10 100644 --- a/apps/cli/src/lib/init.test.ts +++ b/apps/cli/src/lib/init.test.ts @@ -4,10 +4,31 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; +import type { CredentialStore } from "./credentials.js"; import { runMdcmsCli } from "./framework.js"; -import { createInitCommand } from "./init.js"; +import { createInitCommand, parseInitOptions } from "./init.js"; import { createMockPrompter } from "./init/prompt.js"; +function createInMemoryCredentialStore(): CredentialStore { + const profiles = new Map(); + const key = (t: { + serverUrl: string; + project: string; + environment: string; + }) => `${t.serverUrl}|${t.project}|${t.environment}`; + return { + async getProfile(tuple) { + return profiles.get(key(tuple)) as never; + }, + async setProfile(tuple, profile) { + profiles.set(key(tuple), profile); + }, + async deleteProfile(tuple) { + return profiles.delete(key(tuple)); + }, + }; +} + async function withTempDir(run: (cwd: string) => Promise): Promise { const cwd = await mkdtemp(join(tmpdir(), "mdcms-cli-init-")); try { @@ -707,3 +728,347 @@ test("init creates new project and generates config", async () => { ); }); }); + +test("parseInitOptions parses all recognized flags", () => { + const opts = parseInitOptions([ + "--non-interactive", + "--directory", + "content/posts", + "--directory", + "content/pages", + "--default-locale", + "en", + "--no-import", + "--no-git-cleanup", + "--no-example-post", + ]); + + assert.equal(opts.nonInteractive, true); + assert.deepEqual(opts.directories, ["content/posts", "content/pages"]); + assert.equal(opts.defaultLocale, "en"); + assert.equal(opts.noImport, true); + assert.equal(opts.noGitCleanup, true); + assert.equal(opts.noExamplePost, true); + assert.equal(opts.help, false); +}); + +test("parseInitOptions treats -y and --yes as --non-interactive", () => { + assert.equal(parseInitOptions(["-y"]).nonInteractive, true); + assert.equal(parseInitOptions(["--yes"]).nonInteractive, true); + assert.equal(parseInitOptions(["--non-interactive"]).nonInteractive, true); +}); + +test("parseInitOptions parses --directories csv and inline =value forms", () => { + const viaCsv = parseInitOptions([ + "--directories", + "content/posts,content/pages", + ]); + assert.deepEqual(viaCsv.directories, ["content/posts", "content/pages"]); + + const viaInline = parseInitOptions([ + "--directories=content/posts, content/pages", + "--default-locale=fr", + ]); + assert.deepEqual(viaInline.directories, ["content/posts", "content/pages"]); + assert.equal(viaInline.defaultLocale, "fr"); +}); + +test("parseInitOptions throws on unknown flag", () => { + assert.throws(() => parseInitOptions(["--not-a-real-flag"]), /Unknown flag/); +}); + +test("parseInitOptions throws when flag value is missing", () => { + assert.throws(() => parseInitOptions(["--directory"]), /requires a value/); +}); + +test("init --non-interactive completes without prompting", async () => { + await withTempDir(async (cwd) => { + await mkdir(join(cwd, "content", "posts"), { recursive: true }); + await writeFile( + join(cwd, "content", "posts", "hello.md"), + "---\ntitle: Hello\nslug: hello\n---\nBody\n", + ); + + const fetcher = createMockFetcher(createDefaultFetchHandlers()); + + // Empty queues — any prompter call throws and fails the test. + const prompter = createMockPrompter({}); + + const command = createInitCommand({ + prompter, + fetcher, + credentialStore: createInMemoryCredentialStore(), + }); + + const exitCode = await runMdcmsCli( + ["init", "--non-interactive", "--directory", "content/posts"], + { + cwd, + commands: [command], + env: { + MDCMS_SERVER_URL: "http://localhost:4000", + MDCMS_PROJECT: "my-project", + MDCMS_ENVIRONMENT: "staging", + MDCMS_API_KEY: "test-api-key", + }, + resolveStoredApiKey: async () => undefined, + stdout: { write: () => undefined }, + stderr: { write: () => undefined }, + fetcher, + }, + ); + + assert.equal(exitCode, 0); + const configPath = join(cwd, "mdcms.config.ts"); + assert.ok(existsSync(configPath), "mdcms.config.ts should exist"); + const manifestPath = join( + cwd, + ".mdcms", + "manifests", + "my-project.staging.json", + ); + assert.ok(existsSync(manifestPath), "manifest should exist"); + }); +}); + +test("init --non-interactive fails loud when project is missing", async () => { + await withTempDir(async (cwd) => { + const fetcher = createMockFetcher(createDefaultFetchHandlers()); + const prompter = createMockPrompter({}); + + const command = createInitCommand({ + prompter, + fetcher, + credentialStore: createInMemoryCredentialStore(), + }); + + let stderr = ""; + const exitCode = await runMdcmsCli(["init", "--non-interactive"], { + cwd, + commands: [command], + env: { + MDCMS_SERVER_URL: "http://localhost:4000", + MDCMS_API_KEY: "test-api-key", + }, + resolveStoredApiKey: async () => undefined, + stdout: { write: () => undefined }, + stderr: { + write: (c) => { + stderr += c; + }, + }, + fetcher, + }); + + assert.equal(exitCode, 1); + assert.match(stderr, /Project name/i); + assert.match(stderr, /--project/); + }); +}); + +test("init --non-interactive fails loud when api-key is missing", async () => { + await withTempDir(async (cwd) => { + await mkdir(join(cwd, "content", "posts"), { recursive: true }); + await writeFile( + join(cwd, "content", "posts", "hello.md"), + "---\ntitle: Hello\n---\nBody\n", + ); + const fetcher = createMockFetcher(createDefaultFetchHandlers()); + const prompter = createMockPrompter({}); + + const command = createInitCommand({ + prompter, + fetcher, + credentialStore: createInMemoryCredentialStore(), + }); + + let stderr = ""; + const exitCode = await runMdcmsCli( + ["init", "--non-interactive", "--directory", "content/posts"], + { + cwd, + commands: [command], + env: { + MDCMS_SERVER_URL: "http://localhost:4000", + MDCMS_PROJECT: "my-project", + MDCMS_ENVIRONMENT: "staging", + }, + resolveStoredApiKey: async () => undefined, + stdout: { write: () => undefined }, + stderr: { + write: (c) => { + stderr += c; + }, + }, + fetcher, + }, + ); + + assert.equal(exitCode, 1); + assert.match(stderr, /API key/i); + assert.match(stderr, /--api-key/); + }); +}); + +test("init --non-interactive --no-import skips content import", async () => { + await withTempDir(async (cwd) => { + await mkdir(join(cwd, "content", "posts"), { recursive: true }); + await writeFile( + join(cwd, "content", "posts", "hello.md"), + "---\ntitle: Hello\n---\nBody\n", + ); + + let contentCallCount = 0; + const fetcher = createMockFetcher({ + ...createDefaultFetchHandlers(), + "/api/v1/content": (_url: string) => { + contentCallCount += 1; + return new Response( + JSON.stringify({ + data: { + documentId: "doc-1", + draftRevision: 1, + publishedVersion: null, + }, + }), + { status: 201, headers: { "content-type": "application/json" } }, + ); + }, + }); + + const prompter = createMockPrompter({}); + const command = createInitCommand({ + prompter, + fetcher, + credentialStore: createInMemoryCredentialStore(), + }); + + const exitCode = await runMdcmsCli( + [ + "init", + "--non-interactive", + "--directory", + "content/posts", + "--no-import", + ], + { + cwd, + commands: [command], + env: { + MDCMS_SERVER_URL: "http://localhost:4000", + MDCMS_PROJECT: "my-project", + MDCMS_ENVIRONMENT: "staging", + MDCMS_API_KEY: "test-api-key", + }, + resolveStoredApiKey: async () => undefined, + stdout: { write: () => undefined }, + stderr: { write: () => undefined }, + fetcher, + }, + ); + + assert.equal(exitCode, 0); + assert.equal(contentCallCount, 0, "no content POSTs should be made"); + const manifestPath = join( + cwd, + ".mdcms", + "manifests", + "my-project.staging.json", + ); + assert.equal( + existsSync(manifestPath), + false, + "manifest should not be written when import is skipped", + ); + }); +}); + +test("init --non-interactive --no-example-post on empty repo skips example.md", async () => { + await withTempDir(async (cwd) => { + const fetcher = createMockFetcher(createDefaultFetchHandlers()); + const prompter = createMockPrompter({}); + const command = createInitCommand({ + prompter, + fetcher, + credentialStore: createInMemoryCredentialStore(), + }); + + const exitCode = await runMdcmsCli( + [ + "init", + "--non-interactive", + "--directory", + "content/posts", + "--no-example-post", + ], + { + cwd, + commands: [command], + env: { + MDCMS_SERVER_URL: "http://localhost:4000", + MDCMS_PROJECT: "my-project", + MDCMS_ENVIRONMENT: "staging", + MDCMS_API_KEY: "test-api-key", + }, + resolveStoredApiKey: async () => undefined, + stdout: { write: () => undefined }, + stderr: { write: () => undefined }, + fetcher, + }, + ); + + assert.equal(exitCode, 0); + assert.equal( + existsSync(join(cwd, "content", "posts", "example.md")), + false, + "example.md should not be scaffolded", + ); + assert.ok( + existsSync(join(cwd, "mdcms.config.ts")), + "config should still be written", + ); + }); +}); + +test("init --non-interactive rejects --directory that doesn't match any found content", async () => { + await withTempDir(async (cwd) => { + await mkdir(join(cwd, "content", "posts"), { recursive: true }); + await writeFile( + join(cwd, "content", "posts", "hello.md"), + "---\ntitle: Hello\n---\nBody\n", + ); + const fetcher = createMockFetcher(createDefaultFetchHandlers()); + const prompter = createMockPrompter({}); + const command = createInitCommand({ + prompter, + fetcher, + credentialStore: createInMemoryCredentialStore(), + }); + + let stderr = ""; + const exitCode = await runMdcmsCli( + ["init", "--non-interactive", "--directory", "content/pages"], + { + cwd, + commands: [command], + env: { + MDCMS_SERVER_URL: "http://localhost:4000", + MDCMS_PROJECT: "my-project", + MDCMS_ENVIRONMENT: "staging", + MDCMS_API_KEY: "test-api-key", + }, + resolveStoredApiKey: async () => undefined, + stdout: { write: () => undefined }, + stderr: { + write: (c) => { + stderr += c; + }, + }, + fetcher, + }, + ); + + assert.equal(exitCode, 1); + assert.match(stderr, /content\/pages/); + }); +}); diff --git a/apps/cli/src/lib/init.ts b/apps/cli/src/lib/init.ts index a1d7d71..ea5dbf6 100644 --- a/apps/cli/src/lib/init.ts +++ b/apps/cli/src/lib/init.ts @@ -7,6 +7,7 @@ import { defineConfig, defineType, parseMdcmsConfig, + RuntimeError, type MdcmsFieldSchema, } from "@mdcms/shared"; import { buildSchemaSyncPayload } from "@mdcms/shared/server"; @@ -47,6 +48,127 @@ export type InitCommandOptions = { credentialStore?: CredentialStore; }; +export type InitFlagOptions = { + help: boolean; + nonInteractive: boolean; + noImport: boolean; + noGitCleanup: boolean; + noExamplePost: boolean; + directories?: string[]; + defaultLocale?: string; +}; + +function readInitFlagValue( + args: string[], + index: number, + flag: string, +): string { + const next = args[index + 1]; + if (!next || next.startsWith("-")) { + throw new RuntimeError({ + code: "INVALID_INPUT", + message: `Flag "${flag}" requires a value.`, + statusCode: 400, + details: { flag }, + }); + } + return next; +} + +function splitCsv(raw: string): string[] { + return raw + .split(",") + .map((part) => part.trim()) + .filter((part) => part.length > 0); +} + +export function parseInitOptions(args: string[]): InitFlagOptions { + const opts: InitFlagOptions = { + help: false, + nonInteractive: false, + noImport: false, + noGitCleanup: false, + noExamplePost: false, + }; + const directories: string[] = []; + + for (let i = 0; i < args.length; i += 1) { + const token = args[i]!; + + if (token === "-h" || token === "--help") { + opts.help = true; + continue; + } + if (token === "-y" || token === "--yes" || token === "--non-interactive") { + opts.nonInteractive = true; + continue; + } + if (token === "--no-import") { + opts.noImport = true; + continue; + } + if (token === "--no-git-cleanup") { + opts.noGitCleanup = true; + continue; + } + if (token === "--no-example-post") { + opts.noExamplePost = true; + continue; + } + if (token === "--directory") { + directories.push(readInitFlagValue(args, i, "--directory")); + i += 1; + continue; + } + if (token.startsWith("--directory=")) { + directories.push(token.slice("--directory=".length)); + continue; + } + if (token === "--directories") { + directories.push( + ...splitCsv(readInitFlagValue(args, i, "--directories")), + ); + i += 1; + continue; + } + if (token.startsWith("--directories=")) { + directories.push(...splitCsv(token.slice("--directories=".length))); + continue; + } + if (token === "--default-locale") { + opts.defaultLocale = readInitFlagValue(args, i, "--default-locale"); + i += 1; + continue; + } + if (token.startsWith("--default-locale=")) { + opts.defaultLocale = token.slice("--default-locale=".length); + continue; + } + + throw new RuntimeError({ + code: "INVALID_INPUT", + message: `Unknown flag "${token}" for \`mdcms init\`.`, + statusCode: 400, + details: { flag: token }, + }); + } + + if (directories.length > 0) { + opts.directories = directories; + } + + return opts; +} + +function missingInitInput(what: string, flag: string): never { + throw new RuntimeError({ + code: "INIT_MISSING_INPUT", + message: `${what} is required in non-interactive mode. Pass ${flag} (or set via env/config).`, + statusCode: 400, + details: { flag }, + }); +} + function groupFilesByDirectory( files: DiscoveredFile[], ): Map { @@ -274,19 +396,34 @@ export function createInitCommand(options?: InitCommandOptions): CliCommand { requiresConfig: false, requiresTarget: false, run: async (context: CliCommandContext): Promise => { - if (context.args.includes("--help") || context.args.includes("-h")) { + const initOpts = parseInitOptions(context.args); + + if (initOpts.help) { context.stdout.write( [ - "Usage: mdcms init", + "Usage: mdcms init [options]", "", - "Interactive wizard to set up MDCMS in an existing project.", + "Interactive wizard (or non-interactive CI flow) to set up MDCMS in a project.", "", "Steps: server URL, login, project creation,", "directory scan, schema inference, config generation,", "schema sync, content import, and git cleanup.", "", - "Options:", - " -h, --help Show this help text", + "Init-specific options:", + " -y, --yes, --non-interactive Run without prompts; fail on missing inputs", + " --directory Managed content directory (repeatable)", + " --directories Managed content directories (comma-separated)", + " --default-locale Preset default locale (skip locale confirm)", + " --no-import Skip initial content import", + " --no-git-cleanup Skip gitignore/untrack step", + " --no-example-post Skip scaffolded example.md for empty content", + " -h, --help Show this help text", + "", + "Value sources (resolved in this order):", + " --server-url / MDCMS_SERVER_URL / mdcms.config.ts serverUrl", + " --project / MDCMS_PROJECT / mdcms.config.ts project", + " --environment / MDCMS_ENVIRONMENT / mdcms.config.ts environment (default: production)", + " --api-key / MDCMS_API_KEY (non-interactive only; interactive mode opens OAuth)", "", ].join("\n"), ); @@ -299,9 +436,11 @@ export function createInitCommand(options?: InitCommandOptions): CliCommand { const existingConfigPath = join(cwd, "mdcms.config.ts"); if (existsSync(existingConfigPath)) { - const overwrite = await prompter.confirm( - "mdcms.config.ts already exists. Re-running init will overwrite it. Continue?", - ); + const overwrite = initOpts.nonInteractive + ? true + : await prompter.confirm( + "mdcms.config.ts already exists. Re-running init will overwrite it. Continue?", + ); if (!overwrite) { stdout.write("Init cancelled. Existing config preserved.\n"); return 0; @@ -311,10 +450,12 @@ export function createInitCommand(options?: InitCommandOptions): CliCommand { prompter.intro("mdcms init"); // ── Step 1: Server URL ────────────────────────────────────────── - const serverUrl = await prompter.text( - "Server URL", - "http://localhost:4000", - ); + const contextServerUrl = context.serverUrl?.trim(); + const serverUrl = contextServerUrl + ? contextServerUrl + : initOpts.nonInteractive + ? missingInitInput("Server URL", "--server-url") + : await prompter.text("Server URL", "http://localhost:4000"); { const s = prompter.spinner(); @@ -333,13 +474,29 @@ export function createInitCommand(options?: InitCommandOptions): CliCommand { } // ── Step 2: Project + Environment Names ────────────────────────── - const projectName = await prompter.text("Project name"); - const envName = await prompter.text("Environment name", "production"); + const contextProject = context.project?.trim(); + const projectName = contextProject + ? contextProject + : initOpts.nonInteractive + ? missingInitInput("Project name", "--project") + : await prompter.text("Project name"); + + const contextEnvironment = context.environment?.trim(); + const envName = contextEnvironment + ? contextEnvironment + : initOpts.nonInteractive + ? "production" + : await prompter.text("Environment name", "production"); // ── Step 3: Login ────────────────────────────────────────────── let apiKey: string; + const contextApiKey = context.apiKey?.trim(); if (options?.skipAuth) { apiKey = "skip-auth-key"; + } else if (contextApiKey) { + apiKey = contextApiKey; + } else if (initOpts.nonInteractive) { + missingInitInput("API key", "--api-key"); } else { const s = prompter.spinner(); s.start("Opening browser for login..."); @@ -460,10 +617,14 @@ export function createInitCommand(options?: InitCommandOptions): CliCommand { if (dirGroups.size === 0) { stdout.write("No content files found.\n"); - const dirName = await prompter.text( - "Content directory (e.g. content/posts)", - "content/posts", - ); + const dirName = initOpts.directories?.[0] + ? initOpts.directories[0] + : initOpts.nonInteractive + ? "content/posts" + : await prompter.text( + "Content directory (e.g. content/posts)", + "content/posts", + ); selectedDirectories = [dirName]; const lastSegment = dirName.split("/").pop() ?? dirName; @@ -486,21 +647,44 @@ export function createInitCommand(options?: InitCommandOptions): CliCommand { `Will create type "${typeName}" for directory "${dirName}" with fields: title, slug\n`, ); - // Create example post - const examplePath = join(cwd, dirName, "example.md"); - await mkdir(join(cwd, dirName), { recursive: true }); - await writeFile( - examplePath, - [ - "---", - "title: Example Post", - "slug: example", - "---", - "", - "This is an example post created by `mdcms init`.", - "", - ].join("\n"), - "utf-8", + if (!initOpts.noExamplePost) { + // Create example post + const examplePath = join(cwd, dirName, "example.md"); + await mkdir(join(cwd, dirName), { recursive: true }); + await writeFile( + examplePath, + [ + "---", + "title: Example Post", + "slug: example", + "---", + "", + "This is an example post created by `mdcms init`.", + "", + ].join("\n"), + "utf-8", + ); + } + } else if (initOpts.directories && initOpts.directories.length > 0) { + const known = new Set(dirGroups.keys()); + const missing = initOpts.directories.filter((dir) => !known.has(dir)); + if (missing.length > 0) { + throw new RuntimeError({ + code: "INIT_INVALID_DIRECTORY", + message: `Requested --directory ${missing + .map((d) => `"${d}"`) + .join(", ")} not found among content directories (${[...known] + .map((d) => `"${d}"`) + .join(", ")}).`, + statusCode: 400, + details: { missing, available: [...known] }, + }); + } + selectedDirectories = [...initOpts.directories]; + } else if (initOpts.nonInteractive) { + missingInitInput( + "Content directory selection", + "--directory (repeatable) or --directories ", ); } else { const choices = [...dirGroups.entries()].map(([dir, files]) => ({ @@ -523,22 +707,37 @@ export function createInitCommand(options?: InitCommandOptions): CliCommand { const localeConfig = await detectLocaleConfig( allFiles, inferredTypes, - prompter, + initOpts.nonInteractive ? undefined : prompter, ); if (localeConfig) { - const confirmDefault = await prompter.confirm( - `Use "${localeConfig.defaultLocale}" as the default locale?`, - ); - if (!confirmDefault) { - const choices = localeConfig.supported.map((l) => ({ - label: l, - value: l, - })); - localeConfig.defaultLocale = await prompter.select( - "Select default locale", - choices, + if (initOpts.defaultLocale) { + if (!localeConfig.supported.includes(initOpts.defaultLocale)) { + throw new RuntimeError({ + code: "INIT_INVALID_DEFAULT_LOCALE", + message: `--default-locale "${initOpts.defaultLocale}" is not among detected locales [${localeConfig.supported.join(", ")}].`, + statusCode: 400, + details: { + defaultLocale: initOpts.defaultLocale, + supported: localeConfig.supported, + }, + }); + } + localeConfig.defaultLocale = initOpts.defaultLocale; + } else if (!initOpts.nonInteractive) { + const confirmDefault = await prompter.confirm( + `Use "${localeConfig.defaultLocale}" as the default locale?`, ); + if (!confirmDefault) { + const choices = localeConfig.supported.map((l) => ({ + label: l, + value: l, + })); + localeConfig.defaultLocale = await prompter.select( + "Select default locale", + choices, + ); + } } } @@ -551,8 +750,10 @@ export function createInitCommand(options?: InitCommandOptions): CliCommand { ); } - const confirmed = await prompter.confirm("Confirm inferred types?"); - if (!confirmed) return 0; + if (!initOpts.nonInteractive) { + const confirmed = await prompter.confirm("Confirm inferred types?"); + if (!confirmed) return 0; + } } // ── Step 7: Generate Config + Sync Schema ─────────────────────── @@ -649,10 +850,12 @@ export function createInitCommand(options?: InitCommandOptions): CliCommand { ), ); - if (filesToImport.length > 0) { - const shouldImport = await prompter.confirm( - `Import ${filesToImport.length} file${filesToImport.length !== 1 ? "s" : ""} to server?`, - ); + if (filesToImport.length > 0 && !initOpts.noImport) { + const shouldImport = initOpts.nonInteractive + ? true + : await prompter.confirm( + `Import ${filesToImport.length} file${filesToImport.length !== 1 ? "s" : ""} to server?`, + ); if (shouldImport) { const manifest: ScopedManifest = {}; @@ -846,15 +1049,17 @@ export function createInitCommand(options?: InitCommandOptions): CliCommand { } // ── Step 9: Git Cleanup ───────────────────────────────────────── - if (selectedDirectories.length > 0) { + if (selectedDirectories.length > 0 && !initOpts.noGitCleanup) { await updateGitignore(cwd, selectedDirectories); const tracked = detectTrackedFiles(cwd, selectedDirectories); if (tracked.length > 0) { - const shouldUntrack = await prompter.confirm( - `Found ${tracked.length} tracked file${tracked.length !== 1 ? "s" : ""} in managed directories. Untrack them?`, - ); + const shouldUntrack = initOpts.nonInteractive + ? true + : await prompter.confirm( + `Found ${tracked.length} tracked file${tracked.length !== 1 ? "s" : ""} in managed directories. Untrack them?`, + ); if (shouldUntrack) { const removed = untrackFiles(cwd, selectedDirectories); diff --git a/docs/specs/SPEC-008-cli-and-sdk.md b/docs/specs/SPEC-008-cli-and-sdk.md index 3d2564c..5c68c79 100644 --- a/docs/specs/SPEC-008-cli-and-sdk.md +++ b/docs/specs/SPEC-008-cli-and-sdk.md @@ -78,21 +78,21 @@ The SDK follows the same reference-resolution contract documented in SPEC-003. R ### Commands -| Command | Description | -| ---------------------------- | --------------------------------------------------------------------------- | -| `cms init` | Interactive wizard to set up MDCMS in an existing project | -| `cms login` | Authenticate via browser-based OAuth/email login | -| `cms logout` | Clear stored credentials | -| `cms pull` | Download all content from CMS to local `.md`/`.mdx` files | -| `cms push` | Upload local `.md`/`.mdx` files to CMS | -| `cms push --validate` | Validate content against schema before pushing | -| `cms push --sync-schema` | Allow push to sync schema in non-interactive mode on drift (ignored in TTY) | -| `cms schema sync` | Sync `mdcms.config.ts` schema to the server registry | -| `cms migrate` | Generate and apply content migrations for schema changes | -| `cms status` | Show content drift and schema drift (local vs server) | -| `cms action list` | List available backend actions from `/actions` (with permissions metadata). | -| `cms action run ` | Execute a command/query action via the generic action runner. | -| `cms ` | Optional local alias mapped to `actionId` by bundled module CLI surface. | +| Command | Description | +| ---------------------------- | ---------------------------------------------------------------------------- | +| `cms init` | Interactive wizard (or non-interactive CI mode) to set up MDCMS in a project | +| `cms login` | Authenticate via browser-based OAuth/email login | +| `cms logout` | Clear stored credentials | +| `cms pull` | Download all content from CMS to local `.md`/`.mdx` files | +| `cms push` | Upload local `.md`/`.mdx` files to CMS | +| `cms push --validate` | Validate content against schema before pushing | +| `cms push --sync-schema` | Allow push to sync schema in non-interactive mode on drift (ignored in TTY) | +| `cms schema sync` | Sync `mdcms.config.ts` schema to the server registry | +| `cms migrate` | Generate and apply content migrations for schema changes | +| `cms status` | Show content drift and schema drift (local vs server) | +| `cms action list` | List available backend actions from `/actions` (with permissions metadata). | +| `cms action run ` | Execute a command/query action via the generic action runner. | +| `cms ` | Optional local alias mapped to `actionId` by bundled module CLI surface. | All commands that interact with server content resolve a target `(project, environment)` from config defaults and allow per-run overrides via `--project` and `--environment`. @@ -112,9 +112,40 @@ All commands that interact with server content resolve a target `(project, envir CLI extensibility in v1 is intentionally action-based: aliases, formatters, and preflight hooks are allowed; arbitrary command-tree injection is out of scope. -### `cms init` — Interactive Wizard +### `cms init` — Interactive Wizard and Non-Interactive Mode -The setup wizard uses `@inquirer/prompts` for the interactive TUI and walks through: +The setup wizard uses `@inquirer/prompts` for the interactive TUI by default. A non-interactive mode is supported for CI and AI-agent driven setup: the same 13 steps run end-to-end, but every prompt is answered from flags, env vars, or config and any missing required input causes a clear `INIT_MISSING_INPUT` error instead of hanging on a prompt. + +#### Init-specific flags + +| Flag | Description | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `-y`, `--yes`, `--non-interactive` | Enable non-interactive mode. Auto-accept every confirm, skip every select/text prompt, fail loud on any missing required value. | +| `--directory ` | Managed content directory. Repeatable. Each value must match an existing directory when content is found, or is used as the scaffold target when no content exists. | +| `--directories ` | Same as repeated `--directory`, comma-separated. | +| `--default-locale ` | Preset default locale. Must be one of the locales detected from content. Skips the confirm/select for default locale. | +| `--no-import` | Skip the initial content import step, even if matching files are found. | +| `--no-git-cleanup` | Skip the `.gitignore` update and the tracked-file untrack step. | +| `--no-example-post` | When the repo has no content files, still generate `mdcms.config.ts` and scaffold the starter type, but do not write the `example.md` file. | +| `-h`, `--help` | Show help. | + +Global flags also contribute to non-interactive resolution (`--server-url`, `--project`, `--environment`, `--api-key`) alongside env vars `MDCMS_SERVER_URL`, `MDCMS_PROJECT`, `MDCMS_ENVIRONMENT`, `MDCMS_API_KEY`. + +#### Value resolution order + +For each required input the wizard resolves (first match wins): + +1. Global flag (e.g. `--server-url`). +2. Env var (e.g. `MDCMS_SERVER_URL`). +3. Existing `mdcms.config.ts` field (if present). +4. Stored credential store entry (API key only). +5. Interactive prompt (skipped in non-interactive mode; missing value raises `INIT_MISSING_INPUT`). + +Environment name falls back to `"production"` in non-interactive mode when not otherwise provided. + +In non-interactive mode, `mdcms.config.ts` that already exists is overwritten implicitly (the mode acts as an auto-yes for all confirms). The intent is that automated setup can be re-run idempotently. + +The setup wizard still walks through: 1. **Server URL** — Prompt for the MDCMS server URL + health check (`GET /healthz`). 2. **Project + environment names** — Prompt for project name and environment name (default: `"production"`). These are collected before authentication so the login challenge can scope the API key to `(project, environment)`. diff --git a/skills/README.md b/skills/README.md new file mode 100644 index 0000000..558d26a --- /dev/null +++ b/skills/README.md @@ -0,0 +1,117 @@ +# MDCMS Skills Pack + +Installable AI-agent skills that walk developers through adding MDCMS to a project — from bringing up a self-hosted backend to day-to-day `pull`/`push` automation. + +The pack is designed to be installed into any agent environment supported by [skills.sh](https://skills.sh): Claude Code, Cursor, Gemini CLI, Codex, Copilot, OpenCode, and 40+ others. + +## Install + +```bash +# Browse available skills +npx skills add Blazity/mdcms --list + +# Install all nine skills into the current project +npx skills add Blazity/mdcms + +# Target a specific agent (default: prompt for agent) +npx skills add Blazity/mdcms -a claude-code + +# Install globally (~/./skills) so every project sees them +npx skills add Blazity/mdcms -g + +# Non-interactive (CI / scripted install) +npx skills add Blazity/mdcms -y +``` + +The pack is installed into whichever agent directories the user selects (`~/.claude/skills/`, `~/.cursor/skills/`, etc.). All nine skills work best together because the master skill delegates to each of the focused skills by slug. Installing the whole pack is recommended. + +See the [skills.sh CLI reference](https://github.com/vercel-labs/skills) for more options (`--copy`, `--skill`, `--agent`, update/remove commands). + +## Skills at a glance + +| Skill | Purpose | +| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| [`mdcms-setup`](./mdcms-setup/SKILL.md) | **Master orchestrator.** Detects repo state and routes to the right focused skill for each phase. Start here. | +| [`mdcms-self-host-setup`](./mdcms-self-host-setup/SKILL.md) | Stand up the MDCMS backend via Docker Compose. Env, first boot, admin bootstrap. | +| [`mdcms-brownfield-init`](./mdcms-brownfield-init/SKILL.md) | Import an existing Markdown/MDX repo into MDCMS via `mdcms init --non-interactive`. | +| [`mdcms-greenfield-init`](./mdcms-greenfield-init/SKILL.md) | Bootstrap MDCMS in an empty repo with a scaffolded starter. | +| [`mdcms-schema-refine`](./mdcms-schema-refine/SKILL.md) | Add/edit content types, fields, references; `mdcms schema sync`. | +| [`mdcms-studio-embed`](./mdcms-studio-embed/SKILL.md) | Mount `` at a catch-all route inside the user's host app. | +| [`mdcms-sdk-integration`](./mdcms-sdk-integration/SKILL.md) | Fetch content in the host app via `@mdcms/sdk`; drafts vs published; SSR. | +| [`mdcms-mdx-components`](./mdcms-mdx-components/SKILL.md) | Register custom MDX components so Studio preview and host SSR match. | +| [`mdcms-content-sync-workflow`](./mdcms-content-sync-workflow/SKILL.md) | Day-to-day `pull`/`push`; key rotation; CI automation for publishing. | + +## How the skills flow + +The master skill (`mdcms-setup`) walks through these phases and delegates at each branch: + +```mermaid +flowchart TD + Start([User: set up MDCMS]) --> Master + Master[mdcms-setup
master — detects state, routes] + + Master --> Q1{Running MDCMS
server reachable?} + Q1 -- "No" --> SelfHost[mdcms-self-host-setup] + Q1 -- "Yes / skip" --> Q2 + SelfHost --> Q2 + + Q2{Existing Markdown/MDX
content in repo?} + Q2 -- "Yes — brownfield" --> Brown[mdcms-brownfield-init] + Q2 -- "No — greenfield" --> Green[mdcms-greenfield-init] + + Brown --> BSchemaQ{Inferred schema
correct?} + BSchemaQ -- "No / extend" --> BSchema[mdcms-schema-refine] + BSchemaQ -- "Yes" --> BSDKQ + BSchema --> BSDKQ + BSDKQ{Existing code fetches
content from files?} + BSDKQ -- "Yes" --> BSDK[mdcms-sdk-integration
replace file fetching] + BSDKQ -- "No" --> Converge + BSDK --> Converge + + Green --> GSchema[mdcms-schema-refine
author first models] + GSchema --> GSDK[mdcms-sdk-integration
write new fetching] + GSDK --> Converge + + Converge[paths converge] + + Converge --> EmbedQ{Want visual editor
in host app?} + EmbedQ -- "Yes" --> Embed[mdcms-studio-embed] + EmbedQ -- "No" --> MDXQ + Embed --> MDXQ + + MDXQ{Custom MDX
components?} + MDXQ -- "Yes" --> MDX[mdcms-mdx-components] + MDXQ -- "No" --> Sync + MDX --> Sync + + Sync[mdcms-content-sync-workflow] + Sync --> Done([MDCMS integrated]) +``` + +## Assumptions and limitations + +- Assumes the current MDCMS CLI contract. The `mdcms init --non-interactive` surface is required and ships with MDCMS `0.1.x+`. +- Skills reference `apps/studio-example` in the MDCMS source repo for copy-from patterns (Studio embed, MDX component registration). When the reference implementation changes, the skills defer to the reference. +- No assumption is made about the user's host framework beyond "React-based". Examples use Next.js App Router because it is the most common target; adapt for Remix, Astro, Vite, etc. +- The pack does not bundle an MCP server, hooks, or subagents. Claude Code users wanting `/slash` command shortcuts or MCP-backed integrations should install the pack alongside any project-specific `.claude/` config. + +## Distribution conventions + +- Each skill is a directory under this pack named `/`, containing a single `SKILL.md` with YAML frontmatter (`name`, `description`) and a markdown body. +- Skill slugs are stable and used for cross-references. Renaming a skill is a breaking change for consumers of the pack. +- New skills should follow the patterns already in this pack: pushy description, prerequisites, numbered steps, verification, gotchas, related skills, and a final assumptions/limitations note. + +## Updating / removing + +```bash +# Update all installed skills to their latest version +npx skills update + +# Remove one +npx skills remove mdcms-setup + +# Remove everything from this pack +npx skills remove --all --skill 'mdcms-*' +``` + +See `npx skills --help` for the full command surface. diff --git a/skills/mdcms-brownfield-init/SKILL.md b/skills/mdcms-brownfield-init/SKILL.md new file mode 100644 index 0000000..d472a11 --- /dev/null +++ b/skills/mdcms-brownfield-init/SKILL.md @@ -0,0 +1,120 @@ +--- +name: mdcms-brownfield-init +description: Use this skill when the user wants to import an existing Markdown or MDX project into MDCMS, says things like "I have a bunch of markdown files and I want MDCMS to manage them", "import my existing blog into MDCMS", "adopt MDCMS for this repo's content", or when the `mdcms-setup` orchestrator has detected the repo has pre-existing `.md`/`.mdx` files. Drives `mdcms init --non-interactive` against the existing content, then verifies the inferred schema. +--- + +# MDCMS Brownfield Init + +Onboard an existing repository whose content already lives as `.md`/`.mdx` files on disk. `mdcms init` is the one-shot command that creates `mdcms.config.ts`, infers a schema from the existing files, syncs that schema to the server, and imports the files as draft documents. + +## When to use this skill + +The user has: + +- an MDCMS backend reachable (if not, run **`mdcms-self-host-setup`** first), and +- a repo with Markdown/MDX content they want MDCMS to manage. + +Do not use this skill for empty repos — use **`mdcms-greenfield-init`** instead. + +## Prerequisites + +- Server URL (e.g. `http://localhost:4000`). +- An MDCMS API key with permission to create projects/environments and sync schema. Create one via Studio → Settings → API Keys, or use the demo key from `docker-compose.dev.yml` for local development. +- A project slug and environment name (defaults to `production` if omitted). +- Node.js / npm — the CLI is `npx mdcms` (or `bun x mdcms`). + +## Steps + +### 1. Discover the content directories + +```bash +find . -type f \( -name '*.md' -o -name '*.mdx' \) \ + -not -path '*/node_modules/*' -not -path '*/.*' \ + | awk -F/ '{print $2"/"$3}' | sort -u +``` + +List the top two directory levels (for example `content/posts`, `docs/blog`). Confirm with the user which of these MDCMS should manage. Those become `--directory` values in step 3. + +### 2. Prepare the non-interactive flags + +Collect these inputs up front. If any are missing and the user wants headless, stop and ask: + +| Flag | Value | +| --------------- | ---------------------------------------------- | +| `--server-url` | MDCMS API base URL | +| `--project` | slug to create on the server (e.g. `my-site`) | +| `--environment` | environment name (default `production`) | +| `--api-key` | token with project create + schema sync scopes | +| `--directory` | one per managed directory, repeatable | + +Alternatively, set the corresponding env vars (`MDCMS_SERVER_URL`, `MDCMS_PROJECT`, `MDCMS_ENVIRONMENT`, `MDCMS_API_KEY`) and omit the flags. + +### 3. Run init + +```bash +npx mdcms init --non-interactive \ + --server-url "$MDCMS_SERVER_URL" \ + --project "$MDCMS_PROJECT" \ + --environment "$MDCMS_ENVIRONMENT" \ + --api-key "$MDCMS_API_KEY" \ + --directory content/posts \ + --directory content/pages +``` + +What this does in one shot: + +1. Pings `/healthz`. +2. Creates (or joins) the project and environment on the server. +3. Stores the API key in the credential store, scoped to `(server, project, environment)`. +4. Scans each `--directory` for `.md`/`.mdx` files, infers types from frontmatter, detects locale patterns in filenames/folders. +5. Writes `mdcms.config.ts` to the repo root. +6. Syncs the inferred schema to the server (`PUT /api/v1/schema`). +7. Imports every discovered file as a draft document. Files already present on the server are updated in place. +8. Adds the managed directories to `.gitignore` and untracks any tracked files in them (so the server becomes the source of truth). + +### 4. Verify the inferred schema + +Open the generated `mdcms.config.ts` and walk it with the user: + +- Each `defineType` entry names a type (e.g. `post`, `page`) and lists inferred fields as Zod validators. +- Fields inferred as `z.string()` from frontmatter values are the common case. Check that required vs optional is right and that arrays are typed as `z.array(z.string())` (or whatever element type actually appeared). +- `localized: true` appears only when two or more locales were detected. Confirm the inferred default locale is right. + +If the inferred schema is wrong or incomplete (a field got typed as `z.unknown()`, a type is missing, two types should cross-reference), delegate to **`mdcms-schema-refine`**. + +### 5. Verify the server state + +```bash +npx mdcms status +``` + +You should see: + +- `schema: in sync` +- content documents equal to the import count reported by `init` + +Open Studio (`/admin/studio`) and navigate to the imported type — the content should appear there. + +### 6. Commit the config + +Commit `mdcms.config.ts` and the updates to `.gitignore`. The files that got untracked stay on disk for anyone still doing local edits but the server is now authoritative. + +## Common gotchas + +- **Files with no frontmatter**: those get imported but with an empty schema-driven frontmatter. Add fields later via `mdcms-schema-refine` if that matters. +- **Non-BCP47 locale tags in filenames** (e.g. `en_us`): init normalizes them and records the mapping under `locales.aliases`. Confirm the mapping is what the user wants. +- **Conflicting path + locale combos**: init falls back to an update if a document already exists with the same `(type, path, locale)` identity. Review the import log for any warnings. +- **Pre-existing `mdcms.config.ts`**: the non-interactive flag implies yes-to-overwrite. If the user does not want to lose the current config, back it up first. + +## Related skills + +- Needs a server from **`mdcms-self-host-setup`** if the user is self-hosting. +- Delegate to **`mdcms-schema-refine`** when inferred types are wrong. +- Continue with **`mdcms-studio-embed`**, **`mdcms-sdk-integration`**, or **`mdcms-content-sync-workflow`** depending on what the user wants next — **`mdcms-setup`** orchestrates that decision. + +## Assumptions and limitations + +- Requires the non-interactive flag surface on `mdcms init` (shipped with CMS-189). +- Inferred schema is a starting point, not a final answer. Plan on at least one `schema-refine` pass for production use. +- `init` makes `.gitignore` changes; review the diff before committing so any existing ignore lines are preserved. +- Does not cover manual schema authoring from scratch — that path is greenfield + `mdcms-schema-refine`. diff --git a/skills/mdcms-content-sync-workflow/SKILL.md b/skills/mdcms-content-sync-workflow/SKILL.md new file mode 100644 index 0000000..4f148e0 --- /dev/null +++ b/skills/mdcms-content-sync-workflow/SKILL.md @@ -0,0 +1,152 @@ +--- +name: mdcms-content-sync-workflow +description: Use this skill for day-to-day MDCMS CLI usage — `mdcms pull`, `mdcms push`, `mdcms login`, API key rotation, CI automation for publishing, or when the user asks things like "how do I keep my local markdown in sync with MDCMS", "add MDCMS to CI", "rotate the API key", "what's the draft vs publish flow", or "how do I push changes from a GitHub Action". Not about initial setup — this covers operational usage after init. +--- + +# MDCMS Content Sync Workflow + +Operate the local↔server content loop day-to-day: pulling fresh content, pushing edits, logging in/rotating keys, and automating publishes in CI. Assumes **`mdcms-brownfield-init`** or **`mdcms-greenfield-init`** already ran. + +## When to use this skill + +Anything operational: syncing content, managing credentials, automating publishing. Not for first-time setup (that's brownfield/greenfield init), not for schema changes (that's `mdcms-schema-refine`). + +## Core mental model + +- **Drafts** live on the server and in local working copies. Editing locally + `mdcms push` updates the draft on the server. Editing in Studio writes directly to the server draft. Drafts are visible only to authenticated consumers (Studio, preview renders, SDK with `draft: true`). +- **Publishing** is a separate explicit action (via Studio or the CLI's publish surface when applicable). Published documents are what unauthenticated readers of the host app see. +- **Manifest** — MDCMS tracks per-`(project, environment)` document state in `.mdcms/manifests/..json`. The CLI uses it for hash-based change detection. It's not committed; each developer has their own. + +## Daily loop + +### Pull the latest drafts + +```bash +npx mdcms pull +``` + +Compares local files against the server and applies the plan. Destructive cases (both local and server changed, deletions, renames) prompt for confirmation. Non-destructive cases apply automatically. For headless runs: `mdcms pull --force` skips the confirmation. + +Scope: pull always fetches every document the user's credentials can see. There is no path-based filter. + +### Push local edits + +```bash +npx mdcms push +``` + +Uploads changed, new, and deleted local `.md`/`.mdx` files to the server as draft updates. Untracked files (not yet in the manifest) are presented interactively as new content candidates. Headless: pass all answers via flags (see the CLI's `push --help` for the current surface). + +Useful add-ons: + +- `mdcms push --validate` — validate against the synced schema before pushing. +- `mdcms push --sync-schema` — in non-interactive mode, proactively sync schema drift instead of failing. + +### Status check + +```bash +npx mdcms status +``` + +Shows content drift and schema drift for the current `(project, environment)`. Use this before bigger workflows (before starting editing, before CI cut) to confirm the baseline. + +## Credentials + +### Initial login (interactive) + +```bash +npx mdcms login +``` + +Opens a browser for OAuth-style login against the MDCMS server. On success, stores an API key in the OS credential store (keychain on macOS, libsecret on Linux, credential manager on Windows), keyed by `(serverUrl, project, environment)`. + +The CLI resolves keys in this order for every authenticated command: `--api-key` flag → `MDCMS_API_KEY` env var → stored credential. The first non-empty wins. + +### Logout + +```bash +npx mdcms logout +``` + +Clears the stored credential for the current or specified tuple. + +### Rotating an API key + +1. In Studio → Settings → API Keys, create a new key with the same scopes. +2. Re-login with it: + + ```bash + MDCMS_API_KEY="" npx mdcms status # quick sanity check + ``` + + or run `mdcms login` again to rebind. + +3. Revoke the old key in Studio once all consumers have switched. + +Treat keys as secrets. Do not commit them. Environment-scoped keys (created per `(project, environment)`) make blast radius small on rotation. + +## CI automation + +### Pushing on merge (GitHub Actions) + +```yaml +# .github/workflows/mdcms-push.yml +name: MDCMS push +on: + push: + branches: [main] + paths: + - "content/**" + - "mdcms.config.ts" + +jobs: + push: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - run: npm ci + + - name: MDCMS push + env: + MDCMS_SERVER_URL: ${{ vars.MDCMS_SERVER_URL }} + MDCMS_PROJECT: ${{ vars.MDCMS_PROJECT }} + MDCMS_ENVIRONMENT: ${{ vars.MDCMS_ENVIRONMENT }} + MDCMS_API_KEY: ${{ secrets.MDCMS_API_KEY }} + run: npx mdcms push --validate +``` + +Notes: + +- Scope the MDCMS API key to the single environment this workflow touches (e.g., `staging`). A separate key pushes to `production` via a manual workflow or a protected environment. +- `paths` filter avoids noisy runs when only app code changes. +- Never echo the API key in logs. GitHub masks secrets but logging them explicitly can defeat that. + +### Scheduled pull (optional) + +Only needed if the repo treats the filesystem as a mirror (e.g., for static-site build caching). Most consumers fetch via SDK at build/request time and do not need a scheduled `pull`. + +## Gotchas + +- **Manifest is per-machine** — don't commit `.mdcms/manifests/`. It contains server state that's meaningless to other developers. +- **Hash drift** — if a local file was edited without a `mdcms push`, then someone edited the same document in Studio, both sides diverge. `mdcms pull` classifies this as "both modified" and requires confirmation to overwrite. +- **Deleting locally doesn't delete on the server** unless the push flow is explicit about deletions — check `mdcms push --help` for the current deletion flag. Double-check before assuming a local `rm` cascaded. +- **Schema drift blocks push** — if `mdcms.config.ts` changes locally but schema sync didn't run, push fails. Either run `mdcms schema sync` first, or pass `--sync-schema` in the push call. +- **Multi-environment confusion** — the credential store keys by `(server, project, environment)`. Switching between `staging` and `production` with different keys is fine; forgetting which one is active is where accidents happen. Use `--environment` explicitly in CI. + +## Related skills + +- **`mdcms-brownfield-init`** / **`mdcms-greenfield-init`** — produce the project state this skill operates on. +- **`mdcms-schema-refine`** — when schema drift blocks a push, that's the owner. +- **`mdcms-setup`** — this skill is Phase 7 of the master orchestrator. + +## Assumptions and limitations + +- Flag surfaces match the current CLI contract. When in doubt, run `mdcms --help` and trust it over this skill. +- CI example targets GitHub Actions; other CIs follow the same pattern — checkout, install, set env, run `mdcms push`. +- Publish automation (from draft to published) depends on whether the repo wants publishing in CI or exclusively via Studio. This skill does not pick that for the user. +- Does not cover webhooks-driven revalidation of the host app — that is an MDCMS Post-MVP feature. diff --git a/skills/mdcms-greenfield-init/SKILL.md b/skills/mdcms-greenfield-init/SKILL.md new file mode 100644 index 0000000..8e1b001 --- /dev/null +++ b/skills/mdcms-greenfield-init/SKILL.md @@ -0,0 +1,100 @@ +--- +name: mdcms-greenfield-init +description: Use this skill when the user wants to start a new MDCMS project from scratch with no existing Markdown content, says things like "I'm starting a new site and want to use MDCMS", "set up MDCMS in this empty repo", "I want to try MDCMS with fresh content", or when the `mdcms-setup` orchestrator detects no `.md`/`.mdx` files in the repo. Drives `mdcms init --non-interactive` with the scaffolded starter, pushes the example, and optionally proposes first real content types via `mdcms-schema-refine`. +--- + +# MDCMS Greenfield Init + +Bootstrap MDCMS in a repo that has no content yet. `mdcms init` scaffolds a minimal starter (one content type, one example post, a valid `mdcms.config.ts`) and syncs the schema. A follow-up `mdcms push` uploads the example. + +## When to use this skill + +The user wants MDCMS for a new project and has no existing Markdown/MDX files to import. If the repo already has content, use **`mdcms-brownfield-init`** instead. + +## Prerequisites + +- Server URL (self-host with **`mdcms-self-host-setup`** if needed). +- MDCMS API key with project + schema permissions. +- Project slug and environment name. +- Node.js / npm for `npx mdcms`. + +## Steps + +### 1. Run init + +```bash +npx mdcms init --non-interactive \ + --server-url "$MDCMS_SERVER_URL" \ + --project "$MDCMS_PROJECT" \ + --environment "$MDCMS_ENVIRONMENT" \ + --api-key "$MDCMS_API_KEY" +``` + +With no content on disk, init: + +1. Scaffolds `content/posts/` as the managed directory. +2. Generates a `post` type with `title: z.string()` and `slug: z.string().optional()`. +3. Writes `mdcms.config.ts`. +4. Syncs the schema to the server. +5. Creates `content/posts/example.md` (a placeholder with frontmatter and a short body). Skip this last step with `--no-example-post` if the user does not want it. + +If the user prefers a different starter directory, pass `--directory ` — the type is named after the last path segment. + +### 2. Push the example to the server + +```bash +npx mdcms push +``` + +`init` writes the file locally but does not import it. Running `push` uploads it so the user can immediately open Studio and see a real draft document. + +### 3. Decide on the content model + +At this point the repo has a minimal `post` type with two fields. Ask the user what their real content model looks like (blog posts with authors and tags? marketing pages? campaigns with localized variants?) and either: + +- Start drafting directly in the scaffolded directory if the starter shape fits, or +- Delegate to **`mdcms-schema-refine`** to add the real types, fields, and references. + +Nudge the user toward `schema-refine` if they describe more than one content kind — the scaffold is intentionally minimal, not a template. + +### 4. Verify + +```bash +npx mdcms status +``` + +Expected: + +- `schema: in sync` +- at least one document (the example post) if `push` ran + +Open Studio at `/admin/studio` and confirm the example post appears under the `Post` type. + +### 5. Commit the starter + +Commit `mdcms.config.ts` and the `content/posts/example.md` scaffold if the user wants it as a starting point. If the user intends to delete the example and author real content immediately, the file can be dropped — but push again after removal so the server reflects it. + +## Common follow-ups + +- **More content types** → **`mdcms-schema-refine`**. +- **Render the content in the host app** → **`mdcms-sdk-integration`**. +- **Add the Studio UI to the host app** → **`mdcms-studio-embed`**. +- **Teach the user `pull`/`push` flow** → **`mdcms-content-sync-workflow`**. + +## Gotchas + +- The scaffolded `post` type is a placeholder. Do not encourage the user to ship real content against it without a schema review. +- `--no-example-post` still writes the config and syncs the schema, but leaves the directory empty. Only use this when the user will write their own first document immediately. +- If an `mdcms.config.ts` already exists in the repo, non-interactive mode overwrites it. If the user has a hand-written config they want to keep, back it up first. + +## Related skills + +- **`mdcms-self-host-setup`** — needed if the user does not yet have a backend. +- **`mdcms-schema-refine`** — the natural next step to author real content types. +- **`mdcms-setup`** — master orchestrator; this skill is phase 2 of that flow in the greenfield branch. + +## Assumptions and limitations + +- Requires the non-interactive flag surface on `mdcms init` (shipped with CMS-189). +- The starter schema is deliberately simple — one type, two fields. Production projects should refine it. +- Does not set up Studio embed, SDK fetching, or MDX components — those are separate skills. diff --git a/skills/mdcms-mdx-components/SKILL.md b/skills/mdcms-mdx-components/SKILL.md new file mode 100644 index 0000000..fc9cd6b --- /dev/null +++ b/skills/mdcms-mdx-components/SKILL.md @@ -0,0 +1,133 @@ +--- +name: mdcms-mdx-components +description: Use this skill when the user wants to register or author custom MDX components for MDCMS — phrases like "add a Callout component", "register custom MDX components", "my content uses custom React components", "make components available in Studio", or "my MDX renders plain text for ". Covers the `components` entry in `mdcms.config.ts`, the Studio-side registry, and the host-app MDX runtime that has to render them consistently. +--- + +# MDCMS MDX Components + +Register custom React components so they render consistently inside: + +1. The author's MDX source files, +2. The Studio preview (editors see them in the visual editor), and +3. The host app's SSR / CSR output (production renders them too). + +## When to use this skill + +The user has MDX content that uses custom components (``, ``, ``, `