Skip to content

Command line tool ​

apps/cli is spelling-creator, a command line tool for lessons that live in files. It runs the lesson checks on a lesson, makes the quick fixes, and converts a Word document or a text file into a .json lesson file the editor can open. It needs no account and makes no network requests, which makes it the simplest way for a script, a CI job or an AI agent to check a lesson without the MCP server.

In the repository, pnpm cli check lesson.json from the root runs it from source, with no build (Node 22.18 or newer, for its built-in TypeScript support).

From npm it installs as the spelling-creator command, which runs on Node 22.12 or newer:

sh
npm install -g @spelling-creator/cli
spelling-creator check lesson.json

Commands ​

check <files..> ​

Runs the checks over each file and reports. Nothing is written.

domestic-cats.docx: 0 problems, 2 suggestions

Suggestions
  Section 1 (The Wildcat Ancestor) has 7 questions; the usual shape is 15. [W_QUESTION_COUNT]
  ...

Import notes
  Read as a typed document: the question types and answers were worked out from the wording, so check them.

Problems are the checks' errors (what would confuse the speller or mark a right answer wrong) and suggestions are its warnings (the usual shape of a lesson), the same split as the editor's Check panel. Each line is the prose the check wrote, which names the section and question, with the finding's code after it.

The exit code is 0 when no file has problems, 1 when one does, and 2 when a file couldn't be read at all (or, for fix and convert, couldn't be written). --strict counts suggestions as problems, for a lesson library that wants the standard shape enforced.

fix <file> ​

Makes the quick fixes (the ones that follow from a finding alone, such as removing bold from a passage or moving a VAKT activity to the end of its section), writes the lesson back, and reports what it fixed and what is left. A .json lesson is rewritten in place. A document is converted at the same time and written beside it as .json, or wherever --out says; replacing an existing file there, the document itself included, takes --force. --dry-run reports without writing. The exit code is check's, for what remains.

Fixes are made one at a time with the checks rerun in between, since one fix can change the next finding.

Replacing a file (the in-place rewrite, or anything with --force) writes the new lesson to a temporary file beside it and renames that over the old one, so a write that fails partway leaves the old file as it was rather than cut off.

Rewriting a .json lesson in place has three more guards, since it is the author's own file:

  • Nothing to fix, nothing written. The file is left byte for byte as it was, rather than reformatted.
  • Nothing dropped without asking. Reading a lesson keeps only what the editor understands, so a field a newer version added, a block type this version doesn't know, or trustedCollaborators (which a lesson file leaves out on purpose) would vanish in a rewrite. When the file holds anything like that, fix refuses and names it; --out writes the fixed lesson elsewhere, and --force rewrites it anyway. A plain passage stored as a content document coming back as text is the same words, so it doesn't count.
  • No stale overwrites. If the file changes after fix read it (the editor saved it, say), fix leaves the new version alone and exits 2.

convert <file> ​

Reads a document and writes it as a .json lesson file, beside the input or at --out. It never replaces an existing file unless --force is passed, since the document may be the author's only copy. It doesn't run the checks, so its report says "converted, not checked" rather than counting problems (and has "checked": false in --json); check the output for those.

Options every command takes ​

--json prints the report as JSON, one object per file:

json
[
  {
    "file": "domestic-cats.docx",
    "source": "docx",
    "notes": ["Read as a typed document: ..."],
    "errors": [],
    "warnings": [
      {
        "level": "warning",
        "code": "W_QUESTION_COUNT",
        "message": "...",
        "section": 1,
        "...": "..."
      }
    ],
    "fixed": [],
    "wrote": "domestic-cats.json"
  }
]

The findings are core's Finding objects exactly as validateLesson returns them, so a script gets the code, section, blockId and params the editor and the MCP server work from. A file that couldn't be read or written has error instead of source.

What it reads ​

ExtensionHow it is read
.jsonThe app's own lesson file, through core's jsonImport. Lossless, and the envelope or a bare { title, sections } doc are both accepted.
.docxThe document's text, extracted with mammoth, then read by the rules parser below.
.txt, .md, anyThe file as text, read by the rules parser in packages/core/src/documentImport.js, the same one behind Import from text: headings, passages, a spelling line, one question per line.

A document was never a lesson file, so nothing in it says what a question's type is: the parser works that out from the answers and the wording. The report says so in its Import notes, and adds a note for every line it couldn't place and every section where a question may still have its answer glued on, so a clean check can't hide a misread document. Check those before trusting the result.

A Word document is read as plain text even when this app exported it. The styled import that reads question types back from the exporter's character styles (packages/core/src/browser/docxImport.js) needs a DOM and the browser's image store, so for now it only runs in the editor. The rules read an exported file well regardless, since its Spell: line and answer gaps are shapes they know; images are the one thing lost.

Code ​

