@knapsack/mdbase-cli
v0.2.2
Published
CLI for mdbase-style typed markdown collections: validate, query, read, update, links, schema
Readme
@knapsack/mdbase-cli
A TypeScript CLI for typed markdown collections: markdown files with YAML
frontmatter, validated against schemas declared in _types/*.md files and
configured by an mdbase.yaml at the collection root.
It is a clean-room implementation of the command surface of the retired
upstream mdbase CLI: validate, query, read, update, links, and
schema. No upstream code is used; the behavior was reconstructed from
observed output and locked in with a parity harness.
Install
pnpm add -D @knapsack/mdbase-cliPublished to npmjs.org. No registry configuration, authentication, or token is required.
Versions up to 0.2.1 were published to GitHub Packages instead. That registry requires an authenticated request for every read, including of public packages, which is why this package moved.
Requires Node 22.18 or newer.
Usage
Every command needs an mdbase.yaml at the collection root, found by walking
up from cwd, the same way .git is found. Paths on the command line are
cwd-relative; output paths are always root-relative.
Help
mdbase --help prints the command index, the resolution rules, and the
exit-code contract. mdbase <command> --help (or mdbase help <command>)
prints one command's arguments, flags with their legal values, output shape
per format, exit semantics, worked examples, and the gotchas that otherwise
produce a confidently wrong invocation. Help always goes to stdout and exits
0, including with no collection in sight. Bare mdbase remains a usage error
on stderr, exit 2.
mdbase --help
mdbase query --help
mdbase --help --format jsonThat last form emits the entire CLI as a machine-readable spec: every
command, flag, enum, output shape, exit rule, and example as JSON. It exists
for agents. An LLM driving this CLI can parse the spec once instead of
pattern-matching prose, and jq gets you a single command's surface without
reading the rest.
mdbase --help --format json | jq '.commands[] | select(.name=="query") | .flags'The help text is generated from one declarative spec in src/help.ts, and
test/cli.test.ts asserts every command's flags appear in its help, so a flag
added to a parser without a help entry fails CI rather than shipping
undocumented.
validate
mdbase validate [paths...] [--format text|json]
Validates frontmatter against the schemas in _types/*.md. With no paths,
validates the whole collection; with paths, only those files, still checked
against the full type set so link targets resolve.
A path may also be a directory: it expands to every collection file under it,
with excludes and dot-directories respected. Like every path argument,
directories are cwd-relative, so mdbase validate . from the root validates
everything, while . from a subdirectory validates just that subtree. A
directory containing no collection files checks 0 files and exits 0. File and
directory arguments mix freely.
mdbase validate
mdbase validate .
mdbase validate guides people/ada.md
mdbase validate people/ada.md --format jsonNotable flags: --format json emits {findings, counts: {errors, warnings,
files}} instead of one line per finding plus a summary line.
query
mdbase query [path] [-t|--type <type>] [--fields a,b,c] [--format json|tsv|table|paths]
Lists collection files with their resolved types and frontmatter, optionally filtered by type and/or path prefix, optionally projected to specific fields.
mdbase query people/ --fields title,role --format table
mdbase query -t article --format jsonNotable flags: -t/--type filters to files resolving to that type (unknown
type names throw, exit 2); --fields limits columns in tsv/table output
(JSON always includes full frontmatter); --format paths prints bare paths
only, one per line, which pipes into xargs.
read
mdbase read <path> [--format text|json|yaml] [--body]
Prints one file's resolved types and frontmatter, and its body with --body.
This is not validation: an excluded or untyped file still reads fine, just
with types: [].
mdbase read people/ada.md
mdbase read people/ada.md --format json --bodyupdate
mdbase update <path> -f key=value [-f key=value ...]
Sets one or more frontmatter fields, validating the candidate content before
writing anything. Values coerce to the field's declared type; key order,
comments, and the body are preserved byte-for-byte; any now_on_write
generated field on the type regenerates.
mdbase update people/ada.md -f role=maintainer
mdbase update projects/atlas.md -f priority=2 -f status=activeNotable flags: repeat -f for multiple fields in one write. If validation
finds any severity-error finding on the candidate, findings print and
nothing touches disk (exit 1). The file is never left half-written.
links check
mdbase links check [path]
Scans body markdown links for broken relative .md targets. This is separate
from schema-declared link fields, which are covered under Schema features
below. With no path, scans the whole collection; with a path, scans just that
file or directory prefix.
mdbase links check
mdbase links check guides/schema
mdbase schema <type> [--format text|json|yaml]
Prints one type's field definitions as declared in _types/*.md.
mdbase schema article
mdbase schema person --format jsonExit codes
- 0 — clean: no severity-
errorfindings (validate/update), or nothing broken (links check). - 1 — findings: at least one severity-
errorfinding, or at least one broken link. - 2 — usage/config error: bad flags, no
mdbase.yamlfound, a file not found, an invalid type-schema definition.
Warnings never affect the exit code. A collection with only warning-severity findings still exits 0.
Schema features supported
Type files (_types/*.md) declare fields under a fields: map; each field
has a type plus optional modifiers:
- Field types:
string,number,boolean,date,time,enum,list,link. required— missing (or explicitnull) on a required field ismissing-required;nullon an optional field is skipped entirely, not validated.values— the enum's allowed values. Enum fields must declare at least one; an empty or absentvaluesfails type-file load, not collection validation.min/max— numeric range, checked on the coerced value.unique— list fields only; duplicate items (post-coercion) are a finding.items— a nestedFieldDefdescribing a list's element type. List items are validated (and read-coerced) against it but never top-level coerced in output.target/validate_existsonlinkfields —targetnames the type a link must resolve to;validate_existsmakes a missing target abroken-linkfinding. They are independent:targetis only checked when the target does resolve, so a link withtargetset but novalidate_exists, pointing at nothing, is silently not a finding, because the target-type check has nothing to inspect. Set both when a link must exist AND be the right type.generated: now/generated: now_on_write— auto-populated fields, always UTC, shaped by the field's own type:datefields getYYYY-MM-DD,timefields getHH:MM:SS, anything else gets a full ISO-8601 timestamp.
Not implemented, and failing loud at type-file load: cross-file uniqueness,
path_traversal findings, min_length/max_length/min_items/max_items,
wikilink syntax in link fields, and upstream's integer/datetime/object/
any field types.
Development
src→srcimports use.jsspecifiers (e.g.import { foo } from './foo.js'), even though the source file isfoo.ts. This is standard TypeScript-with-nodenextstyle:tscrewrites nothing at the specifier level, so the specifier must already match the emitteddist/foo.js.test→srcimports use real.tsspecifiers (e.g.import { foo } from '../src/foo.ts'). Tests run directly against source via Node's native TypeScript type-stripping, which resolves literal.tsspecifiers rather than compiled output.- Tests run without a build:
pnpm testrunsnode --test "test/*.test.ts"directly, no compile step required. scripts/ts-loader.mjs: Node's native TypeScript type-stripping does not remap a relative.jsspecifier to a sibling.tsfile (a deliberate Node decision, see nodejs/loaders#214), but that remapping is exactly what thesrc→src.js-specifier convention above needs once onesrcmodule imports another, sincepnpm testruns.tssources directly with no build step.tscitself resolves the convention fine, which is what makespnpm buildandpnpm typecheckwork, so the gap is Node's runtime resolver alone. Thetestscript loadsscripts/ts-loader.mjsvia--import, which usesnode:module'sregisterHooksto retry a failed relative.jsresolution as.tsbefore giving up. This only affectspnpm test; build output and typechecking are untouched.- Two tsconfigs:
tsconfig.json(used bypnpm build) scopesrootDir/includetosrconly, sodistmirrors just the package's public surface.tsconfig.test.jsonextends it withinclude: ["src", "test", "scripts"],rootDir: ".",noEmit: true, andallowImportingTsExtensions: true, sopnpm typecheckalso typechecks tests, whichtsconfig.jsonalone would silently skip.
License
MIT. See LICENSE.
