@coursefoundry/cli
v0.9.0
Published
Local CLI for CourseFoundry - manage and sync your courses from the terminal.
Maintainers
Readme
@coursefoundry/cli - the cf command
A local CLI for CourseFoundry. Manage and (soon)
sync your courses from the terminal, authenticated with a cfry_ API key - no
GitHub required.
Status: Phases 1–5 - auth, read-only commands, full local folder sync (
clone/status/pull/push), local Quarto preview, and authoring commands (publish,new lesson,workflow run,watch).
Install
Requires Node 20+. The package is published to npm as @coursefoundry/cli:
npm i -g @coursefoundry/cli # installs the `cf` command
cf --versionOr run it without installing:
npx @coursefoundry/cli whoamiUpgrade with the same npm i -g command. When a cf login or cf whoami finds
that your copy is older than the oldest version CourseFoundry still supports, it
prints one line naming the install command; nothing is refused.
Development
From a checkout of this repository, in cli/:
npm install
npm run build
npm link # makes your local build the `cf` on your PATHDuring development you can run without building:
npm run dev -- whoaminpm test runs the CLI's own vitest suite, and npm pack --dry-run shows exactly
what a release would ship (dist/, README.md and package.json, nothing else).
A release is cut by tagging cli-v<version> on main; see
docs/deploy.md.
Authenticate
cf login # browser sign-in (device flow) - opens CourseFoundry to approve
cf login --no-browser # prints the URL + code instead of opening a browser
cf login --scope read # request a read-only key
cf login --force # re-authenticate even if already logged in
cf login --key cfry_xxx # skip the browser; store a key you already have
CF_API_KEY=cfry_xxx cf whoami # one-off, no stored loginBrowser sign-in (cf login with no --key): the CLI shows a short code and opens
CourseFoundry's /cli-auth page. You approve there, and a cfry_ key is minted and
returned to the CLI automatically - no copy-pasting. If the server doesn't support the
device flow, cf login falls back to pasting a key (create one under Settings → MCP
API Keys).
Key hygiene - three things keep keys from piling up:
cf loginis idempotent: if a valid login is already stored it does nothing (use--forceto deliberately re-authenticate).- Keys are named per device (
cf CLI · <hostname>); signing in again on the same machine revokes the previous key for that device, so you keep one active key per machine rather than one per login. cf logoutrevokes the key on the server (not just locally). Usecf logout --localto keep the server key and only forget it on this machine.
The key is stored at ~/.coursefoundry/config.json (chmod 600).
Commands
Account
| Command | Description |
|---|---|
| cf login | Save and verify a cfry_ API key |
| cf logout | Remove the stored key |
| cf whoami | Show the authenticated account (plan, level, XP, credits) |
| cf courses list [--status draft\|published\|all] | List your courses |
Sync (run inside a cloned course folder)
| Command | Description |
|---|---|
| cf clone <id\|slug> [dir] [--force] | Download a course into a local folder + write the sync manifest |
| cf status [--offline] | Show what changed locally vs on the server. --offline compares with the last sync only (no login, no network) and lists what cf push will send; plain cf status falls back to that view, after one warning line, when the server is unreachable |
| cf pull [--force] | Bring server changes down; --force overwrites locally-conflicting files |
| cf push [--ours\|--theirs\|--force] | Send local changes up; refuses on conflict unless a resolution flag is given |
| cf check [--ops <file>] | Validate the clone offline - no login, no network: what cf push would refuse (invalid paths, images over 3 MB, oversize text, files the sync ignores), what a render or an AI edit would trip on (missing frontmatter, _quarto.yml links to missing files, image references that resolve to no file, a locally edited specification.md), and with --ops a dry run of a harness-op batch. See below |
| cf render [--native] [--no-standalone] [--offline] [--open] [--tier t] [--engine e] [--only p...] | Render the clone locally in the pinned CourseFoundry container (or with your Quarto); cf render doctor / pull / clean beside it |
| cf preview [--no-quarto] [--no-extensions] | Live local preview via a local Quarto install (graceful fallback if absent). First installs any catalogue extension the course's decks/pages reference but _extensions/ lacks (codefrag, tabset, fragmention, cascade, webr, pyodide), at the same pins the CI render uses |
Image size limit: each image (.jpg/.jpeg/.png/.webp/.gif/.svg) may be at most 3 MB. Before uploading anything, cf push checks every added or modified image (and, with --ours/--force, every conflicting one it would send); if any is over the limit it uploads nothing and fails with image_too_large, listing each oversize image with its size. Compress or resize them and push again. Other files keep their existing limits.
Never synced, in either direction: Quarto build output (_output/), the generated CI workflows (.github/workflows/publish.yml and render.yml), catalogue extensions installed as _extensions/<owner>/<name>/, and coding-agent guidance (AGENTS.md, AGENTS.local.md, CLAUDE.md, CLAUDE.local.md at any depth, and anything under .claude/). cf status lists any such files still stored on the server so they can be deleted in the web editor; your own extensions and workflows keep syncing.
Import (run inside a cloned course folder)
| Command | Description |
|---|---|
| cf import <path...> | Convert local files into course content. Accepts files, directories (recursive), and glob patterns |
| --into <dir> | Destination directory inside the course (default: course root) |
| --dry-run | Show the plan and exit without writing |
| --overwrite | Replace files that already exist (default: skip them) |
| -y, --yes | Apply without asking for confirmation |
| --ai | Send extracted text to the AI import workflow for structuring (uses AI credits) |
| --push | Push the imported files after a successful import |
| --title <title> | Lesson title; single file only |
Authoring
| Command | Description |
|---|---|
| cf publish [--off] | Publish, or unpublish with --off |
| cf new lesson <path.qmd> [--title] | Create a lesson on the server and sync it locally |
| cf workflow run <type> [...] | Run an AI workflow (outline, lesson, slides, assessment, review, import, suggest_outcomes) |
| cf prompt [message] [-y] | AI chat about the current course (uses AI credits). See below |
| cf watch [--ours\|--theirs\|--force] | Auto-push local changes as you edit (debounced) |
| cf agents init [--claude] [--codex] [--force] | Write coding-agent guidance into the clone (both agents by default; existing files are skipped unless --force). Local only - never synced or published. See below |
| cf placeholders list [--unresolved] | List the course's image placeholders: id prefix, label, type, size, status, reference count, suggested image path and prompt. --json gives full ids and every reference |
| cf placeholders apply <id> <image> / --map <file> [-y] | Upload local image(s), replace the placeholder(s) everywhere they are used, then pull the rewritten files. See below |
Prompt mode (cf prompt)
Run inside a cloned course folder. With a message it's a one-shot question; without
one it opens an interactive chat session (/help, /clear, /exit):
cf prompt "what does week 3 cover?"
cf prompt "add a lesson on error handling to week 4"
cf prompt # interactiveIt's the same assistant as the web editor's Chat panel: the AI sees the course
structure, can read course files, and proposes file changes as an ops batch
instead of applying them. The CLI shows the batch, asks for confirmation
(-y/--yes skips the prompt), applies it server-side, and then pulls so the
local clone matches. Files that also changed locally are left untouched and
reported as conflicts - resolve them with cf pull --force or cf push.
If any of the course's enabled skills shaped the answer, the reply is followed by a line naming them and where each came from:
Skills used: deck-doctor (built-in), house-style (yours)Nothing is printed on a turn where no skill was loaded. --json carries the
same information as skillsUsed (null when none).
The AI can also propose changes to a single slide of a RevealJS deck rather than rewriting the whole file. Those lines name the deck and the slide position - counted from 1, in the deck's numbering before any of the batch is applied:
~ edit slides/week01.qmd slide 4
+ insert slides/week01.qmd new slide at position 4, after slide 3
- delete slides/week01.qmd slide 3, removed from the deck
→ reorder slides/week01.qmd slide 1 moves to position 3 once it has been lifted outA reorder's destination counts the deck with the moved slide already lifted out,
so [A,B,C] moved 0 -> 2 gives [B,C,A].
--json works in one-shot mode only and never prompts: without --yes the
proposed batch is returned in data.harnessOps but not applied.
Using an AI coding agent (cf agents init)
Run cf agents init inside a cloned course to tell a coding agent how the course works:
- Claude Code (
--claude):.claude/CLAUDE.mdplus acoursefoundry-placeholdersskill at.claude/skills/coursefoundry-placeholders/SKILL.md, which Claude Code loads when you ask it to fill image placeholders or add course images. - Codex (
--codex): a rootAGENTS.md.
With neither flag it writes both. The guidance covers the course path rules (letters, digits,
- _ . /, no spaces), images as course files under images/, the 3 MB image limit, and the
placeholder workflow: cf placeholders list --json, produce the images under images/, then
cf placeholders apply --map <file> --yes. It also tells the agent never to edit placeholder URLs by
hand and not to run cf watch during a run.
A file that already exists is left unchanged and reported; --force overwrites it. --json reports
{ written, skipped, warnings }. The guidance files never leave your machine: cf push/pull skip them in
both directions, cf status lists any copy already stored on the server, and cf clone never writes
them. Quarto 1.9 and later does not render AGENTS.md/CLAUDE.md as pages; on an older Quarto,
cf agents init and cf preview warn that a root AGENTS.md/AGENTS.local.md/CLAUDE.md/CLAUDE.local.md
will show as a local preview page (it is still never published) and suggest upgrading. The
quarto preview command itself is unchanged.
Filling image placeholders (cf placeholders)
cf placeholders list shows every image placeholder in the course; --unresolved hides the ones
already replaced, and --json returns the full records (ids, prompts, briefs, pixel sizes, every
referencing file and line, and a suggested path under images/).
cf placeholders apply replaces placeholders with images you have put in the clone, in one step:
cf placeholders list --json > /tmp/placeholders.json # an agent reads this
# ... produce images/pump-diagram.svg and images/valve-photo.png ...
cat > /tmp/fill.json <<'JSON'
{ "replacements": [
{ "placeholder": "3f2a9c1e", "image": "images/pump-diagram.svg" },
{ "placeholder": "8b0d4e77-5c1a-4f0e-9a2b-1c3d5e7f9a0b", "image": "images/valve-photo.png" }
] }
JSON
cf placeholders apply --map /tmp/fill.json --yes
cf placeholders apply 3f2a9c1e images/pump-diagram.svg # a single pair, asks first- Ids are the full id or a unique prefix of at least 8 characters; an unknown or ambiguous one
stops with
usagebefore anything is uploaded. - Image paths are course paths, relative to the clone root (the same form as
suggested_path). A path relative to the current directory, or an absolute one, is also accepted when it lands inside the clone. The map file can live anywhere and is read relative to the current directory - keep it outside the clone, since a.jsoninside would be pushed as a course file. - Every image is checked first - inside the clone, course path rules, a supported type
(
.png.jpg.jpeg.webp.gif.svg), at most 3 MB, and a regular file cf actually syncs (not a symbolic link, and not inside a folder cf skips such as_freeze/,_site/or.quarto/). Any failure stops the run withimage_invalid, naming each image and the rule, and nothing is uploaded. - Confirmation: the plan is shown and you are asked before anything changes. Without a terminal,
or with
--json,--yesis required; otherwise it stops withconfirmation_requiredbefore any server call. - Upload goes through the normal sync engine, but only for the chosen images - other local edits
stay unpushed. An image that also changed on the server stops the run with
push_conflictbefore anything is replaced. - Replacement rewrites every reference to each placeholder (lessons, slides,
_quarto.yml, notebooks) to a path relative to the file, all pairs together or none. If CourseFoundry refuses, every failing pair's reason is printed (placeholder_refused); the images stay uploaded, so a corrected retry does not send them again. Under--jsonthe error envelope also carriesreasonandfailures([{ placeholder_id, image_path, code, message }]) besidecodeandmessage. - Pull: the rewritten files are then pulled. A file you also edited locally is left untouched
and reported as a conflict, with the commands that resolve it.
--jsonreturns{ uploaded, resolve, pulled }.
Checking a clone offline (cf check)
cf check reads the clone and reports, without logging in or touching the network:
| Severity | Code | Meaning |
|---|---|---|
| error | path_invalid | A path the server refuses: anything outside letters, digits, - _ . / (so no spaces), a leading slash, ./.. segments, over 500 characters |
| error | image_too_large | An image over the 3 MB limit (cf push refuses it too) |
| error | content_too_large | A text file over the server's 15,000,000-character limit |
| info | not_synced | An extension the sync never uploads (.txt, .pdf, no extension…) - cf push ignores it |
| warning | frontmatter | A .qmd/.md without a YAML block, with an unclosed one, or without title: - the same sentence the AI harness gives |
| warning | sidebar_href_missing | An href: in _quarto.yml that names no file in the clone - the render drops it |
| warning | image_missing | A markdown image (, with or without {...}) or raw <img src> in a .qmd/.md that names no file in the clone, resolved relative to the referencing file (or to the course root when it starts with /), case-exact since CI renders on Linux. The website render shows a broken image; the PDF render fails on it. URLs, data: URIs and anchors are ignored, as are fenced code blocks and platform placeholder addresses |
| warning | managed_modified | The root specification.md differs from the last sync: Programme Designer regenerates it and local edits are overwritten |
Managed paths (_output/, the generated workflows, catalogue extensions, agent guidance) are skipped exactly as the sync skips them.
Exit status is 1 only when an error finding exists - what the server would refuse. Warnings and infos never fail the run.
cf check --ops <file> additionally dry-runs a harness-op batch against the clone with the file-op rules the server applies
(batch_limit, protected_path, invalid_path, collision, unexpanded_intent, plus frontmatter warnings). The file may be a bare
{ "ops": [...] }, the { "harnessOps": ... } payload, or a whole cf prompt --json envelope (it reads data.harnessOps). Slide, plan,
hidden, theme, sidebar and git ops are reported as not_checked - the server validates those on apply. Keep the file outside the clone,
since a .json inside would be pushed as a course file.
cf check
cf prompt "tidy week 3" --json > /tmp/proposal.json && cf check --ops /tmp/proposal.json
cf check --json # ok:true with data.findings, or ok:false code check_failed with the same findings in errorA clean cf check is not a promise that a push succeeds: storage quota, entitlement and conflicts are decided online by the server, and
_brand.yml values are validated there only. Every rule here is a mirror of the server's or core's, pinned by drift tests in cli/.
Working offline
Nothing in the CLI needs a connection except talking to the server: cf check and
cf status --offline run entirely locally, and editing a clone needs nothing at all.
When a command that does need the server cannot reach it, it says so in one line
(naming the server, noting that local edits are safe, and pointing at cf status --offline)
and exits with the offline error code under --json - never a stack trace, and
never a hang: every request has a timeout (5 minutes, since AI turns can be long).
cf status --offlinecompares the clone with the manifest written at the last sync, so it lists exactly the adds, edits and deletescf pushwill send when you reconnect. It says which sync time it compared against; server-side changes are unknown until then.cf pull/cf pushoffline stop with that one line.cf promptprobes the server (5 s) before any turn and refuses offline: the AI runs only on the server, never on your machine.- Reconnecting:
cf pushruns the usual three-way diff, so a day of offline edits goes up cleanly and only a file changed on both sides since the last sync is reported as a conflict (--ours/--theirsresolve it per file).
Rendering locally (cf render)
cf render # render the clone in the pinned CourseFoundry container
cf render --native # no container: Quarto on your PATH (NOT parity - it says so)
cf render --no-standalone # skip the per-resource HTML+PDF pass (much faster)
cf render --offline # use the last prepared tree; no network
cf render --open # open the rendered site when done
cf render doctor # engine, image, disk, cached tree, Quarto on PATH
cf render pull # pre-pull the image (0.5-1 GB the first time)
cf render clean # delete the output, the cached tree and the cache volumeWhat it renders is what the cloud renders. The clone's files are sent to
POST /api/courses/<id>/render-tree, where CourseFoundry applies the same
push-time transforms the commit route applies (brand merge, deck options, the
_quarto.yml patch, the hidden-file manifests) and answers only what changed.
That overlay goes onto a staged copy of the clone, which is rendered in the
cf-render container image pinned by digest - the exact Quarto the cloud
uses. Your files are never written: the output lands in
.coursefoundry/render/out/ (rendered/, standalone/, render-report.json),
the cached tree in .coursefoundry/render/tree.json, and the stage under your
OS temp directory (Quarto renders nothing from inside a hidden folder, so the
stage cannot live in .coursefoundry/). Nothing under .coursefoundry/ is
ever synced.
Needs Docker Desktop (or Podman) running; cf render doctor tells a
stopped engine from a missing one. Without an engine, --native renders with
the Quarto on your machine and prints a not-parity banner (and a version
warning against the pin); the standalone pass runs only in the container.
--offline renders from the last prepared tree and lists every file that
changed since - those render unpatched. --only <files> is --native only.
Exit status is 0 iff the site rendered; the standalone pass is best-effort,
exactly as in CI. --json emits the report (render_failed, no_engine,
no_image, no_render_cache as error codes).
A long run is visibly alive (0.9.0+). The container streams Quarto's
[n/N] path lines as the site renders and one [i/N] path html ok 4.1s pdf
ok 12.3s line per resource through the standalone pass; whenever nothing has
printed for 5 s the CLI shows rendering… 40 s on stderr - redrawn in place
on a terminal, one line per interval when piped - and clears it when the run
ends. All of it is stderr: under --json stdout is still exactly one
envelope. The per-file table at the end is unchanged. (The streaming needs
the render-image-v2 image or later; against an older pin only the heartbeat
shows.)
Machine-readable output
Add --json to status, pull, push, import, check, agents init, or placeholders list/apply. Every response is a single
document on stdout wrapped in a versioned envelope, successes and failures alike:
{ "schema": "cf-cli/1", "command": "import", "ok": true, "data": { } }
{ "schema": "cf-cli/1", "command": "import", "ok": false,
"error": { "code": "confirmation_required", "message": "…" } }Some errors add structured fields beside code and message (currently placeholder_refused:
reason, failures; check_failed: findings, counts, checkedFiles); ignore keys you do not know.
Error codes: not_in_clone, no_sources, confirmation_required, not_authenticated,
write_failed, extractor_unavailable, push_conflict, image_too_large, image_invalid,
placeholder_refused, check_failed, offline, usage, internal.
Under --json the CLI never prompts, so import and placeholders apply additionally require --yes to
apply. whoami and courses list still emit their own bare --json shapes.
Breaking change in 0.5.0:
cf status --jsonpreviously emitted its payload at the top level. It is now wrapped in the envelope above - readdata.changesrather thanchanges.
Importing local files
cf import converts material on your machine into course files. Everything happens
locally: nothing is uploaded unless you pass --ai or --push.
With --ai, the number of files to be sent is reported before anything is spent. If
structuring fails for a file, the import keeps that file's raw extraction and says so
(! file.md: AI enrichment failed …; kept the raw extraction.) - a fallback is never
silent, so a success line always means what it says.
| Source | Produces |
|---|---|
| PDF | Lesson text, embedded images, title from document metadata |
| Word (.docx) | Markdown with heading levels, list nesting, emphasis and embedded images |
| PowerPoint (.pptx) | One ## heading per slide, speaker notes as a ::: {.notes} block, embedded media |
| Markdown / text | Passed through; existing YAML front matter is merged, not nested |
| Images | Placed under images/; no lesson generated |
Nothing is written until you see the plan and confirm. Files that already exist are
skipped unless you pass --overwrite. Imported files land in your working copy as
ordinary local changes, so they reach the server through the normal cf push
three-way diff - import never writes to the server itself and never touches the
sync manifest.
cf import ~/lectures/week3.pptx --into week3 --dry-run
cf import ~/lectures --into week3 --yes
cf import handout.pdf --ai --pushIf a local Pandoc is on your PATH it is used for Word
documents, which handles tables and complex lists better. Its absence changes nothing.
Scanned, image-only PDFs report as empty rather than producing a blank lesson -
OCR is not included. Legacy .doc/.ppt binaries are detected and refused with a
note to re-save them in the modern format.
How sync works
cf clone writes the course files plus a .coursefoundry/manifest.json recording a
sha256 per file. cf status / pull / push do a three-way diff - base
(manifest) vs local (disk) vs remote (list_files) - so they can tell a local edit
from a server edit and flag files changed on both sides as conflicts. Hashes are
computed identically on client and server, so only real changes move. Binary files
(images) are handled as base64; build artefacts (_site/, .quarto/, _freeze/)
and non-course files are ignored.
Workflow examples
# Generate a course outline to stdout
cf workflow run outline --title "Intro to Rust" --description "Ownership, traits, async"
# Draft a lesson body into a file, using the current clone's course context
cf workflow run lesson --course-title "Intro to Rust" --lesson-title "Ownership" --out ownership.qmd
# Anything not covered by a flag: pass raw JSON context
cf workflow run assessment --content lesson.qmd --ctx '{"courseTitle":"Intro to Rust"}'Global options & environment
| Flag | Env | Default |
|---|---|---|
| --api-base <url> | CF_API_BASE | https://www.coursefoundry.com |
| CF_RENDER_IMAGE | cf render only: a local image reference to run instead of the pinned digest (for testing an unpublished cf-render build; prints a not-parity line) |
| --key <token> | CF_API_KEY | (stored login) |
--api-base is handy for pointing at a local dev server, e.g.
cf --api-base http://localhost:5173 whoami. The bare coursefoundry.com apex is
auto-upgraded to www. to avoid a redirect that drops POST bodies.
Inside a cloned course, the server is chosen in this order: --api-base, then
$CF_API_BASE, then the address saved when the course was cloned, then your
stored login. So CF_API_BASE=https://<preview>.vercel.app cf placeholders list
talks to that preview even in a clone made against production.
How it works
The CLI is a thin JSON-RPC 2.0 client over
CourseFoundry's MCP endpoint (POST /api/mcp). courses list calls the list_courses
tool; whoami reads the coursefoundry://user/profile resource. The create_course
tool accepts an optional workspace (personal or organisation) naming which of your
two workspaces the course belongs to - your own plan or the joined organisation's plan
then governs its course count, storage and AI credits; omitted, it lands in your active
workspace. See
docs/mcp-integration-guide.md.