apps/cli/
  src/cli.ts            yargs setup: registers the commands, help, version
  src/commands/*.ts     one module per command
  src/lesson.ts         readLesson / writeLesson: file in, lesson out, and back
  src/report.ts         FileReport, the prose and JSON output, the exit code
  src/types.ts          the lesson document's shape, for TypeScript
  src/core.d.ts         ambient types for the core modules the CLI calls
  rolldown.config.ts    the build: src/cli.ts and core's modules into dist/cli.js
  test/                 node --test, with the built bin spawned for the commands

TypeScript, and the build ​

The CLI is the first TypeScript package in the repository. In development it needs no build: pnpm start and the root's pnpm cli run src/cli.ts directly, with Node stripping the types as it loads them. That takes Node 22.18 or newer, the first release where it does so without a flag; the engines field says 22.12 because it describes the published bundle, which is plain JavaScript. Running from source holds the code to syntax Node can erase (erasableSyntaxOnly in tsconfig.json: no enums, namespaces or parameter properties), and imports between its own files name the .ts extension. pnpm --filter @spelling-creator/cli typecheck runs tsc --noEmit, which is all tsc does here.

What ships is a bundle. pnpm build runs rolldown with rolldown.config.ts, which turns src/cli.ts and everything it reaches into one ESM file, dist/cli.js, that the package's bin points at. @spelling-creator/core is a workspace package that isn't published to npm, so the core modules the CLI uses are bundled in; they import nothing from npm themselves, so the bundle adds no dependencies. The CLI's own dependencies (yargs, mammoth) and Node's builtins stay external. pnpm test builds first and spawns the bundle, so what is tested is what is published, and prepack builds again whenever the package is packed.

Rolldown rather than Vite or tsdown because the job is small and plain: Vite brings a dev server and an app's conventions, and tsdown adds declaration output and a library's packaging, neither of which a command line tool uses.

@spelling-creator/core is JavaScript with JSDoc and publishes no declarations, so the CLI carries its own in src/core.d.ts: ambient declare module blocks for the few core subpaths it imports, with signatures written against the document types in src/types.ts. That is a stopgap. When a second TypeScript package needs core, the better move is for core to emit declarations from its JSDoc (tsc --allowJs --declaration --emitDeclarationOnly) and for both to use those.

Releasing ​

The CLI is published to npmjs.com as @spelling-creator/cli, under the same scope as Spellophone, by .github/workflows/release-cli.yml. That runs when a GitHub release whose tag starts with cli-v is published; the repository's other releases are left alone.

  1. Bump the version in apps/cli/package.json:

    bash
    pnpm --filter @spelling-creator/cli exec pnpm version minor --no-git-tag-version
  2. Commit it and merge to main.

  3. Create the release on GitHub with the tag cli-v plus that version:

    bash
    gh release create cli-v0.2.0 --title "CLI 0.2.0" --generate-notes
  4. Publishing the release runs the workflow. It checks that the tag matches package.json, type-checks, builds and tests the bundle, packs it, publishes the tarball, and attaches the same tarball to the release.

Trusted publishing ​

The workflow publishes with npm's trusted publishing: GitHub Actions hands the registry a short-lived OIDC token, which npm accepts because the package names this repository and workflow as its trusted publisher. No npm token is stored anywhere, and every version gets a provenance attestation tying it to the commit and run that built it. pnpm does the token exchange itself, so the workflow publishes with pnpm publish, which also turns the workspace:* devDependency on core into a plain version.

npm only accepts a trusted publisher for a package that already exists, so the first version on npm, 0.1.0, was published by hand on 2026-10-12: packed with pnpm pack from the cli-v0.1.0 tag and published with npm publish by a maintainer of the @spelling-creator scope (the same route Spellophone's first version took). It is byte for byte the tarball the tag builds, but it has no provenance attestation. Every version after it comes from the workflow; 0.1.1 was the first.

The trusted publisher is set on npmjs.com, in the package's settings under "Trusted publishing", or with npm trust github @spelling-creator/cli --file release-cli.yml --repository Spelling-Creator/spelling-creator --allow-publish:

FieldValue
PublisherGitHub Actions
OrganizationSpelling-Creator
Repositoryspelling-creator
Workflow filenamerelease-cli.yml
Environment(empty)

Renaming the workflow file or moving the repository breaks publishing until this is updated, and a trusted publisher that is added but not used within two days expires. With the workflow publishing, the package's publishing access should be "Require two-factor authentication and disallow tokens", so nothing but the workflow can publish.

Not yet ​

This is the first cut. Things it points toward, in rough order:

  • An interactive check (Ink) for stepping through findings and applying quick fixes one by one, and a --watch that rechecks a document on save.
  • Reading an exported Word document through the styled import, so question types and images survive.
  • Hub access: login, listing and fetching lessons, and clone / pull / push against a lesson's version history.
  • A Markdown lesson format with directives for blocks, for edit in $EDITOR.

Copyright © 2026 Spelling Creator.