Sign In

@hrtips/cvx

Package Overview
Dependencies
Maintainers
1
Versions
11
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@hrtips/cvx - npm Package Compare versions

Comparing version
1.2.1
to
1.3.0-next.3d7f6c4
+45
lib/mcp/server.js
import { readFileSync } from "fs";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import { TOOLS } from "./tools.js";
const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
async function runMcpServer() {
const { version } = JSON.parse(readFileSync(join(pkgRoot, "package.json"), "utf8"));
const server = new Server(
{ name: "cvx", version },
{
capabilities: { tools: {} },
instructions: "CVX renders CVs from plain YAML (cv-content/) to pixel-perfect PDFs, fully locally. Loop: get_schema \u2192 init_cv (if no cv-content/ yet) \u2192 edit the YAML files with the user's real details \u2192 validate_cv after every edit \u2192 build_pdf. Never invent facts: every entry must be truthful to the user's real history, especially keywords.yaml (ATS parsers cross-check keywords against the CV body). Pass the workspace folder as `dir` (absolute path) on every call."
}
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: TOOLS.map(({ name, title, description, inputSchema }) => ({ name, title, description, inputSchema }))
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const tool = TOOLS.find((t) => t.name === request.params.name);
if (!tool) {
return { content: [{ type: "text", text: JSON.stringify({ ok: false, error: { code: "unknown-tool", message: `unknown tool: ${request.params.name}` } }) }], isError: true };
}
try {
const result = await tool.handler(request.params.arguments ?? {});
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
isError: result?.ok === false
};
} catch (err) {
return {
content: [{ type: "text", text: JSON.stringify({ ok: false, error: { code: "tool-failed", message: err.message } }) }],
isError: true
};
}
});
const transport = new StdioServerTransport();
await server.connect(transport);
console.error(`cvx mcp v${version} \u2014 stdio server ready (tools: ${TOOLS.map((t) => t.name).join(", ")})`);
}
export {
runMcpServer
};
import { existsSync, cpSync, writeFileSync, readFileSync, readdirSync } from "fs";
import { fileURLToPath } from "url";
import { dirname, join, resolve, basename } from "path";
import { validateContent } from "../pdf/validateContent.js";
import { renderCV } from "../pdf/render.js";
import { discoverThemes } from "../pdf/themes/index.js";
const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const workspace = (dir) => resolve(dir ?? process.cwd());
const contentDirOf = (dir) => join(workspace(dir), "cv-content");
async function getSchema({ dir } = {}) {
const schema = JSON.parse(readFileSync(join(pkgRoot, "schema", "v1", "cvx.schema.json"), "utf8"));
const themes = Object.keys(await discoverThemes()).map((name) => ({ name, default: name === "teal" }));
const layoutsDir = join(contentDirOf(dir), "layouts");
const builtIn = ["two-column", "single-column"];
const names = new Set(builtIn);
const layouts = builtIn.map((name) => ({ name, default: name === "two-column", source: "built-in" }));
if (existsSync(layoutsDir)) {
for (const f of readdirSync(layoutsDir).filter((f2) => f2.endsWith(".yaml"))) {
const name = basename(f, ".yaml");
if (!names.has(name)) layouts.push({ name, default: false, source: "cv-content/layouts" });
names.add(name);
}
}
return { schemaVersion: 1, schema, themes, layouts };
}
async function initCv({ dir } = {}) {
const dest = contentDirOf(dir);
if (existsSync(dest)) {
return { ok: false, error: { code: "already-exists", message: `${dest} already exists \u2014 refusing to overwrite` } };
}
cpSync(join(pkgRoot, "template", "cv-content"), dest, { recursive: true });
return {
ok: true,
dest,
nextSteps: [
"Edit the YAML files in cv-content/ with real, truthful details (see AGENTS.md there)",
"Ask the user for a photo at cv-content/images/profile.jpg \u2014 it cannot be generated",
"Run validate_cv after every edit, then build_pdf"
]
};
}
async function validateCv({ dir, strict = true } = {}) {
const result = validateContent({ contentDir: contentDirOf(dir), strict });
return { ok: result.ok, schemaVersion: 1, strict, errors: result.errors, warnings: result.warnings, checked: result.checked };
}
async function buildPdf({ dir, ats = false } = {}) {
const libFonts = join(pkgRoot, "lib", "fonts");
const warnings = [];
const { buffer, filename, themeName, layoutName } = await renderCV({
contentDir: contentDirOf(dir),
fontsDir: existsSync(libFonts) ? libFonts : join(pkgRoot, "src", "fonts"),
ats,
warn: (msg) => warnings.push(msg)
});
const path = join(workspace(dir), filename);
writeFileSync(path, buffer);
return { ok: true, filename, path, bytes: buffer.byteLength, ats, theme: ats ? null : themeName, layout: ats ? null : layoutName, warnings };
}
const TOOLS = [
{
name: "get_schema",
title: "Get the CVX content schema and inventory",
description: "Call this FIRST, before writing or editing any cv-content YAML. Returns the canonical JSON Schema for every content file (personal, summary, experience, education, competencies, achievements, referees, keywords, config, layouts) plus the available themes and layouts. The schema is the authoritative contract for keys and shapes.",
inputSchema: {
type: "object",
properties: {
dir: { type: "string", description: "Absolute path of the workspace folder containing cv-content/. Defaults to the server working directory." }
},
additionalProperties: false
},
handler: getSchema
},
{
name: "init_cv",
title: "Scaffold a starter cv-content/ folder",
description: "Creates cv-content/ with a complete example CV (Bruce Wayne) in the given workspace folder. Call when the user wants to start a CV and no cv-content/ exists. Refuses to overwrite an existing folder. After init, replace the example content with the user's real, truthful details \u2014 never invent facts.",
inputSchema: {
type: "object",
properties: {
dir: { type: "string", description: "Absolute path of the workspace folder to scaffold into. Defaults to the server working directory." }
},
additionalProperties: false
},
handler: initCv
},
{
name: "validate_cv",
title: "Validate cv-content/ and get every problem at once",
description: "Checks every YAML file in cv-content/ against the canonical schema plus practical checks (missing required files, unknown theme/layout, photo problems, stray files). Returns all errors and warnings with file + field paths and suggested fixes. Call after every edit and always before build_pdf. Fix errors, re-validate, then build.",
inputSchema: {
type: "object",
properties: {
dir: { type: "string", description: "Absolute path of the workspace folder containing cv-content/. Defaults to the server working directory." },
strict: { type: "boolean", description: "Treat warnings (e.g. unknown keys) as errors. Default true \u2014 recommended for agents.", default: true }
},
additionalProperties: false
},
handler: validateCv
},
{
name: "build_pdf",
title: "Render cv-content/ to a PDF",
description: "Renders cv-content/ to a pixel-perfect CV PDF in the workspace folder, named after the person (e.g. jane-doe.pdf). Set ats: true for the ATS-safe single-column variant (machine-friendly, no colours; produces <name>-ats.pdf). Run validate_cv first \u2014 a build with invalid content can fail or render wrong.",
inputSchema: {
type: "object",
properties: {
dir: { type: "string", description: "Absolute path of the workspace folder containing cv-content/. The PDF is written here. Defaults to the server working directory." },
ats: { type: "boolean", description: "Build the ATS-safe single-column variant instead of the designed two-column CV.", default: false }
},
additionalProperties: false
},
handler: buildPdf
}
];
export {
TOOLS,
buildPdf,
getSchema,
initCv,
validateCv
};
import { existsSync, readdirSync, readFileSync } from "fs";
import { fileURLToPath } from "url";
import { dirname, join, basename } from "path";
import yaml from "js-yaml";
import Ajv2020Module from "ajv/dist/2020.js";
import { PHOTO_EXTENSIONS } from "./profilePhoto.js";
import { estimatePage1Overflow, PAGE1_OVERFLOW_WARN_THRESHOLD } from "./layout.js";
import { THEMES } from "./themes/index.js";
const Ajv2020 = Ajv2020Module.default ?? Ajv2020Module;
const SCHEMA_PATH = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "schema", "v1", "cvx.schema.json");
const BUILT_IN_THEMES = ["teal", "coral", "mono"];
const BUILT_IN_LAYOUTS = ["two-column", "single-column"];
const REQUIRED_FILES = ["personal", "summary", "experience"];
let ajv, canonicalSchema;
function getValidator(def) {
if (!ajv) {
canonicalSchema = JSON.parse(readFileSync(SCHEMA_PATH, "utf8"));
ajv = new Ajv2020({ allErrors: true, verbose: true });
ajv.addSchema(canonicalSchema);
}
if (!canonicalSchema.$defs[def]) return null;
return ajv.getSchema(`${canonicalSchema.$id}#/$defs/${def}`) ?? ajv.compile({ $ref: `${canonicalSchema.$id}#/$defs/${def}` });
}
function levenshtein(a, b) {
const m = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
for (let j = 1; j <= b.length; j++) m[0][j] = j;
for (let i = 1; i <= a.length; i++)
for (let j = 1; j <= b.length; j++)
m[i][j] = Math.min(m[i - 1][j] + 1, m[i][j - 1] + 1, m[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
return m[a.length][b.length];
}
function didYouMean(word, candidates) {
let best = null, bestDist = Infinity;
for (const c of candidates) {
const d = levenshtein(word.toLowerCase(), c.toLowerCase());
if (d < bestDist) {
best = c;
bestDist = d;
}
}
return bestDist <= Math.max(2, Math.floor(word.length / 3)) ? best : null;
}
const jsonType = (v) => v === null ? "null" : Array.isArray(v) ? "array" : typeof v;
function mapAjvErrors(errors, doc) {
const oneOfPaths = new Set(errors.filter((e) => e.keyword === "oneOf").map((e) => e.instancePath));
const findings = [];
const seen = /* @__PURE__ */ new Set();
for (const err of errors) {
const container = [...oneOfPaths].find((p) => err.instancePath.startsWith(p));
if (container !== void 0 && err.keyword !== "oneOf") {
const instance = container.split("/").slice(1).reduce((v, k) => v?.[k === "" ? void 0 : k], doc);
const branchType = err.parentSchema?.type ?? err.schema?.type;
if (err.instancePath === container && err.keyword === "type" && err.params.type !== jsonType(instance)) continue;
}
let finding;
switch (err.keyword) {
case "required":
finding = { path: err.instancePath || "(root)", message: `missing required key "${err.params.missingProperty}"` };
break;
case "additionalProperties": {
const key2 = err.params.additionalProperty;
const guess = didYouMean(key2, Object.keys(err.parentSchema?.properties ?? {}));
finding = {
path: err.instancePath || "(root)",
message: `unknown key "${key2}"`,
suggestion: guess ? `did you mean "${guess}"?` : void 0,
unknownKey: true
};
break;
}
case "enum": {
const allowed = err.params.allowedValues;
const value = err.instancePath.split("/").slice(1).reduce((v, k) => v?.[k], doc);
const guess = typeof value === "string" ? didYouMean(value, allowed) : null;
finding = {
path: err.instancePath || "(root)",
message: `"${value}" is not one of: ${allowed.join(", ")}`,
suggestion: guess ? `did you mean "${guess}"?` : void 0
};
break;
}
case "const":
finding = { path: err.instancePath || "(root)", message: `must be ${JSON.stringify(err.params.allowedValue)}` };
break;
case "oneOf": {
const desc = (err.parentSchema?.description ?? "").split(".")[0];
finding = { path: err.instancePath || "(root)", message: desc ? `invalid shape \u2014 ${desc.toLowerCase()}` : "invalid shape" };
break;
}
case "type":
finding = { path: err.instancePath || "(root)", message: `must be ${err.params.type.replace(",", " or ")}` };
break;
case "minimum":
finding = { path: err.instancePath || "(root)", message: `must be >= ${err.params.limit}` };
break;
case "minLength":
finding = { path: err.instancePath || "(root)", message: "must not be empty" };
break;
default:
finding = { path: err.instancePath || "(root)", message: err.message };
}
finding.keyword = err.keyword;
const key = `${finding.path}|${finding.message}`;
if (!seen.has(key)) {
seen.add(key);
findings.push(finding);
}
}
return findings.filter(
(f, _, all) => f.keyword !== "oneOf" || !all.some((o) => o !== f && (o.path === f.path || o.path.startsWith(`${f.path}/`)))
).map(({ keyword, ...f }) => f);
}
function validateContent({ contentDir, strict = false } = {}) {
const errors = [];
const warnings = [];
const checked = [];
const add = (severity, file, code, f) => (severity === "error" ? errors : warnings).push({ file, code, path: f.path ?? "(root)", message: f.message, ...f.suggestion ? { suggestion: f.suggestion } : {} });
if (!existsSync(contentDir)) {
add("error", "", "missing-content-dir", {
message: "content directory not found",
suggestion: 'run "cvx init" to scaffold one'
});
return { ok: false, errors, warnings, checked };
}
getValidator("personal");
const knownDefs = Object.keys(canonicalSchema.$defs);
const docs = {};
const files = readdirSync(contentDir).filter((f) => f.endsWith(".yaml") || f.endsWith(".yml"));
for (const file of files) {
const def = basename(file, file.endsWith(".yml") ? ".yml" : ".yaml");
checked.push(file);
if (file.endsWith(".yml")) {
add("warning", file, "wrong-extension", {
message: "file uses .yml \u2014 cvx only reads .yaml files, this file is ignored",
suggestion: `rename to ${def}.yaml`
});
continue;
}
let doc;
try {
doc = yaml.load(readFileSync(join(contentDir, file), "utf8"));
} catch (e) {
add("error", file, "yaml-parse", {
path: e.mark ? `line ${e.mark.line + 1}` : "(root)",
message: `YAML parse error: ${e.reason ?? e.message}`
});
continue;
}
docs[def] = doc;
if (doc == null) continue;
const validate = getValidator(def);
if (!validate) {
const guess = didYouMean(def, knownDefs.filter((d) => !d.endsWith("Entry") && !["bulletItem", "progressionStep", "keywordGroup", "layoutSlot", "layoutPage", "layout", "nonEmptyString"].includes(d)));
add("warning", file, "unknown-file", {
message: "not a file cvx reads \u2014 it will be ignored",
suggestion: guess ? `did you mean "${guess}.yaml"?` : void 0
});
continue;
}
if (!validate(doc)) {
for (const f of mapAjvErrors(validate.errors, doc)) {
const severity = f.unknownKey && !strict ? "warning" : "error";
add(severity, file, f.unknownKey ? "unknown-key" : "schema", f);
}
}
}
for (const req of REQUIRED_FILES) {
if (docs[req] == null) {
add("error", `${req}.yaml`, "missing-file", {
message: docs[req] === null ? "file is empty but required" : "file is missing but required",
suggestion: req === "personal" ? 'at minimum provide "name"' : "the default layouts cannot render without it"
});
}
}
const config = docs.config ?? {};
if (config.page1ExperienceCount != null && Array.isArray(docs.experience)) {
const theme = THEMES[config.theme] ?? THEMES.teal;
const overflow = estimatePage1Overflow(docs.experience, Array.isArray(docs.summary) ? docs.summary : [], config, theme);
if (overflow > PAGE1_OVERFLOW_WARN_THRESHOLD) {
add("warning", "config.yaml", "page1-overflow", {
path: "/page1ExperienceCount",
message: `${config.page1ExperienceCount} experience entries likely do not fit on page 1 (estimate \u2248${overflow - PAGE1_OVERFLOW_WARN_THRESHOLD}pt past the tuned margin) \u2014 overflow is clipped at the page edge in the designed layout`,
suggestion: "check the rendered page 1; reduce page1ExperienceCount, set page1SplitBullets, or remove both for automatic pagination"
});
}
}
const layoutsDir = join(contentDir, "layouts");
const userLayouts = existsSync(layoutsDir) ? readdirSync(layoutsDir).filter((f) => f.endsWith(".yaml")).map((f) => basename(f, ".yaml")) : [];
if (typeof config.layout === "string" && ![...BUILT_IN_LAYOUTS, ...userLayouts].includes(config.layout)) {
add("warning", "config.yaml", "unknown-layout", {
path: "/layout",
message: `layout "${config.layout}" not found \u2014 the build will fall back to the built-in default`,
suggestion: didYouMean(config.layout, [...BUILT_IN_LAYOUTS, ...userLayouts]) ? `did you mean "${didYouMean(config.layout, [...BUILT_IN_LAYOUTS, ...userLayouts])}"?` : `available: ${[...BUILT_IN_LAYOUTS, ...userLayouts].join(", ")}`
});
}
for (const name of userLayouts) {
const file = `layouts/${name}.yaml`;
checked.push(file);
let doc;
try {
doc = yaml.load(readFileSync(join(layoutsDir, `${name}.yaml`), "utf8"));
} catch (e) {
add("error", file, "yaml-parse", {
path: e.mark ? `line ${e.mark.line + 1}` : "(root)",
message: `YAML parse error: ${e.reason ?? e.message}`
});
continue;
}
if (doc == null) continue;
const validate = getValidator("layout");
if (!validate(doc)) {
for (const f of mapAjvErrors(validate.errors, doc)) {
const severity = f.unknownKey && !strict ? "warning" : "error";
add(severity, file, f.unknownKey ? "unknown-key" : "schema", f);
}
}
}
const imagesDir = join(contentDir, "images");
if (existsSync(imagesDir)) {
const images = readdirSync(imagesDir).filter((f) => !f.startsWith("."));
const match = images.find((f) => {
const [base, ext] = [f.slice(0, f.lastIndexOf(".")), f.slice(f.lastIndexOf(".") + 1)];
return base === "profile" && PHOTO_EXTENSIONS.includes(ext.toLowerCase());
});
if (!match && images.length > 0) {
const near = images.find((f) => f.toLowerCase().startsWith("profile."));
add("warning", "images/", "no-photo", {
message: `no usable profile photo found (need profile.<${PHOTO_EXTENSIONS.join("|")}>)`,
suggestion: near ? `"${near}" has an unsupported extension \u2014 convert it to one of: ${PHOTO_EXTENSIONS.join(", ")}` : `rename your photo to profile.jpg (found: ${images.slice(0, 3).join(", ")}${images.length > 3 ? ", \u2026" : ""})`
});
}
}
return { ok: errors.length === 0, errors, warnings, checked };
}
export {
validateContent
};
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://raw.githubusercontent.com/hrtips/cvx/main/schema/v1/achievements.schema.json",
"$ref": "cvx.schema.json#/$defs/achievements"
}
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://raw.githubusercontent.com/hrtips/cvx/main/schema/v1/competencies.schema.json",
"$ref": "cvx.schema.json#/$defs/competencies"
}
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://raw.githubusercontent.com/hrtips/cvx/main/schema/v1/config.schema.json",
"$ref": "cvx.schema.json#/$defs/config"
}
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://raw.githubusercontent.com/hrtips/cvx/main/schema/v1/cvx.schema.json",
"title": "CVX content schema (v1)",
"description": "Canonical definitions for every file CVX reads from cv-content/. Per-file schemas (personal.schema.json, config.schema.json, ...) reference the $defs here. Content files never break within a schema major version.",
"$defs": {
"nonEmptyString": {
"type": "string",
"minLength": 1
},
"bulletItem": {
"description": "A bullet line. Either a plain string, or an object when part of the line should be a hyperlink: { text, link: { href, label }, suffix }. Rendered as: text + link.label (clickable) + suffix.",
"oneOf": [
{ "$ref": "#/$defs/nonEmptyString" },
{
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "Text before the link."
},
"link": {
"type": "object",
"properties": {
"href": {
"type": "string",
"description": "Destination URL."
},
"label": {
"type": "string",
"description": "Visible, clickable link text."
}
},
"required": ["href", "label"],
"additionalProperties": false
},
"suffix": {
"type": "string",
"description": "Text after the link."
}
},
"required": ["text"],
"additionalProperties": false
}
]
},
"personal": {
"title": "personal.yaml",
"description": "Identity and contact details. Required in practice: 'name' drives the output filename and PDF metadata. (The default two-column layout also needs summary.yaml and experience.yaml present.)",
"type": "object",
"properties": {
"name": {
"$ref": "#/$defs/nonEmptyString",
"description": "Full name. Required — also derives the PDF filename (lowercased, spaces to hyphens)."
},
"title": {
"type": "string",
"description": "Professional headline, e.g. \"Senior Engineer\". Also used for PDF metadata and ATS keyword derivation."
},
"company": {
"type": "string",
"description": "Current employer, shown under the title."
},
"phone": {
"type": "string",
"description": "Phone number as displayed."
},
"phoneHref": {
"type": "string",
"description": "Optional link target for the phone row, e.g. \"tel:+15550100\"."
},
"email": {
"type": "string",
"description": "Email address; rendered as a mailto: link automatically."
},
"linkedin": {
"type": "string",
"description": "LinkedIn display text, e.g. \"linkedin.com/in/janedoe\"."
},
"linkedinHref": {
"type": "string",
"description": "Full LinkedIn URL used as the link target."
},
"facebook": {
"type": "string",
"description": "Facebook display text. Shown only in the two-column sidebar, not in the ATS header."
},
"facebookHref": {
"type": "string",
"description": "Full Facebook URL used as the link target."
},
"location": {
"type": "string",
"description": "City / country line. Plain text, no link."
}
},
"required": ["name"],
"additionalProperties": false
},
"summary": {
"title": "summary.yaml",
"description": "Professional summary as a list of bullet lines. Items may be plain strings or the object form with an inline hyperlink.",
"type": "array",
"items": { "$ref": "#/$defs/bulletItem" }
},
"progressionStep": {
"type": "object",
"properties": {
"title": {
"$ref": "#/$defs/nonEmptyString",
"description": "Role title held during this step. Also feeds ATS keyword derivation."
},
"period": {
"type": "string",
"description": "Free-text date range for this step, e.g. \"2019 – 2021\"."
}
},
"required": ["title"],
"additionalProperties": false
},
"experienceEntry": {
"type": "object",
"properties": {
"role": {
"$ref": "#/$defs/nonEmptyString",
"description": "Job title. Required. Also feeds ATS keyword derivation."
},
"company": {
"type": "string",
"description": "Employer name."
},
"period": {
"type": "string",
"description": "Free-text date range, e.g. \"Mar 2021 – Present\"."
},
"location": {
"type": "string",
"description": "Where the role was based. Shown in the designed layout only, not in the ATS variant."
},
"description": {
"type": "string",
"description": "One-paragraph summary of the role."
},
"progression": {
"type": "array",
"description": "Promotions / role changes within the same company, most recent first.",
"items": { "$ref": "#/$defs/progressionStep" }
},
"bullets": {
"type": "array",
"description": "Achievement bullet points. Items may be plain strings or the object form with an inline hyperlink.",
"items": { "$ref": "#/$defs/bulletItem" }
}
},
"required": ["role"],
"additionalProperties": false
},
"experience": {
"title": "experience.yaml",
"description": "Work history as a list of entries, most recent first.",
"type": "array",
"items": { "$ref": "#/$defs/experienceEntry" }
},
"educationEntry": {
"type": "object",
"properties": {
"degree": {
"$ref": "#/$defs/nonEmptyString",
"description": "Qualification name, e.g. \"BSc Computer Science\". Required."
},
"institution": {
"$ref": "#/$defs/nonEmptyString",
"description": "Awarding institution. Required."
},
"period": {
"type": "string",
"description": "Free-text date range or year. Omit to hide the date line."
}
},
"required": ["degree", "institution"],
"additionalProperties": false
},
"education": {
"title": "education.yaml",
"description": "Education as a list of entries, most recent first.",
"type": "array",
"items": { "$ref": "#/$defs/educationEntry" }
},
"competencies": {
"title": "competencies.yaml",
"description": "Core skills as a flat list of short strings. Rendered as pills in the designed layout; also feeds ATS keyword derivation.",
"type": "array",
"items": { "$ref": "#/$defs/nonEmptyString" }
},
"achievementEntry": {
"type": "object",
"properties": {
"year": {
"$ref": "#/$defs/nonEmptyString",
"description": "Bold headline of the achievement — often a year or award name, but any short string works."
},
"text": {
"$ref": "#/$defs/nonEmptyString",
"description": "Attribution / detail line rendered under the headline."
}
},
"required": ["year", "text"],
"additionalProperties": false
},
"achievements": {
"title": "achievements.yaml",
"description": "Awards and recognitions as a list of entries.",
"type": "array",
"items": { "$ref": "#/$defs/achievementEntry" }
},
"refereeEntry": {
"type": "object",
"properties": {
"name": {
"$ref": "#/$defs/nonEmptyString",
"description": "Referee's full name. Required."
},
"title": {
"type": "string",
"description": "Referee's role/title."
},
"company": {
"type": "string",
"description": "Referee's organisation."
},
"email": {
"type": "string",
"description": "Contact email."
},
"phone": {
"type": "string",
"description": "Contact phone."
}
},
"required": ["name"],
"additionalProperties": false
},
"referees": {
"title": "referees.yaml",
"description": "References as a list of entries. An empty list ([]) prints \"References available upon request.\" in the two-column layout and omits the section in the ATS variant.",
"type": "array",
"items": { "$ref": "#/$defs/refereeEntry" }
},
"keywordGroup": {
"type": "object",
"description": "Named group of keywords. Group names are for your organisation only — they are flattened and discarded in the PDF metadata.",
"additionalProperties": {
"oneOf": [
{ "type": "string" },
{ "type": "array", "items": { "type": "string" } }
]
}
},
"keywords": {
"title": "keywords.yaml",
"description": "Optional ATS keywords embedded in PDF metadata (never printed). Accepts a flat list of strings, a map of named groups, a mixed list of strings and group maps, or a single string. Merged with auto-derived keywords (competencies, titles) unless disabled in config.",
"oneOf": [
{ "type": "string" },
{ "$ref": "#/$defs/keywordGroup" },
{
"type": "array",
"items": {
"oneOf": [
{ "type": "string" },
{ "$ref": "#/$defs/keywordGroup" }
]
}
}
]
},
"config": {
"title": "config.yaml",
"description": "Build configuration: theme, layout, first-page pagination, and ATS keyword behaviour.",
"type": "object",
"properties": {
"schemaVersion": {
"type": "integer",
"const": 1,
"description": "Content schema major version. Content files never break within a major."
},
"theme": {
"type": "string",
"enum": ["teal", "coral", "mono"],
"default": "teal",
"description": "Colour theme. One of the built-in themes; an unknown value fails the build."
},
"layout": {
"type": "string",
"default": "two-column",
"description": "Layout name, matching a file in cv-content/layouts/ (built-ins: \"two-column\", \"single-column\"). An unknown value warns and falls back to the built-in default."
},
"page1ExperienceCount": {
"type": "integer",
"minimum": 1,
"description": "Exact number of experience entries on page 1 — entry N+1 starts page 2. If the forced count does not fit, validate/build warn and the overflow is clipped at the page edge. Omit for automatic page packing (never overflows)."
},
"page1SplitBullets": {
"type": "integer",
"minimum": 1,
"description": "Split the last page-1 experience entry after this many bullets, continuing overleaf. Omit to keep entries whole."
},
"atsKeywords": {
"type": "object",
"description": "ATS keyword metadata behaviour.",
"properties": {
"enabled": {
"type": "boolean",
"default": true,
"description": "Write the Keywords field into PDF metadata."
},
"autoDerive": {
"type": "boolean",
"default": true,
"description": "Merge keywords derived from competencies and role titles."
},
"max": {
"type": "integer",
"minimum": 1,
"description": "Cap the number of keywords written. Omit for no cap."
}
},
"additionalProperties": false
}
},
"additionalProperties": false
},
"layoutSlot": {
"description": "One slot in a page region: a section key string (identity-photo, identity-compact, contact, summary, experience, education, competencies, achievements, referees, header-ats, experience:continued, spacer:N), or an object form — { spacer: N } or { <section>: { continued: true } }.",
"oneOf": [
{ "$ref": "#/$defs/nonEmptyString" },
{
"type": "object",
"minProperties": 1,
"maxProperties": 1,
"properties": {
"spacer": {
"type": "number",
"description": "Vertical spacer height in points."
}
},
"additionalProperties": {
"type": "object",
"properties": {
"continued": {
"type": "boolean",
"description": "Render this section's continuation slice."
}
},
"additionalProperties": false
}
}
]
},
"layoutPage": {
"type": "object",
"description": "Section placement for one page kind.",
"properties": {
"sidebar": {
"type": "array",
"items": { "$ref": "#/$defs/layoutSlot" },
"description": "Slots in the sidebar column (two-column template only)."
},
"main": {
"type": "array",
"items": { "$ref": "#/$defs/layoutSlot" },
"description": "Slots in the main column."
}
},
"additionalProperties": false
},
"layout": {
"title": "layouts/*.yaml",
"description": "A layout file: which template to use and which sections appear where on each page kind.",
"type": "object",
"properties": {
"template": {
"type": "string",
"enum": ["two-column", "single-column"],
"default": "two-column",
"description": "Page template the layout builds on."
},
"pages": {
"type": "object",
"properties": {
"first": { "$ref": "#/$defs/layoutPage" },
"continuation": { "$ref": "#/$defs/layoutPage" },
"last": { "$ref": "#/$defs/layoutPage" }
},
"additionalProperties": false
},
"geometry": {
"type": "object",
"description": "Reserved. Currently ignored — page geometry comes from the theme. Kept so existing files with a geometry block stay valid.",
"additionalProperties": true
},
"first": {
"$ref": "#/$defs/layoutPage",
"description": "Flat form: equivalent to pages.first when the pages: wrapper is omitted."
},
"continuation": {
"$ref": "#/$defs/layoutPage",
"description": "Flat form: equivalent to pages.continuation when the pages: wrapper is omitted."
},
"last": {
"$ref": "#/$defs/layoutPage",
"description": "Flat form: equivalent to pages.last when the pages: wrapper is omitted."
}
},
"additionalProperties": false
}
}
}
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://raw.githubusercontent.com/hrtips/cvx/main/schema/v1/education.schema.json",
"$ref": "cvx.schema.json#/$defs/education"
}
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://raw.githubusercontent.com/hrtips/cvx/main/schema/v1/experience.schema.json",
"$ref": "cvx.schema.json#/$defs/experience"
}
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://raw.githubusercontent.com/hrtips/cvx/main/schema/v1/keywords.schema.json",
"$ref": "cvx.schema.json#/$defs/keywords"
}
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://raw.githubusercontent.com/hrtips/cvx/main/schema/v1/layout.schema.json",
"$ref": "cvx.schema.json#/$defs/layout"
}
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://raw.githubusercontent.com/hrtips/cvx/main/schema/v1/personal.schema.json",
"$ref": "cvx.schema.json#/$defs/personal"
}
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://raw.githubusercontent.com/hrtips/cvx/main/schema/v1/referees.schema.json",
"$ref": "cvx.schema.json#/$defs/referees"
}
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://raw.githubusercontent.com/hrtips/cvx/main/schema/v1/summary.schema.json",
"$ref": "cvx.schema.json#/$defs/summary"
}
---
name: cvx
description: Create, validate, and render professional CV/resume PDFs from plain YAML using CVX — fully local, no accounts. Use when the user wants to write a CV or resume, convert an existing CV to a maintained format, tailor a CV for a job application, or produce an ATS-safe variant. Covers the cv-content/ YAML schema, the edit→validate→build loop, themes, layouts, and ATS keywords.
license: Apache-2.0
compatibility: Requires Node.js 20+ (runs via npx, no install). MCP server available via `npx @hrtips/cvx mcp`.
metadata:
author: hrtips
homepage: https://github.com/hrtips/cvx
---
# CVX — structured input, professional output
CVX renders a folder of plain YAML files (`cv-content/`) into a pixel-perfect CV PDF. Everything runs locally: no accounts, no network calls, and the user's data never leaves their machine. The YAML is the durable asset — the user keeps and re-edits it for every future application.
## The loop
If the CVX MCP server is connected, use its tools: `get_schema` → `init_cv` → edit YAML → `validate_cv` → `build_pdf` (pass the workspace folder as `dir`, absolute path). Otherwise use the CLI:
```bash
npx @hrtips/cvx init # scaffold cv-content/ with a complete example CV
npx @hrtips/cvx validate --strict --json # every problem at once: file + field paths + fixes
npx @hrtips/cvx build --json # writes <name>.pdf; add --ats for the ATS-safe variant
npx @hrtips/cvx list --json # available themes and layouts
```
Exit codes: `0` ok · `2` validation failed · `3` render failed · `64` usage error. With `--json`, stdout is exactly one JSON object; logs go to stderr.
Always validate after every edit and before every build. Findings include the file, the field path, and a suggested fix — apply the fix and re-validate.
If `npx` is unreachable (no network in your sandbox), write the `cv-content/*.yaml` files from the schema and deliver them with the handoff from the [AI guide's default flow](https://raw.githubusercontent.com/hrtips/cvx/main/docs/ai-guide.md) — never substitute another PDF renderer. A linkedin.com URL is unfetchable even when public: ask for the profile's **More → Save to PDF** export or pasted text instead of inferring.
## Rules that are not optional
1. **Never invent facts.** Every entry must be truthful to the user's real history. This matters most for `keywords.yaml`: ATS parsers cross-check keywords against the CV body, and stuffing false terms gets CVs auto-rejected.
2. **Don't rename the YAML files.** Sections are discovered by filename: `personal.yaml`, `summary.yaml`, `experience.yaml`, `education.yaml`, `competencies.yaml`, `achievements.yaml`, `referees.yaml`, `keywords.yaml`, `config.yaml`.
3. **Quote strings containing colons** (`"Director: Operations"`). Date ranges are free text (`2019 – Present`).
4. The photo goes at `cv-content/images/profile.jpg` (or `.jpeg`/`.png`/`.webp`) — ask the user for it; it cannot be generated.
## Content files (summary — the schema is authoritative)
Every scaffolded file carries a `$schema` header; the canonical JSON Schema lives at `schema/v1/` in the repo and is returned by the MCP `get_schema` tool.
- `personal.yaml` (object): `name` (required — drives the output filename), `title`, `company`, `phone`+`phoneHref`, `email`, `linkedin`+`linkedinHref`, `facebook`+`facebookHref`, `location`.
- `summary.yaml`: list of 3–6 single-sentence bullets. A bullet may also be `{text, link: {href, label}, suffix}` to embed a clickable link (same form works in experience bullets).
- `experience.yaml`: list of roles, most recent first — `role` (required), `company`, `period`, `location`, `description`, `progression` (list of `{title, period}`), `bullets` (verb-first, quantified, truthful).
- `education.yaml`: list of `{degree, institution, period}`.
- `competencies.yaml`: 6–12 short skill strings.
- `achievements.yaml`: list of `{year, text}` — `year` is the bold headline (often the award name), `text` the attribution.
- `referees.yaml`: list of `{name, title, company, email, phone}`, or `[]` for "available upon request".
- `keywords.yaml` (optional): extra truthful ATS keywords not already covered by competencies/titles; embedded in PDF metadata, never printed.
- `config.yaml`: `schemaVersion: 1`, `theme` (`teal`|`coral`|`mono`), `layout` (`two-column`|`single-column`|custom filename); pagination keys only if page 1 overflows.
## Variants
- Designed CV: `build` → two-column, photo sidebar, theme colours.
- Job-portal upload: `build --ats` (or `build_pdf` with `ats: true`) → single column, no colours, machine-friendly, `<name>-ats.pdf`.
## When validation fails
Report the findings to the user in plain language, apply the suggested fixes to the YAML, and re-validate. Unknown keys are typos more often than not — the findings include a "did you mean" suggestion. Do not build until validation passes.
# Working with this cv-content/ folder
This folder is [CVX](https://github.com/hrtips/cvx) content: YAML in, pixel-perfect CV PDF out. Everything runs locally.
## The loop
```bash
npx @hrtips/cvx validate --strict --json # machine-readable findings, exit 2 on any problem
npx @hrtips/cvx build --json # writes <name>.pdf, prints {filename, bytes, theme, layout}
```
Edit → validate → build. Always validate before building; it reports every problem at once with file + field paths and suggested fixes.
## Contract
- Exit codes: `0` ok · `2` validation failed · `3` render failed · `64` usage error.
- With `--json`, stdout is exactly one JSON object; logs go to stderr.
- `npx @hrtips/cvx list --json` shows available themes and layouts.
- Every file here carries a `# yaml-language-server: $schema=…` header — the JSON Schema is the authoritative contract for keys and shapes. Full field reference: [docs/cv-schema.md](https://github.com/hrtips/cvx/blob/main/docs/cv-schema.md).
## Rules
1. **Never invent facts.** Every entry must be truthful to the person's real history. This especially matters for `keywords.yaml` — ATS parsers cross-check keywords against the CV body, and stuffing gets CVs auto-rejected. A linkedin.com URL is unfetchable even when public — ask for the profile's **More → Save to PDF** export or pasted text instead of inferring.
2. **Don't rename the YAML files.** Sections are discovered by filename (`personal.yaml`, `summary.yaml`, `experience.yaml`, `education.yaml`, `competencies.yaml`, `achievements.yaml`, `referees.yaml`, `keywords.yaml`, `config.yaml`).
3. **Quote strings containing colons** (`"Director: Operations"`). Date ranges are free text (`2019 – Present`).
4. The profile photo goes at `images/profile.jpg` (or `.jpeg`/`.png`/`.webp`) — ask the user for it early; it can't be generated. The scaffolded `images/profile.jpg` is Bruce Wayne's example photo: replace it with the user's or delete it before building (the CV renders fine without one).
5. `config.yaml` usually needs only `theme` + `layout`; add pagination keys only if page 1 overflows.
+187
-24

@@ -5,11 +5,19 @@ #!/usr/bin/env node

*
* cvx init scaffold a starter cv-content/ in the current directory
* cvx build [--ats] render cv-content/ to a PDF in the current directory
* cvx init scaffold a starter cv-content/ in the current directory
* cvx validate check cv-content/ and report every problem at once
* cvx build [--ats] render cv-content/ to a PDF in the current directory
*
* Imports from ../lib (the published transform of src/pdf). In a repo checkout
* run `npm run build:lib` first, or use the `npm run pdf` scripts instead.
*
* Contract for agents and scripts:
* - exit codes: 0 success · 2 validation failed · 3 render failed · 64 usage error
* - with --json, stdout carries exactly one JSON object (the result);
* logs and warnings go to stderr. Errors become { ok: false, error: {...} }.
* - every command is non-interactive.
*/
import { existsSync, cpSync, writeFileSync, readFileSync } from 'fs'
import { existsSync, cpSync, writeFileSync, readFileSync, readdirSync, mkdirSync } from 'fs'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
import { homedir } from 'os'
import { parseArgs } from 'node:util'

@@ -20,24 +28,40 @@

const EXIT = { ok: 0, validation: 2, render: 3, usage: 64 }
const HELP = `cvx ${version} — config-driven CV generator
Usage:
cvx init Scaffold a starter cv-content/ here (Bruce Wayne demo)
cvx build Render cv-content/ to <your-name>.pdf
cvx build --ats Render the ATS-safe single-column variant
cvx init Scaffold a starter cv-content/ here (Bruce Wayne demo)
cvx validate Check cv-content/ — all errors at once, with fixes
cvx build Render cv-content/ to <your-name>.pdf
cvx build --ats Render the ATS-safe single-column variant
cvx list [themes|layouts] Show available themes and layouts
cvx mcp Run the MCP stdio server (4 tools, fully offline)
cvx mcp init --client claude|claude-desktop|cursor|vscode
Write the MCP config for your client
Options:
-h, --help Show this help
-v, --version Show version
--strict validate: treat warnings (e.g. unknown keys) as errors
--json Machine-readable result on stdout; logs on stderr
-h, --help Show this help
-v, --version Show version
Exit codes: 0 ok · 2 validation failed · 3 render failed · 64 usage error
Edit the YAML files in cv-content/ and re-run "cvx build".
Docs: https://github.com/hrtips/cvx#readme`
async function init() {
const emit = (obj) => console.log(JSON.stringify(obj, null, 2))
async function init({ json }) {
const dest = join(process.cwd(), 'cv-content')
if (existsSync(dest)) {
console.error(`cv-content/ already exists here — refusing to overwrite.`)
process.exit(1)
if (json) emit({ command: 'init', ok: false, error: { code: 'already-exists', message: 'cv-content/ already exists here — refusing to overwrite' } })
else console.error(`cv-content/ already exists here — refusing to overwrite.`)
process.exit(EXIT.usage)
}
cpSync(join(pkgRoot, 'template', 'cv-content'), dest, { recursive: true })
console.log(`✅ Created cv-content/ with starter content.
if (json) {
emit({ command: 'init', ok: true, dest: 'cv-content' })
} else {
console.log(`✅ Created cv-content/ with starter content.

@@ -47,7 +71,110 @@ Next steps:

2. Drop your photo at cv-content/images/profile.jpg
3. Run: npx @hrtips/cvx build (or just "cvx build" if installed globally)`)
3. Check: npx @hrtips/cvx validate
4. Run: npx @hrtips/cvx build`)
}
}
async function build(ats) {
async function validate({ strict, json }) {
const { validateContent } = await import('../lib/pdf/validateContent.js')
const result = validateContent({ contentDir: join(process.cwd(), 'cv-content'), strict })
if (json) {
emit({ command: 'validate', ok: result.ok, schemaVersion: 1, strict, errors: result.errors, warnings: result.warnings, checked: result.checked })
} else {
const byFile = new Map()
for (const [sev, list] of [['error', result.errors], ['warning', result.warnings]])
for (const f of list) {
if (!byFile.has(f.file)) byFile.set(f.file, [])
byFile.get(f.file).push({ sev, ...f })
}
for (const [file, findings] of byFile) {
console.log(file ? `cv-content/${file}` : 'cv-content/')
for (const f of findings) {
const mark = f.sev === 'error' ? '✖' : '⚠'
const where = f.path && f.path !== '(root)' ? `${f.path}: ` : ''
console.log(` ${mark} ${where}${f.message}${f.suggestion ? `\n ↳ ${f.suggestion}` : ''}`)
}
}
const e = result.errors.length, w = result.warnings.length
if (e === 0 && w === 0) console.log(`✅ cv-content/ is valid (${result.checked.length} files checked)`)
else console.log(`\n${e ? '✖' : '⚠'} ${e} error${e === 1 ? '' : 's'}, ${w} warning${w === 1 ? '' : 's'}${!strict && w ? ' (use --strict to treat warnings as errors)' : ''}`)
}
process.exit(result.ok ? EXIT.ok : EXIT.validation)
}
async function list({ kind, json }) {
const { discoverThemes } = await import('../lib/pdf/themes/index.js')
const themes = Object.keys(await discoverThemes()).map((name) => ({ name, default: name === 'teal' }))
const layoutsDir = join(process.cwd(), 'cv-content', 'layouts')
const builtIn = ['two-column', 'single-column']
const names = new Set(builtIn)
const layouts = builtIn.map((name) => ({ name, default: name === 'two-column', source: 'built-in' }))
if (existsSync(layoutsDir)) {
for (const f of readdirSync(layoutsDir).filter((f) => f.endsWith('.yaml'))) {
const name = f.replace(/\.yaml$/, '')
if (!names.has(name)) layouts.push({ name, default: false, source: 'cv-content/layouts' })
names.add(name)
}
}
const result = { command: 'list', ...((!kind || kind === 'themes') && { themes }), ...((!kind || kind === 'layouts') && { layouts }) }
if (json) return emit(result)
if (result.themes) {
console.log('Themes (config.yaml → theme):')
for (const t of result.themes) console.log(` ${t.name}${t.default ? ' (default)' : ''}`)
}
if (result.layouts) {
console.log('Layouts (config.yaml → layout):')
for (const l of result.layouts) console.log(` ${l.name}${l.default ? ' (default)' : ''}${l.source === 'built-in' ? '' : ` [${l.source}]`}`)
}
}
const MCP_ENTRY = { command: 'npx', args: ['-y', '@hrtips/cvx', 'mcp'] }
const MCP_CLIENTS = {
claude: { file: () => join(process.cwd(), '.mcp.json'), root: 'mcpServers', entry: { type: 'stdio', ...MCP_ENTRY } },
cursor: { file: () => join(process.cwd(), '.cursor', 'mcp.json'), root: 'mcpServers', entry: MCP_ENTRY },
vscode: { file: () => join(process.cwd(), '.vscode', 'mcp.json'), root: 'servers', entry: { type: 'stdio', ...MCP_ENTRY } },
'claude-desktop': {
file: () => {
if (process.platform === 'darwin') return join(homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json')
if (process.platform === 'win32') return join(process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'), 'Claude', 'claude_desktop_config.json')
return join(homedir(), '.config', 'claude-desktop', 'claude_desktop_config.json')
},
root: 'mcpServers',
entry: MCP_ENTRY,
},
}
async function mcpInit({ client, json }) {
const target = MCP_CLIENTS[client]
if (!target) {
const msg = `unknown client: ${client ?? '(none)'} (expected ${Object.keys(MCP_CLIENTS).join(', ')})`
if (json) emit({ command: 'mcp-init', ok: false, error: { code: 'unknown-client', message: msg } })
else console.error(`Unknown client: ${client ?? '(none)'} — use --client ${Object.keys(MCP_CLIENTS).join('|')}`)
process.exit(EXIT.usage)
}
const file = target.file()
// Merge into an existing config rather than clobbering other servers.
let config = {}
if (existsSync(file)) {
try {
config = JSON.parse(readFileSync(file, 'utf8'))
} catch {
const msg = `${file} exists but is not valid JSON — fix it manually, then re-run`
if (json) emit({ command: 'mcp-init', ok: false, error: { code: 'invalid-config', message: msg } })
else console.error(msg)
process.exit(EXIT.usage)
}
}
config[target.root] = { ...config[target.root], cvx: target.entry }
mkdirSync(dirname(file), { recursive: true })
writeFileSync(file, JSON.stringify(config, null, 2) + '\n')
if (json) emit({ command: 'mcp-init', ok: true, client, file })
else console.log(`✅ Added the cvx MCP server to ${file}\n Restart ${client === 'claude-desktop' ? 'Claude Desktop' : client} to pick it up.`)
}
async function build({ ats, json }) {
const { renderCV } = await import('../lib/pdf/render.js')
const warnings = []
const { buffer, filename, themeName, layoutName } = await renderCV({

@@ -57,8 +184,15 @@ contentDir: join(process.cwd(), 'cv-content'),

ats,
warn: (msg) => { warnings.push(msg); console.error(`⚠ ${msg}`) },
})
writeFileSync(join(process.cwd(), filename), buffer)
const mode = ats ? 'ATS' : `theme: ${themeName}, layout: ${layoutName}`
console.log(`✅ ${filename} (${(buffer.byteLength / 1024).toFixed(0)} KB, ${mode})`)
if (json) {
emit({ command: 'build', ok: true, filename, bytes: buffer.byteLength, ats, theme: ats ? null : themeName, layout: ats ? null : layoutName, warnings })
} else {
const mode = ats ? 'ATS' : `theme: ${themeName}, layout: ${layoutName}`
console.log(`✅ ${filename} (${(buffer.byteLength / 1024).toFixed(0)} KB, ${mode})`)
}
}
let command = null
let jsonMode = false
try {

@@ -68,2 +202,5 @@ const { values, positionals } = parseArgs({

ats: { type: 'boolean', default: false },
strict: { type: 'boolean', default: false },
json: { type: 'boolean', default: false },
client: { type: 'string' },
help: { type: 'boolean', short: 'h', default: false },

@@ -74,2 +211,4 @@ version: { type: 'boolean', short: 'v', default: false },

})
command = positionals[0] ?? null
jsonMode = values.json

@@ -80,13 +219,37 @@ if (values.version) {

console.log(HELP)
} else if (positionals[0] === 'init') {
await init()
} else if (positionals[0] === 'build') {
await build(values.ats)
} else if (command === 'init') {
await init(values)
} else if (command === 'validate') {
await validate(values)
} else if (command === 'list') {
const kind = positionals[1]
if (kind && !['themes', 'layouts'].includes(kind)) {
if (jsonMode) emit({ command: 'list', ok: false, error: { code: 'unknown-list-kind', message: `unknown list kind: ${kind} (expected themes or layouts)` } })
else console.error(`Unknown list kind: ${kind} (expected themes or layouts)`)
process.exit(EXIT.usage)
}
await list({ kind, json: values.json })
} else if (command === 'mcp') {
if (positionals[1] === 'init') {
await mcpInit({ client: values.client, json: values.json })
} else if (positionals[1] === undefined) {
const { runMcpServer } = await import('../lib/mcp/server.js')
await runMcpServer()
} else {
if (jsonMode) emit({ command: 'mcp', ok: false, error: { code: 'unknown-subcommand', message: `unknown mcp subcommand: ${positionals[1]}` } })
else console.error(`Unknown mcp subcommand: ${positionals[1]} (expected "init" or nothing)`)
process.exit(EXIT.usage)
}
} else if (command === 'build') {
await build(values)
} else {
console.error(`Unknown command: ${positionals[0]}\n\n${HELP}`)
process.exit(1)
if (jsonMode) emit({ command, ok: false, error: { code: 'unknown-command', message: `unknown command: ${command}` } })
else console.error(`Unknown command: ${command}\n\n${HELP}`)
process.exit(EXIT.usage)
}
} catch (err) {
console.error(err.message)
process.exit(1)
const code = command === 'build' ? EXIT.render : EXIT.usage
if (jsonMode) emit({ command, ok: false, error: { code: command === 'build' ? 'render-failed' : 'usage', message: err.message } })
else console.error(err.message)
process.exit(code)
}

@@ -121,2 +121,19 @@ import { tealTheme } from "./themes/teal.js";

}
const PAGE1_OVERFLOW_WARN_THRESHOLD = 220;
function estimatePage1Overflow(experience, summary, config = {}, theme) {
const { page1ExperienceCount: count, page1SplitBullets: splitAt } = config;
if (count == null) return 0;
const m = deriveMetrics(theme);
const entries = experience.slice(0, count).map((e, i) => {
const isLast = i === count - 1;
if (isLast && splitAt != null && splitAt < (e.bullets?.length ?? 0)) return { ...e, endBullet: splitAt };
return e;
});
let used = 0;
entries.forEach((e, i) => {
used += entryH(e, m) + (i > 0 ? calcDividerH(m) : 0);
});
const budget = m.pageH - m.topBar - m.mainPad.top - m.mainPad.bottom - summaryH(summary ?? [], m) - m.spacer - calcTitleH(m) - m.safety;
return Math.max(0, Math.round(used - budget));
}
function packExperiences(experience, summary, config = {}, theme) {

@@ -186,4 +203,6 @@ const m = deriveMetrics(theme);

export {
PAGE1_OVERFLOW_WARN_THRESHOLD,
estimatePage1Overflow,
packExperiences,
resolveFirstSidebar
};

@@ -11,2 +11,3 @@ import { readdirSync, readFileSync, existsSync } from "fs";

import { discoverThemes } from "./themes/index.js";
import { estimatePage1Overflow, PAGE1_OVERFLOW_WARN_THRESHOLD } from "./layout.js";
import CVDocument from "./CVDocument.js";

@@ -52,2 +53,8 @@ import ATSDocument from "./ATSDocument.js";

}
const overflow = estimatePage1Overflow(content.experience ?? [], content.summary ?? [], config, theme);
if (overflow > PAGE1_OVERFLOW_WARN_THRESHOLD) {
warn(
`page1ExperienceCount: ${config.page1ExperienceCount} likely does not fit on page 1 (estimate \u2248${overflow - PAGE1_OVERFLOW_WARN_THRESHOLD}pt past the tuned margin) \u2014 overflowing content is clipped at the page edge. Check the rendered page 1; reduce page1ExperienceCount, set page1SplitBullets, or remove both for automatic pagination.`
);
}
const buffer = await renderToBuffer(

@@ -54,0 +61,0 @@ createElement(CVDocument, { ...content, profilePhoto, config, theme, layout, creationDate })

@@ -11,3 +11,9 @@ import { jsx, jsxs } from "react/jsx-runtime";

topBar: { height: g.topBar, backgroundColor: t.palette.accent },
body: { flexDirection: "row", height: bodyH, backgroundColor: t.palette.accent },
// minHeight, NOT height: a fixed height authorizes yoga to compress the
// columns' children when content overflows (glyphs overprint — see
// dogfood report 2026-07-26). With a minimum, short content still fills
// the page and long content overflows past the page edge, clipped —
// visible and honest. The packer + page1-overflow warning keep content
// within budget; this is the last line of defense.
body: { flexDirection: "row", minHeight: bodyH, backgroundColor: t.palette.accent },
sidebar: { width: sidebarPct, backgroundColor: t.palette.sidebarBg },

@@ -14,0 +20,0 @@ mainFirst: { flex: 1, flexDirection: "column", backgroundColor: t.palette.white, borderTopLeftRadius: t.chrome.mainColumnTopRadius },

+13
-4
{
"name": "@hrtips/cvx",
"version": "1.2.1",
"description": "CVX — structured input, professional output. YAML content in, pixel-perfect CV PDFs out; swappable themes and layouts, no headless browser. Formerly makecv.",
"version": "1.3.0-next.3d7f6c4",
"description": "CVX — structured input, professional output. YAML content in, pixel-perfect CV PDFs out; swappable themes and layouts, no headless browser. MCP server included. Formerly makecv.",
"mcpName": "io.github.hrtips/cvx",
"bin": {

@@ -20,3 +21,6 @@ "cvx": "bin/cvx.js"

"ats",
"react-pdf"
"react-pdf",
"mcp",
"mcp-server",
"model-context-protocol"
],

@@ -31,2 +35,3 @@ "scripts": {

"pdf:ats": "npx tsx scripts/export-pdf-ats.js",
"pretest": "npm run build:lib",
"test": "vitest run"

@@ -41,3 +46,5 @@ },

"bin",
"template"
"template",
"schema",
"skills"
],

@@ -53,3 +60,5 @@ "devDependencies": {

"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"@react-pdf/renderer": "^4.5.1",
"ajv": "^8.20.0",
"js-yaml": "^4.3.0",

@@ -56,0 +65,0 @@ "react": "^19.2.8",

@@ -15,4 +15,12 @@ <p align="center">

Write your CV in plain YAML files, run one command, get a pixel-perfect PDF. No Word, no Google Docs, no design tool, no account — your data never leaves your machine. <sub>(Formerly published as <code>makecv</code>.)</sub>
**CVX turns plain YAML into a professionally designed CV PDF — one command, fully local, no accounts; your data never leaves your machine.** Use it two ways: edit the YAML yourself, or have any AI assistant write it for you from your existing CV — CVX validates the files and tells the assistant exactly what to fix.
> **🤖 AI assistants:** if a user asked you to create their CV with CVX, follow the default flow in the [AI guide](https://raw.githubusercontent.com/hrtips/cvx/main/docs/ai-guide.md) (section "Default flow (for assistants)"). Non-negotiables even if you skip the link: **never invent facts** — every entry must be truthful to the user's input; **linkedin.com URLs are unfetchable even when public** — don't infer, ask for the profile's **More → Save to PDF** export or pasted text instead; ask for a **square photo** in your first reply (it can't be generated; the CV renders fine without one); and CVX is the only renderer — never substitute another PDF generator.
**No terminal? No problem.** Paste this into any AI chat (Claude, ChatGPT, Gemini, …):
```text
Create my CV with https://github.com/hrtips/cvx — open that page and follow its instructions for AI assistants.
Here is my LinkedIn profile: <your profile URL> (or attach your current CV and a photo instead).
```
<p align="center">

@@ -50,2 +58,12 @@ <img src="assets/hero-two-column.png" alt="Designed two-column CV produced by CVX — teal theme, photo sidebar, achievements" width="720">

Made a typo or unsure a file is right? `npx @hrtips/cvx validate` checks everything at once and tells you exactly what to fix:
```
cv-content/personal.yaml
⚠ unknown key "linkdin"
↳ did you mean "linkedin"?
```
Every scaffolded file also carries a `$schema` header, so editors with YAML support (VS Code + the YAML extension, JetBrains, …) autocomplete keys and flag mistakes as you type.
Applying through a job portal? Generate the ATS-safe variant too — single column, no colours, machine-friendly:

@@ -62,6 +80,13 @@

| `npx @hrtips/cvx init` | Scaffold `cv-content/` with the example CV (won't overwrite an existing one) |
| `npx @hrtips/cvx validate` | Check `cv-content/` — every problem at once, with file + field paths and fixes |
| `npx @hrtips/cvx validate --strict` | Also fail on warnings (unknown keys); recommended for agents/CI |
| `npx @hrtips/cvx build` | Render `cv-content/` to `<your-name>.pdf` |
| `npx @hrtips/cvx build --ats` | Render the ATS-safe single-column variant |
| `npx @hrtips/cvx list` | Show available themes and layouts |
| `npx @hrtips/cvx --help` / `--version` | Help / version |
All commands accept `--json` for machine-readable output (one JSON object on stdout, logs on stderr) and use semantic exit codes: `0` ok, `2` validation failed, `3` render failed, `64` usage error. `init` is a convenience, not a prerequisite — `build` renders any `cv-content/` folder with valid YAML (built-in themes and layouts need no extra files).
**Compatibility promise:** content files are versioned by `schemaVersion` in `config.yaml` (currently `1`) and validated against the [canonical JSON Schema](schema/v1/cvx.schema.json). Within a schema major version, your content files never break — new keys may appear, existing ones keep working. `npx @hrtips/cvx validate` on today's files will still pass on every future 1.x release.
---

@@ -161,2 +186,15 @@

### Plug it into your agent (MCP)
CVX ships an MCP server — any MCP client (Claude Desktop, Claude Code, Cursor, VS Code, …) can drive the whole loop with four tools: `get_schema`, `init_cv`, `validate_cv`, `build_pdf`. No API keys, fully offline.
```bash
npx @hrtips/cvx mcp init --client claude # Claude Code (.mcp.json, project)
npx @hrtips/cvx mcp init --client claude-desktop # Claude Desktop (global config)
npx @hrtips/cvx mcp init --client cursor # Cursor (.cursor/mcp.json)
npx @hrtips/cvx mcp init --client vscode # VS Code (.vscode/mcp.json)
```
Then restart the client and ask it to make your CV — it fetches the schema, scaffolds, fills in your details, validates after every edit, and renders the PDF. The config writer merges into existing files; it never clobbers other servers. There's also a ready-made [Agent Skill](skills/cvx/SKILL.md) with the same loop for skill-capable agents.
### Your photo

@@ -163,0 +201,0 @@

@@ -0,1 +1,2 @@

# yaml-language-server: $schema=https://raw.githubusercontent.com/hrtips/cvx/main/schema/v1/achievements.schema.json
- year: Gotham's Most Influential Citizen

@@ -2,0 +3,0 @@ text: "— 2024, Gotham Gazette"

@@ -0,1 +1,2 @@

# yaml-language-server: $schema=https://raw.githubusercontent.com/hrtips/cvx/main/schema/v1/competencies.schema.json
- Strategic Planning

@@ -2,0 +3,0 @@ - Criminal Investigation

@@ -0,1 +1,2 @@

# yaml-language-server: $schema=https://raw.githubusercontent.com/hrtips/cvx/main/schema/v1/config.schema.json
# ── CV Configuration ─────────────────────────────────────────────────────────

@@ -15,2 +16,5 @@ #

# schemaVersion Content schema major — content files never break within a major.
schemaVersion: 1
theme: teal

@@ -17,0 +21,0 @@ layout: two-column

@@ -0,1 +1,2 @@

# yaml-language-server: $schema=https://raw.githubusercontent.com/hrtips/cvx/main/schema/v1/education.schema.json
- degree: "Applied Sciences & Criminology (self-directed)"

@@ -2,0 +3,0 @@ institution: League of Shadows

@@ -0,1 +1,2 @@

# yaml-language-server: $schema=https://raw.githubusercontent.com/hrtips/cvx/main/schema/v1/experience.schema.json
- role: Founder & Field Commander – Gotham Operations

@@ -2,0 +3,0 @@ company: The Batman

@@ -0,1 +1,2 @@

# yaml-language-server: $schema=https://raw.githubusercontent.com/hrtips/cvx/main/schema/v1/keywords.schema.json
# ── ATS & AI-parser keywords ─────────────────────────────────────────────────

@@ -2,0 +3,0 @@ # These terms are embedded in the PDF's standard "Keywords" metadata field,

@@ -0,1 +1,2 @@

# yaml-language-server: $schema=https://raw.githubusercontent.com/hrtips/cvx/main/schema/v1/layout.schema.json
# ── Single-Column Layout (ATS) ────────────────────────────────────────────────

@@ -2,0 +3,0 @@ # Plain single-column layout optimised for Applicant Tracking Systems.

@@ -0,1 +1,2 @@

# yaml-language-server: $schema=https://raw.githubusercontent.com/hrtips/cvx/main/schema/v1/layout.schema.json
# ── Two-Column Layout ────────────────────────────────────────────────────────

@@ -2,0 +3,0 @@ # Professional two-column CV with sidebar identity block and main content area.

@@ -0,1 +1,2 @@

# yaml-language-server: $schema=https://raw.githubusercontent.com/hrtips/cvx/main/schema/v1/personal.schema.json
name: Bruce Wayne

@@ -2,0 +3,0 @@ title: Founder & Field Commander – Gotham Operations

@@ -0,1 +1,2 @@

# yaml-language-server: $schema=https://raw.githubusercontent.com/hrtips/cvx/main/schema/v1/referees.schema.json
- name: Diana Prince

@@ -2,0 +3,0 @@ title: Founding Member, Justice League

@@ -0,1 +1,2 @@

# yaml-language-server: $schema=https://raw.githubusercontent.com/hrtips/cvx/main/schema/v1/summary.schema.json
- "Strategic operations leader with 20+ years' experience, progressing from solo field operative to Field Commander of a citywide security network and Chairman of a multinational conglomerate."

@@ -2,0 +3,0 @@ - "Bootstrapped a citywide vigilante operation from the ground up, building end-to-end capability across surveillance, forensic investigation, crisis response, and team leadership under a strict zero-lethality code."