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.7.0
to
1.7.1
+252
docs/ai-guide.md
# Generating your CV with an AI assistant
CVX is designed to pair with LLMs: the content format is plain YAML with a small, documented schema, so any capable assistant — Claude, ChatGPT, Gemini, or a coding agent — can turn your existing CV, LinkedIn profile, or a rough brain-dump into polished `cv-content/` files. You review the facts, run one command, and get the PDF.
**If you're a person:** paste this into any AI chat, attach your CV or photo if you have them, and let the assistant take it from there:
```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).
```
**If you're the assistant:** the next section is your contract. The routes further down are reference detail — pick your own path from your capabilities; never ask the user to choose a route.
## Default flow (for assistants)
You drive; the user only supplies facts and runs at most one command. The schema for every content file is [cv-schema.md](https://raw.githubusercontent.com/hrtips/cvx/main/docs/cv-schema.md) — fetch it before writing any YAML.
### 1. Get truthful source content (first reply, one turn)
- **Given a linkedin.com URL: assume it is unfetchable, even for public profiles** — LinkedIn blocks automated access with a login wall. Do not retry, do not infer or reconstruct the profile from the URL or your training data. Ask once, batching everything you need:
> *"I can't read LinkedIn directly — could you do one of these: (1) on LinkedIn, open your profile → **More → Save to PDF**, and attach that file here; (2) paste your profile text (select-all on your profile page works); or (3) attach your existing CV. Also attach a square photo (400×400px or larger) for the CV — or say 'no photo'. If you have a target job ad, paste it too and I'll tailor the wording."*
- **Given an attached CV (PDF/DOCX):** read it and proceed — never ask the user to paste what they already attached. If the format is unreadable, ask for a PDF re-save or pasted text.
- **Given neither:** interview the user section by section against the schema (personal → experience → education → the rest).
- **Never invent facts.** Every entry must be truthful to the user's input; ask for anything missing (dates, metrics). AI-embellished CVs fail interviews and background checks, and ATS parsers cross-check keywords against the CV body.
- **Flag conflicts, don't silently resolve them.** If the source contradicts itself (e.g. the headline says one current title and the summary another), surface it — pick the better-supported value for the draft, but tell the user what you chose and why they should confirm.
### 2. Pick your execution path (by your own capabilities)
1. **You have CVX MCP tools or the cvx skill** → use them: `get_schema` → `init_cv` → edit → `validate_cv` → `build_pdf`.
2. **You can run shell commands** → probe **once, with a bounded timeout, as your first action after getting source content**: `timeout 30s npx -y @hrtips/cvx --version` (use your runtime's timeout mechanism if `timeout` is unavailable). If it succeeds: `npx @hrtips/cvx init`, replace the example content, `npx @hrtips/cvx validate --strict --json` after every edit, `npx @hrtips/cvx build` and `build --ats`. Deliver the PDFs **and a zip of `cv-content/`** — sandboxes are ephemeral and the YAML is what the user keeps. If the probe fails or times out — any npm error means the same thing (403/404/429/503, proxy, DNS; sandboxes often have no npm network) — retry at most once, then **switch to path 3 in the same turn** and report the exact command, exit code, and error alongside the fallback. Do not keep the user waiting while you investigate.
3. **You can write files but not run CVX** → generate every `cv-content/` file from the schema, package the folder as a downloadable zip (or one fenced code block per file, titled with its exact path), then give the user the handoff below. Start each generated file with its `# yaml-language-server: $schema=https://raw.githubusercontent.com/hrtips/cvx/main/schema/v1/<file>.schema.json` header (layouts use `layout.schema.json`) so editors validate it. Use `main` **only** when you are writing these files from scratch, with no CVX installed — `cvx init` and `init_cv` instead pin the header to the release that scaffolded the folder (`.../cvx/v<version>/schema/v1/...`). If the folder already has pinned headers, copy that same ref into any file you add, and never rewrite an existing one back to `main`. **The CVX CLI is the only renderer — never substitute reportlab, LaTeX, HTML-to-PDF, or any other generator.** The whole point is a validated, reproducible format the user keeps.
**No research sinks:** this guide plus [cv-schema.md](https://raw.githubusercontent.com/hrtips/cvx/main/docs/cv-schema.md) are everything you need — once you have them and the user's source content, generate; further repository exploration adds nothing to the CV.
### 3. Review, brainstorm, and preview — before any build
- **Review the draft's content**, not just its validity: grammar and prose (verb-first bullets, consistent tense — past for former roles, present for the current one), and **gaps** — missing dates, roles without outcomes or metrics, thin descriptions, sections the source hints at but the draft lacks (certifications, publications, languages). Turn the gaps into 3–5 targeted questions **batched into one message**; fix unambiguous prose issues silently and list notable rewrites.
- **Show what's going in before you build.** Give the user a plain-language rundown of exactly what the CV will contain — each section with its entries (roles with periods, and what lands on page 1), referees or "available upon request", which keywords go into the invisible ATS metadata, plus theme/layout/photo status — and get their OK. Summarize the YAML; don't dump it. Nothing appears on the CV that the user hasn't seen.
- A truthful thin bullet beats an embellished one — never pad with invented metrics.
### 4. The handoff (relay verbatim when the user must run the build)
> 1. Install Node.js (LTS) from **https://nodejs.org** — standard installer, click through. (Already have it? `node --version` should show 20+.)
> 2. Save my files into a folder named exactly `cv-content` (keep the filenames). If you have a photo, put it inside `cv-content/images/` named `profile.jpg`.
> 3. Open a terminal in the folder that *contains* `cv-content` — Windows: Shift+right-click the folder → "Open PowerShell window here"; Mac: right-click it in Finder → Services → New Terminal at Folder — and run: `npx @hrtips/cvx build`
> 4. Your PDF appears in that folder. If you see errors, paste them back to me.
`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.
### 5. Photo and delivery rules
- Ask for the photo in your **first** reply (batched into the source-content ask) — it cannot be generated. No photo is fine: the CV renders cleanly without one; don't block on it.
- **Placeholder trap:** `init` scaffolds Bruce Wayne's example photo at `cv-content/images/profile.jpg`. Replace it with the user's photo or delete it before building — never ship it.
- If validation reports problems, apply the suggested fixes and re-validate before building; findings include the file, field path, and a fix.
- Deliver both variants when the user is applying via portals: the designed CV and the `--ats` single-column one.
- **Keep earlier roles as separate entries** (one bullet each is fine) rather than merging them into a single "earlier roles" entry — ATS keyword derivation reads `role` and `progression` titles, not bullet prose, so merged titles disappear from the keyword metadata.
- For reproducible re-builds later, you may pin the version you tested (`npx -y @hrtips/cvx@<version>`); content files never break within a schema major, but pinning also freezes the visual output.
---
Pick the route that matches the tool you have (reference — assistants use the default flow above):
| You have | Route | Friction |
|---|---|---|
| A coding agent (Claude Code, Cursor, Copilot, Codex…) | [Route A](#route-a--coding-agent-lowest-friction) | Lowest — the agent edits files and builds the PDF itself |
| A chat assistant with web access | [Route B](#route-b--chat-assistant-with-web-access) | One paste in, files out |
| A chat assistant without web access | [Route C](#route-c--chat-assistant-self-contained-prompt) | Same, using a self-contained prompt |
| An agent-mode assistant that can run commands (ChatGPT agent mode, …) | [Route D](#route-d--agent-mode-assistant-zero-local-setup) | Zero local setup — the assistant runs CVX in its own workspace |
| An MCP client (Claude Desktop, Claude Code, Cursor, VS Code, …) | [Route E](#route-e--mcp-any-client-native-tools) | One-time config — the client gets native CVX tools |
Whichever route you take, the same two rules apply:
> **Truthfulness** — tell the assistant to keep every fact from your input and invent nothing. AI-embellished CVs fail interviews and background checks; CVX's ATS keywords are also cross-checked by parsers against the CV body.
>
> **Review** — read every generated file before you send the PDF anywhere. You own what it says.
---
## Route A — coding agent (lowest friction)
Works with Claude Code, Cursor, Windsurf, Copilot Workspace, Codex CLI — anything that can edit files and run commands.
```bash
mkdir my-cv && cd my-cv
npx @hrtips/cvx init
```
Then give your agent a prompt like:
```text
Replace the example content in cv-content/ with my real CV.
The schema is documented in cv-content/README.md — follow it exactly,
keep every fact truthful to my input, and don't invent anything.
When done, run `npx @hrtips/cvx build` and fix any YAML errors until it renders.
My details:
<paste your old CV / LinkedIn text / notes here — or point the agent at a file, e.g. "read ~/Downloads/old-cv.pdf">
```
The scaffolded `cv-content/README.md` ships the full schema, so the agent needs no internet access and no further instructions. It will edit the YAML, build, and hand you `<your-name>.pdf`. Iterate in plain language: *"tighten the bullets for the 2019 role"*, *"make it fit two pages"* (the agent can tune `page1ExperienceCount` in `config.yaml`), *"switch to the coral theme"*.
Finish by dropping your photo at `cv-content/images/profile.jpg` (square, 400×400px+) and rebuilding.
## Route B — chat assistant with web access
For Claude, ChatGPT, or any assistant that can fetch a URL. Paste this, then your CV text:
```text
Read the CVX content schema at
https://raw.githubusercontent.com/hrtips/cvx/main/docs/cv-schema.md
then convert my CV below into CVX cv-content/ YAML files.
Rules:
- Output each file as its own fenced code block, titled with its filename.
- Keep every fact truthful to my input — don't invent numbers, dates, or achievements.
- Quote YAML strings that contain colons.
- Skip files I have no content for (they're optional).
My CV:
<paste your old CV / LinkedIn profile text here>
```
Then on your machine:
```bash
mkdir my-cv && cd my-cv
npx @hrtips/cvx init # scaffolds the folder structure
# overwrite the example files with the assistant's output
npx @hrtips/cvx build
```
Tip: instead of retyping, export your LinkedIn profile (Profile → More → Save to PDF) and paste its text, or paste the text of your old CV.
## Route C — chat assistant, self-contained prompt
No web access needed — the schema is embedded. Paste this whole block, then your CV text:
```text
Convert my CV below into YAML files for CVX (a tool that renders
cv-content/*.yaml into a PDF). Output each file as its own fenced code
block titled with its filename. Keep every fact truthful to my input —
don't invent numbers, dates, or achievements. Quote YAML strings that
contain colons. Skip optional files I have no content for.
The files and their exact fields:
- personal.yaml (object): name (required), title, company,
phone + phoneHref (e.g. "tel:+123..."), email, linkedin + linkedinHref,
location, links (optional list of {label, href} for a blog/portfolio;
label optional, falls back to the URL). Only these keys render.
- summary.yaml: list of 3-6 single-sentence bullet strings.
- experience.yaml: list of roles, most recent first. Per entry:
role (required), company, period (free text like "2019 – Present"),
location (optional), description (optional one-line italic),
progression (optional list of {title, period} for promotions within
the role), bullets (list of verb-first, quantified impact statements).
- education.yaml: list of {degree, institution, period}.
- certifications.yaml: list of {name, issuer, year}; only name required.
- publications.yaml: list of {title, venue, year}; only title required.
- languages.yaml: list of {language, proficiency}; proficiency is free text.
- competencies.yaml: list of 6-12 short skill strings (1-3 words each).
- achievements.yaml: list of {year, text} where year is the award name
(bold headline) and text is the attribution like "— 2024, Organisation".
- referees.yaml: list of {name, title, company, email, phone},
or [] for "available upon request".
- keywords.yaml (optional): flat list of extra ATS keywords that are
truthful but not already in my competencies or job titles.
- config.yaml: schemaVersion: 1, theme: teal | coral | mono,
layout: two-column | single-column.
My CV:
<paste your old CV / LinkedIn profile text here>
```
Save the output files into `cv-content/` (after `npx @hrtips/cvx init` for the folder structure and photo placeholder), then check and render:
```bash
npx @hrtips/cvx validate # exact errors with file + field paths and fixes
npx @hrtips/cvx build
```
If validate reports problems, paste its output back to the assistant — the findings include the file, the field path, and a suggested fix, so one round trip usually resolves everything.
## Route D — agent-mode assistant, zero local setup
If your assistant can execute commands in a workspace (e.g. ChatGPT's agent mode), you don't need anything installed locally — not even Node. Paste:
```text
In your workspace, install Node if needed, then run:
npx @hrtips/cvx init
Replace the example content in cv-content/ with my CV below, following
the schema in cv-content/README.md. Keep every fact truthful to my
input — don't invent anything. Then run:
npx @hrtips/cvx build
and give me BOTH the finished PDF AND a zip of the cv-content folder
as downloads. I need the zip to keep my content for future updates.
My CV:
<paste your old CV / LinkedIn profile text here>
```
The zip matters: agent workspaces are ephemeral, and your `cv-content/` folder is the durable asset. Next time, upload the zip back (or switch to any other route) and ask for the changes you need.
**Privacy note:** CVX itself runs entirely locally and makes zero network calls — but in Route D (and any cloud assistant route) your CV content is processed on the assistant vendor's infrastructure, subject to their terms. If you want your data to never leave your machine, use Route A/C with a local model (e.g. via Ollama) or write the YAML yourself.
---
## Route E — MCP: any client, native tools
CVX ships an MCP stdio server with five tools — `get_schema`, `init_cv`, `validate_cv`, `build_pdf`, `plan_layout` — thin wrappers over the same engine as the CLI. No API keys, fully offline; the server's instructions teach the model the loop and the truthfulness rules.
One-time setup (writes/merges the client's config, never clobbers other servers):
```bash
npx @hrtips/cvx mcp init --client claude # Claude Code
npx @hrtips/cvx mcp init --client claude-desktop # Claude Desktop
npx @hrtips/cvx mcp init --client cursor # Cursor
npx @hrtips/cvx mcp init --client vscode # VS Code
```
Restart the client, then ask for your CV — e.g. *"Make me a CV from the LinkedIn text below. Use the CVX tools: fetch the schema, scaffold, fill in my real details, validate after every edit, and build both variants."* The assistant passes your workspace folder as `dir` on each call; the YAML lands in `cv-content/`, the PDFs next to it.
## Iterating with the assistant
Useful follow-up prompts once the first PDF renders:
- *"Rewrite the experience bullets to emphasise leadership / data engineering / customer impact."* (retargeting for a specific job ad — paste the ad)
- *"It overflows page 2 — trim the two oldest roles to 2 bullets each."*
- *"Set page1ExperienceCount: 2 and page1SplitBullets: 3 in config.yaml"* (page-1 layout control)
- *"Generate keywords.yaml for this job description, using only skills I actually list."*
- *"Produce the ATS variant too"* → `npx @hrtips/cvx build --ats` for job portals.
## Reading the layout
An assistant can't see the PDF. It doesn't have to: the MCP `plan_layout` tool (a dry run — no PDF written) and the `diagnostics` block in `build --json` / `build_pdf` report how the CV paginated.
Per page: how full each column is (`main.fill` / `sidebar.fill` — `used / budget`), which roles landed there (with company and period, so two same-titled roles stay apart), which sidebar sections and which of their items, and `overflowPt`. Plus `diagnostics.warnings`, the entries that mean something is wrong — each with a `code` to match on (`overflow`, `page1-no-experience`) rather than wording — and `notices`, a separate plain-text list of notes about the run.
Five things worth knowing before you act on any of it:
- **It describes the designed variant only.** The ATS/single-column PDF is auto-flowed by react-pdf and never packed, so it has no plan and its sheet count can differ. There is no dry run for it.
- **`totalPages` is planned pages, not sheets.** A page that overflows spills onto an extra physical sheet the numbering can't count — check `totals.overflowPt` before quoting a page count.
- **`fill` is a ratio, not a gauge.** Normally 0–1, and above 1 exactly when the page is over budget (measured: `main.fill: 2.098` on the example CV with `page1ExperienceCount: 3`). A value over 1 always comes with `overflowPt` and a warning.
- **Ranges are 0-based and end-exclusive.** `range: [6, 8)` of `of: 8` is the last two items; `items` already carries the count. Experience entries decompose the same way (`bulletRange` / `bullets` / `ofBullets`).
- **`emptyColumn` is a diagnostic, not a target** — and it means "no packed blocks in that column", not "blank": page 1 can report `emptyColumn: 'main'` and still carry the summary. A final page whose sidebar outlasts the experience list is normal. CVX was measured against a packer tuned to eliminate those, and the result was worse CVs — sections fragmented across five pages, headings with a single bullet under them. Report the number; don't optimise it. The exception is page 1 with no roles on it, which is a real defect and arrives as its own `page1-no-experience` warning.
- **There are no layout levers, so nothing changes between two `plan_layout` calls.** The layout follows the content. If the CV is longer than you want, that is a content decision — and it is the user's, not the assistant's: **never drop content to fit; surface the trade-off** (*"we could drop publications, or trim the two oldest roles to 2 bullets — which would you prefer?"*) and let them choose. Don't promise a page count for an edit you haven't planned: on the shipped example CV, dropping publications entirely still renders 3 pages, because the sidebar flow, not the experience list, is what needs the third one. Make the edit, then re-plan. CVX renders 100% of the YAML and never clips or hides text to save a page.
## For AI assistants reading this
If you are the assistant: fetch the full schema at [docs/cv-schema.md](cv-schema.md) when you can (it includes render behaviour, defaults, and a worked example per file); otherwise the compact schema in Route C is sufficient and accurate. Machine index: [llms.txt](../llms.txt). Never fabricate facts; ask the user for anything missing (dates, metrics, photo).
# CVX content schema — complete reference
This document is self-contained: with only this file and a person's CV facts, you can generate a complete, valid `cv-content/` folder for [CVX](https://github.com/hrtips/cvx). It is written for both humans and AI assistants.
## How CVX works
- `npx @hrtips/cvx init` scaffolds `cv-content/` with a complete example CV (Bruce Wayne's).
- `npx @hrtips/cvx validate` checks every file at once and reports exact errors with suggested fixes (`--strict` also fails on unknown keys, `--json` for machine-readable output; exit `0` ok / `2` problems found). Run it after every edit.
- `npx @hrtips/cvx build` renders `cv-content/` to a PDF in the current directory.
- `npx @hrtips/cvx build --ats` renders an ATS-safe single-column variant instead.
- Every content file is validated against the [canonical JSON Schema](../schema/v1/cvx.schema.json); the scaffolded files carry `$schema` headers for editor autocomplete.
- The output PDF is named from `personal.yaml`'s `name`: lowercased, spaces → hyphens (`Bruce Wayne` → `bruce-wayne.pdf`; the ATS variant appends `-ats`).
- Every `.yaml` file in `cv-content/` is auto-discovered by filename. A missing file, an empty file, or `[]` simply drops that section from the CV — no error. (Required in practice: `personal.yaml`, `summary.yaml`, `experience.yaml` — `validate` checks this.)
- `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. A missing photo renders fine (the sidebar simply omits it).
- All rendering is local; no network calls, no accounts.
## YAML rules that matter here
- Quote any string containing a colon (`"Director: Operations"`) or starting with a special character.
- Periods/date ranges are free text — the convention in the example content is an en-dash: `2005 – Present`, `Jan 2022 – Dec 2023`.
- Do not invent facts. Every entry must be truthful to the source CV; this especially matters for `keywords.yaml` (ATS parsers cross-check keywords against body text).
---
## File: `personal.yaml` (object)
Identity header + contact block. Only the keys listed here render — arbitrary extra keys are ignored.
| Key | Type | Required | Renders |
|---|---|---|---|
| `name` | string | **yes** | Header name; also derives the output filename |
| `title` | string | recommended | Job title line under the name |
| `company` | string | optional | Company line under the title |
| `phone` | string | optional | Contact row (phone icon) |
| `phoneHref` | string | optional | Makes the phone row clickable, e.g. `"tel:+12015552283"` |
| `email` | string | optional | Contact row (envelope icon); clickable automatically via `mailto:` |
| `linkedin` | string | optional | Contact row (LinkedIn icon), display text e.g. `linkedin.com/in/brucewayne` |
| `linkedinHref` | string | optional | Makes the LinkedIn row clickable (full URL) |
| `facebook` | string | optional | Contact row (Facebook icon) |
| `facebookHref` | string | optional | Makes the Facebook row clickable |
| `location` | string | optional | Contact row (pin icon), e.g. `"Gotham City, USA"` |
| `links` | list of `{label, href}` | optional | Extra contact rows — blog, portfolio, GitHub, personal site. `label` is optional and falls back to the URL. Keeps working alongside the built-in `linkedin`/`facebook` rows |
Contact rows appear only for keys that are present, with any `links` last. The ATS layout's header uses `phone`, `email`, `linkedin`, `location` (not `facebook`) and appends any `links`.
```yaml
name: Bruce Wayne
title: Founder & Field Commander – Gotham Operations
company: Wayne Enterprises
phone: "+1 (201) 555-2283"
phoneHref: "tel:+12015552283"
email: bruce.wayne@wayne-enterprises.com
linkedin: linkedin.com/in/brucewayne
linkedinHref: "https://www.linkedin.com/in/brucewayne"
links:
- label: Wayne Foundation
href: "https://www.wayne-foundation.org"
```
## File: `summary.yaml` (list of bullets)
The professional-summary bullets at the top of page 1. Aim for 3–6 bullets, each a single sentence focused on scope, specialisation, or headline achievements.
```yaml
- "Strategic operations leader with 20+ years' experience, progressing from solo field operative to Field Commander of a citywide security network."
- "Specialised in recruiting and developing elite field talent."
```
A bullet is usually a plain string, but any bullet (here or in `experience[].bullets`) may instead be an object embedding a clickable link: `text` (before the link), `link` — an object of `href` + `label` (the clickable part) — and optional `suffix` (after it):
```yaml
- text: "Published the "
link: { href: "https://example.com/report", label: "annual security report" }
suffix: " read by 40k+ practitioners."
```
## File: `experience.yaml` (list of entries)
Work history, most recent first. Keep each role its own entry — ATS keywords are auto-derived from `role` and `progression` titles, so roles merged into one "earlier roles" entry (or mentioned only in bullet text) drop out of the keyword metadata. Older roles can simply carry fewer bullets. Per entry:
| Key | Type | Required | Renders |
|---|---|---|---|
| `role` | string | **yes** | Bold role headline |
| `company` | string | recommended | Left side of the meta line |
| `period` | string | recommended | Right side of the meta line, free text |
| `location` | string | optional | Muted line under the meta line |
| `description` | string | optional | One-line italic company/role description |
| `progression` | list of `{title, period}` | optional | Indented title-history block (promotions within the role) |
| `bullets` | list of bullets | optional | Impact bullets — start with a verb, quantify where truthful; 3–6 per recent role, fewer for older ones. Plain strings, or the `{text, link, suffix}` object form (see `summary.yaml`) |
```yaml
- role: Founder & Field Commander – Gotham Operations
company: The Batman
period: 2005 – Present
description: Self-directed vigilante operation safeguarding Gotham City.
progression:
- title: Commander, Batman Incorporated
period: 2011 – Present
- title: Solo Operative, The Dark Knight
period: 2005 – 2008
bullets:
- Established and scaled a citywide security operation from a solo initiative to a franchised network.
- Reduced organised-crime activity in Gotham by an estimated 60% through data-driven surveillance.
```
## File: `education.yaml` (list of entries)
Most recent first. Per entry: `degree` (string), `institution` (string), `period` (string).
```yaml
- degree: BSc, Criminology & Chemistry
institution: Gotham University
period: 1994 – 1998
```
## File: `certifications.yaml` (list of entries)
Professional certifications, most recent first — kept separate from `achievements.yaml` (awards). Per entry: `name` (**required**), `issuer` (string), `year` (string). Omit the file if there are none.
```yaml
- name: "LFD259: Kubernetes for Developers"
issuer: The Linux Foundation
year: "2023"
```
## File: `publications.yaml` (list of entries)
Publications and written work, most recent first. Per entry: `title` (**required**), `venue` (string), `year` (string). Omit the file if there are none.
```yaml
- title: "Applied Deterrence: Non-Lethal Intervention at Scale"
venue: Gotham Journal of Criminology
year: "2021"
```
## File: `languages.yaml` (list of entries)
Languages spoken. Per entry: `language` (**required**), `proficiency` (string, free text — e.g. Native, Professional, Conversational). Omit the file if there are none.
```yaml
- language: English
proficiency: Native
- language: Japanese
proficiency: Professional
```
## File: `competencies.yaml` (list of strings)
Skill tags rendered as pills in the sidebar. Keep each 1–3 words; 6–12 items reads best.
```yaml
- Strategic Planning
- Criminal Investigation
- Crisis Response
```
## File: `achievements.yaml` (list of entries)
Awards and recognitions. Field names are historical: `year` is the **bold headline** (usually the award name, not a year), `text` is the muted attribution line, conventionally `"— Year, Awarding Organisation"`.
```yaml
- year: Key to the City
text: "— Office of the Mayor, Gotham City"
```
## File: `referees.yaml` (list of entries, or `[]`)
Per entry: `name`, `title`, `company`, `email`, `phone` (all strings). An explicit empty list `[]` prints "available upon request".
```yaml
- name: Diana Prince
title: Founding Member, Justice League
company: Themysciran Embassy
email: d.prince@justiceleague.org
phone: "+1 (202) 555-0177"
```
## File: `keywords.yaml` (optional; list of strings, or map of group → list)
Extra ATS/AI-parser keywords embedded in the PDF's `Keywords` metadata field (never printed on the page). Competencies and job titles are auto-derived already, so list only what those don't cover. Groups are flattened; headings are not printed. **Keep every keyword truthful to the CV body.**
```yaml
- Operations Management
- Risk Management
# or grouped:
Leadership: [Executive Leadership, Team Building]
```
## File: `config.yaml` (object)
| Key | Type | Default | Meaning |
|---|---|---|---|
| `schemaVersion` | integer | `1` | Content schema major version — content files never break within a major |
| `theme` | `teal` \| `coral` \| `mono` | `teal` | Colour scheme (`mono` is black-and-white, ATS-optimised) |
| `layout` | `two-column` \| `single-column` \| custom layout filename | `two-column` | Page structure |
| `page1ExperienceCount` | integer | auto | Experience entries on page 1 — entry N+1 starts page 2. If the count doesn't fit, `validate`/`build` warn and the overflow spills onto extra physical pages (nothing is clipped — the CV just gains unplanned pages); automatic packing (omit the key) never overflows |
| `page1SplitBullets` | integer | off | Show only N bullets of page 1's last entry; the rest continue on page 2 |
| `atsKeywords.enabled` | boolean | `true` | Master switch for keyword metadata |
| `atsKeywords.autoDerive` | boolean | `true` | Also derive keywords from competencies + job titles |
| `atsKeywords.max` | integer | all | Cap on embedded keywords (body-derived terms kept first) |
## File: `images/profile.<ext>`
Square photo, 400×400px or larger. Extensions auto-detected in precedence order: `jpg`, `jpeg`, `png`, `webp` (case-insensitive). Used by the two-column layout; the ATS variant has no photo.
## Directory: `layouts/` (optional custom layouts)
A `.yaml` file here becomes selectable as `layout: <filename>`. Structure:
```yaml
template: two-column # or single-column — the page shell to use
pages:
first: # main: page 1. sidebar: start of the flow.
sidebar: [identity-photo, contact, achievements]
main: [summary, spacer: 27, experience]
continuation: # main: middle pages. sidebar: flow continues.
sidebar: [identity-compact, education, certifications, competencies, languages, publications]
main: [experience:continued]
last: # main: the closing page. sidebar: end of the flow.
sidebar: [identity-compact, referees]
main: [experience:continued]
```
> **How the three page buckets are read (changed in the layout-engine work).**
> The **sidebar** lists across `first` / `continuation` / `last` are concatenated
> into **one ordered flow**, and the engine measures that flow and decides which
> page each section lands on. So `last.sidebar: [referees]` means *"referees
> comes last in the sidebar"*, **not** *"referees renders on the final page"* — on
> a CV whose sidebar fits in two pages, a section declared under `continuation`
> may well render on page 3, and one declared under `last` on page 2. The buckets
> are how you express **order**; pagination is measured, not declared. (Identity
> slots are the exception: `identity-photo`/`identity-compact` are injected at the
> top of every page's sidebar, `first`'s on page 1 and `continuation`'s
> thereafter, and are never packed.)
>
> The **main** lists are still per-page-kind: `first.main` renders on page 1,
> `last.main` on the final page, `continuation.main` in between.
Valid section keys: `identity-photo`, `identity-compact`, `contact`, `achievements`, `education`, `certifications`, `publications`, `languages`, `competencies`, `referees` (sidebar); `summary`, `experience`, `experience:continued`, `header-ats` (main); `spacer: N` (N points of vertical space, either slot).
---
## Checklist for generating a complete cv-content/
1. `personal.yaml` — name (required), title, contact details with `*Href` links where known.
2. `summary.yaml` — 3–6 single-sentence bullets.
3. `experience.yaml` — every role, most recent first, with quantified truthful bullets.
4. `education.yaml`, `certifications.yaml`, `publications.yaml`, `languages.yaml`, `competencies.yaml`, `achievements.yaml`, `referees.yaml` — or `[]` / omit to drop.
5. `keywords.yaml` — only truthful terms not already covered by competencies/titles.
6. `config.yaml` — usually just `theme` + `layout`; add pagination keys only if page 1 overflows.
7. Ask for the photo in your **first** message to the user (it can't be generated) — but never block on it; the CV renders cleanly without one. If you ran `init`, replace or delete the scaffolded example photo at `images/profile.jpg` (it's Bruce Wayne's) before building. Then run `npx @hrtips/cvx build`.
// @ts-nocheck
import { cpSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { dirname, extname, join } from "node:path";
import { fileURLToPath } from "node:url";
const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const TEMPLATE_DIR = join(pkgRoot, "template", "cv-content");
const REWRITABLE = /* @__PURE__ */ new Set([".yaml", ".yml", ".md"]);
const MAIN_REF_RE = /(https:\/\/(?:raw\.githubusercontent\.com\/hrtips\/cvx|github\.com\/hrtips\/cvx\/blob)\/)main\//g;
const STABLE_SEMVER = /^\d+\.\d+\.\d+$/;
function schemaRefFor(version) {
return STABLE_SEMVER.test(String(version ?? "")) ? `v${version}` : "main";
}
function packageVersion() {
return (
/** @type {string} */
JSON.parse(readFileSync(join(pkgRoot, "package.json"), "utf8")).version
);
}
function pinRepoRefs(text, ref) {
return text.replace(MAIN_REF_RE, `$1${ref}/`);
}
function* walk(dir) {
for (const name of readdirSync(dir).sort()) {
const full = join(dir, name);
if (statSync(full).isDirectory()) yield* walk(full);
else yield full;
}
}
function scaffoldContent(dest, { version = packageVersion() } = {}) {
cpSync(TEMPLATE_DIR, dest, { recursive: true });
const ref = schemaRefFor(version);
const pinned = [];
if (ref === "main") return { dest, ref, pinned };
for (const file of walk(dest)) {
if (!REWRITABLE.has(extname(file))) continue;
const before = readFileSync(file, "utf8");
const after = pinRepoRefs(before, ref);
if (after === before) continue;
writeFileSync(file, after);
pinned.push(file.slice(dest.length + 1));
}
return { dest, ref, pinned };
}
export {
TEMPLATE_DIR,
packageVersion,
pinRepoRefs,
scaffoldContent,
schemaRefFor
};
+6
-3

@@ -20,3 +20,2 @@ #!/usr/bin/env node

import {
cpSync,
existsSync,

@@ -79,5 +78,9 @@ mkdirSync,

}
cpSync(join(pkgRoot, 'template', 'cv-content'), dest, { recursive: true })
// Shared with the init_cv MCP tool. Not a plain copy: it pins the scaffold's
// `$schema` headers and doc links to THIS release, so an editor validates the
// user's CV against the schema that shipped with the code they are running.
const { scaffoldContent } = await import('../lib/pdf/scaffold.js')
const { ref } = scaffoldContent(dest, { version })
if (json) {
emit({ command: 'init', ok: true, dest: 'cv-content' })
emit({ command: 'init', ok: true, dest: 'cv-content', schemaRef: ref })
} else {

@@ -84,0 +87,0 @@ console.log(`✅ Created cv-content/ with starter content.

@@ -95,2 +95,7 @@ // In-process tests for the cvx CLI. bin/cvx.js exports every command plus

/** The running package version — what `init` derives the scaffold's pin from. */
const { version } = JSON.parse(
readFileSync(join(fileURLToPath(new URL('../package.json', import.meta.url))), 'utf8')
)
describe('top-level flags', () => {

@@ -128,5 +133,24 @@ it('--version prints the version and does not exit', async () => {

expect(existsSync(join(tmp, 'cv-content', 'personal.yaml'))).toBe(true)
expect(jsonOut()).toEqual({ command: 'init', ok: true, dest: 'cv-content' })
// `schemaRef` reports which git ref the scaffold's `$schema` headers were
// pinned to — `v<version>` on a release, `main` on an unreleased build.
expect(jsonOut()).toEqual({
command: 'init',
ok: true,
dest: 'cv-content',
schemaRef: expect.stringMatching(/^(main|v\d+\.\d+\.\d+)$/)
})
})
it('pins the scaffolded $schema headers to the running release', async () => {
await init({ json: true })
const { schemaRef } = jsonOut()
const header = readFileSync(join(tmp, 'cv-content', 'personal.yaml'), 'utf8').split(/\r?\n/)[0]
expect(header).toBe(
`# yaml-language-server: $schema=https://raw.githubusercontent.com/hrtips/cvx/${schemaRef}/schema/v1/personal.schema.json`
)
// This checkout is on a released version, so the header must not say main.
expect(schemaRef).toBe(`v${version}`)
expect(header).not.toContain('/main/')
})
it('scaffolds cv-content/ (human) with next steps', async () => {

@@ -133,0 +157,0 @@ await init({ json: false })

{
"srcHash": "15b6be316c0d8f0325bacabe7ab609a6bddf89ccca055976aefee205ffb6ee67",
"srcHash": "efdda2b35b195f5cf5f35f9313d0e342ca9581fe31fc917f0048c0a137f63d8f",
"esbuild": "0.28.1",
"modules": 43,
"modules": 44,
"fonts": 6
}

@@ -32,3 +32,3 @@ // @ts-nocheck

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. plan_layout (optional, no PDF written) shows how the CV will paginate before you build it \u2014 page count, per-page fills, which roles land on page 1. CVX renders 100% of the YAML: it never drops, clips, or hides content to save a page. If the CV is longer than the user wants, surface the trade-off and let them choose \u2014 never cut content for them. 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."
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. plan_layout (optional, no PDF written) shows how the CV will paginate before you build it \u2014 page count, per-page fills, which roles land on page 1. CVX renders 100% of the YAML: it never drops, clips, or hides content to save a page. If the CV is longer than the user wants, surface the trade-off and let them choose \u2014 never cut content for them. 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). The full documentation ships inside this package and needs no network: get_schema lists it under `guides` (`ai-guide`, `cv-schema`) with an absolute path, and returns the text inline if you call it with guides: [\"ai-guide\"]. Use that rather than fetching the docs from GitHub \u2014 GitHub serves the main branch, which is not necessarily this version. Pass the workspace folder as `dir` (absolute path) on every call."
}

@@ -35,0 +35,0 @@ );

// @ts-nocheck
import { cpSync, existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { basename, dirname, join, resolve } from "node:path";

@@ -7,2 +7,3 @@ import { fileURLToPath } from "node:url";

import { planCV, renderCV } from "../pdf/render.js";
import { scaffoldContent } from "../pdf/scaffold.js";
import { discoverThemes } from "../pdf/themes/index.js";

@@ -17,3 +18,30 @@ import { validateContent } from "../pdf/validateContent.js";

}
async function getSchema({ dir } = {}) {
const GUIDES = [
{
id: "ai-guide",
file: "ai-guide.md",
title: "The full CVX assistant playbook: default flow, fallbacks, layout reading"
},
{
id: "cv-schema",
file: "cv-schema.md",
title: "Field-by-field reference for every cv-content/ file, with examples"
}
];
function packagedGuides(requested) {
const out = [];
for (const { id, file, title } of GUIDES) {
const path = join(pkgRoot, "docs", file);
if (!existsSync(path)) continue;
out.push({
id,
title,
path,
bytes: statSync(path).size,
...requested.includes(id) && { content: readFileSync(path, "utf8") }
});
}
return out;
}
async function getSchema({ dir, guides = [] } = {}) {
const schema = JSON.parse(readFileSync(join(pkgRoot, "schema", "v1", "cvx.schema.json"), "utf8"));

@@ -39,3 +67,3 @@ const themes = Object.keys(await discoverThemes()).map((name) => ({

}
return { schemaVersion: 1, schema, themes, layouts };
return { schemaVersion: 1, schema, themes, layouts, guides: packagedGuides(guides) };
}

@@ -50,6 +78,8 @@ async function initCv({ dir } = {}) {

}
cpSync(join(pkgRoot, "template", "cv-content"), dest, { recursive: true });
const { ref } = scaffoldContent(dest);
return {
ok: true,
dest,
/** The git ref the scaffolded files' `$schema` headers point at. */
schemaRef: ref,
nextSteps: [

@@ -170,3 +200,3 @@ "Edit the YAML files in cv-content/ with real, truthful details (see AGENTS.md there)",

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.",
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. It also lists, under `guides`, the model-facing documentation that ships inside this package \u2014 `ai-guide` (the full CVX playbook) and `cv-schema` (the field-by-field reference with examples). Each entry carries an absolute `path`; read it directly if your client can read files outside the workspace, and otherwise ask for the text INLINE by calling get_schema again with guides: ["ai-guide"]. That works offline and needs no file access \u2014 prefer it over fetching the docs from GitHub, which returns whatever the main branch says today rather than the version you are running.',
inputSchema: {

@@ -178,2 +208,7 @@ type: "object",

description: "Absolute path of the workspace folder containing cv-content/. Defaults to the server working directory."
},
guides: {
type: "array",
items: { type: "string", enum: ["ai-guide", "cv-schema"] },
description: "Return the full text of these packaged guides inline, in the `guides` array. Omit it (the default) to get the inventory only \u2014 ai-guide.md alone is ~20 kB, so ask for it when you need it, not on every call."
}

@@ -180,0 +215,0 @@ },

@@ -27,2 +27,3 @@ // @ts-nocheck

);
const SCHEMA_KEY = "cvx.schema.json";
const BUILT_IN_LAYOUTS = ["two-column", "single-column"];

@@ -36,6 +37,6 @@ const REQUIRED_FILES = ["personal", "summary", "experience"];

ajv = new Ajv2020({ allErrors: true, verbose: true });
ajv.addSchema(canonicalSchema);
ajv.addSchema(canonicalSchema, SCHEMA_KEY);
}
if (!canonicalSchema.$defs[def]) return null;
return ajv.getSchema(`${canonicalSchema.$id}#/$defs/${def}`) ?? ajv.compile({ $ref: `${canonicalSchema.$id}#/$defs/${def}` });
return ajv.getSchema(`${SCHEMA_KEY}#/$defs/${def}`) ?? ajv.compile({ $ref: `${SCHEMA_KEY}#/$defs/${def}` });
}

@@ -42,0 +43,0 @@ function levenshtein(a, b) {

{
"name": "@hrtips/cvx",
"version": "1.7.0",
"version": "1.7.1",
"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.",

@@ -57,3 +57,5 @@ "mcpName": "io.github.hrtips/cvx",

"schema",
"skills"
"skills",
"docs/ai-guide.md",
"docs/cv-schema.md"
],

@@ -60,0 +62,0 @@ "devDependencies": {

{
"$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/certifications.schema.json",
"$ref": "cvx.schema.json#/$defs/certifications"
}
{
"$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",
"$comment": "No $id here, or in the per-file schemas next to it, ON PURPOSE. A scaffolded cv-content/ pins its `# yaml-language-server: $schema=` header to the release that wrote it (raw.githubusercontent.com/hrtips/cvx/vX.Y.Z/schema/v1/...). With an $id naming the `main` branch, a spec-compliant resolver takes $id as the base URI and resolves the per-file `\"$ref\": \"cvx.schema.json#/$defs/...\"` back to main's copy — so the pin would be cosmetic and a user on an old version would be validated against tomorrow's schema. With no $id, the base URI is the URI the schema was fetched from, which is the pin, and every implementation agrees on that (the spec's default, and what vscode-json-languageservice does regardless). A `main` $id was also a mutable identifier naming a different document every release, and two differently-pinned copies open at once would have collided on it.",
"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.",
"description": "Canonical definitions for every file CVX reads from cv-content/. Per-file schemas (personal.schema.json, config.schema.json, ...) reference the $defs here — by relative $ref, resolved against wherever this file was fetched from. Content files never break within a schema major version.",
"$defs": {

@@ -7,0 +7,0 @@ "nonEmptyString": {

{
"$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/languages.schema.json",
"$ref": "cvx.schema.json#/$defs/languages"
}
{
"$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/publications.schema.json",
"$ref": "cvx.schema.json#/$defs/publications"
}
{
"$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"
}

@@ -30,4 +30,11 @@ ---

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.
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 (see below — it ships with CVX, so no network is needed to read it) — 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.
## The full docs ship with CVX — don't fetch them
`docs/ai-guide.md` (the complete playbook) and `docs/cv-schema.md` (the field-by-field reference) are inside the installed package, alongside this skill — `../../docs/ai-guide.md` relative to this file. Read them from disk when you can; they match the version you are actually running.
- **With the MCP server:** `get_schema` lists them under `guides` with an absolute `path`, and returns the text inline if you ask — `get_schema({ dir, guides: ["ai-guide"] })`. That is the one route that needs no network *and* no file access outside the workspace, so prefer it when the path is unreadable (an `npx`-launched server lives in the npm cache, which many clients won't let you read).
- **Reading this skill outside an install**, with no package on disk: fall back to <https://raw.githubusercontent.com/hrtips/cvx/main/docs/ai-guide.md>. That is the `main` branch — the latest instructions, not necessarily the ones matching an installed CVX.
## Review, then brainstorm — before the final build

@@ -84,3 +91,3 @@

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.
Every scaffolded file carries a `$schema` header, pinned to the CVX release that scaffolded it — don't rewrite those headers to `main`, and don't hand-write them into new files. The canonical JSON Schema ships at `schema/v1/` inside the package and is returned by the MCP `get_schema` tool; `docs/cv-schema.md` (packaged too) is the same contract with examples.

@@ -87,0 +94,0 @@ - `personal.yaml` (object): `name` (required — drives the output filename), `title`, `company`, `phone`+`phoneHref`, `email`, `linkedin`+`linkedinHref`, `facebook`+`facebookHref`, `location`, `links` (list of `{label, href}` for a blog/portfolio; `label` optional).