Skip to content

CLI reference

manni meta exposes seven subcommands: validate (the default), get, query, fill, derive, relocate, and schemas, the last of which is a group with its own infer and vendor subcommands. Every one of them but schemas vendor takes paths. They share an input model and several flags with identical semantics, --collection included.

Terminal window
manni meta [global options] [command] [command options] [arguments]

Global options are accepted before the subcommand.

Option Description
-V, --version Print the manni version and exit.
-h, --help Print help for the program or a subcommand.

The metadata tool. Every command on this page lives under it, and it is the same program the package’s docmeta bin runs, so docmeta validate … and manni meta validate … are one command with two spellings. meta accepts its own -V, --version (the same version) and -h, --help.

Terminal window
manni meta [options] [command] [command options] [arguments]
Option Description
--no-color Disable colored output. See color behavior.

validate is the default command, so manni meta <paths...> runs validation without naming the subcommand.

Validate the metadata in the given files, directories, or globs against the resolved schema set for each file. This is the default command.

Terminal window
manni meta validate [paths...] [options]
manni meta [paths...] [options]
Argument Description
[paths...] Files, directories, or globs to validate. Use - to read from stdin. Optional, and falls back to the configured collections. See the shared input model.
Option Argument Default Description
-s, --schema <ref> n/a Schema to validate against. Repeatable. When given, it overrides $schema and config for every file. See reference kinds.
--ext <list> supported extensions Comma-separated extensions used when expanding directories and globs.
--exclude <glob> n/a Glob to exclude from directory and glob walks. Repeatable. Merged with the default ignores. A collection’s own exclude: is a membership rule and never filters a path you type; --exclude is the only thing that does.
--as <format> n/a Force an input format (extractor name, e.g. markdown). Required when reading from stdin.
-f, --format <pretty|json|github|sarif|junit> pretty Output format. An unknown value is an error (exit 2).
-c, --config <path> discovered Path to a config file. When set, the file must exist or manni meta exits 2.
--no-config n/a off Ignore any discovered config file and run on the built-in default schema set. -c and --no-config set the same option, so the one written later on the command line wins.
--collection <name> every collection Run over a configured collection rather than the whole corpus. Repeatable, one name per occurrence, never comma-separated; repeats collapse, and a name must match a declared collection exactly. Cannot be combined with positional paths, and needs a config file to select from (exit 2 for both). See collections as inputs.
-q, --quiet n/a off In pretty output, hide passing files. Has no effect on json, github, sarif, or junit.
--allow-empty n/a off Treat zero matched files as success instead of an error (exit 2). See empty input sets.
--no-gitignore n/a on Validate files .gitignore covers as well. Filtering is on by default; see default ignores. Overrides config respectGitignore:.
--offline n/a off Never fetch a remote schema. A url reference is served from the schema cache; one that is not cached is an error (exit 2) naming the URL. Built-in and local-file references are unaffected. Overrides config offline:.
--baseline [path] config baseline:, else .manni-baseline.json Compare findings against a recorded baseline and fail only on new ones. The value is optional, and omitting it means this project’s baseline. That is the configured baseline: path when there is one, resolved against the config file, and otherwise .manni-baseline.json. Config baseline: turns this on for every run. A named file that does not exist is an error (exit 2).
--write-baseline [path] config baseline:, else .manni-baseline.json Record this run’s findings as the baseline, then exit 0. The value is optional. Wins over --baseline. Findings from - (stdin) are the one exception: they cannot be recorded, so they still fail the run.
--no-baseline n/a off Ignore a baseline supplied by config baseline: for this run. --baseline and --no-baseline set the same option, so the one written later on the command line wins.
--no-checks n/a off Skip the corpus checks supplied by config checks: for this run. Checks already run only when the resolved file set is the config-resolved corpus. A run scoped by --collection, positional paths, stdin, --as, --ext, --exclude, or --no-gitignore skips them with a stderr notice; see corpus checks and a narrowed run. This flag opts out explicitly.
--no-cache n/a off Ask GitHub or GitLab again rather than reading the review cache. Only the managed-field comparison consults it, and only for reviewed-by and last-reviewed. A merged change’s approvals never change, so the cache is on by default; this is the way past an answer recorded wrongly.
--no-derive n/a off Skip the managed-field comparison supplied by config derive: for this run. Unlike checks, the comparison runs on scoped runs too, --collection included, because it is a per-file fact. This flag is the only opt-out.

With externalMetadata: on a collection, validate merges each manifest’s values into the document before resolving its schema set. Which manifests apply is decided by membership. A document gets the manifests of every collection it belongs to, and a file that belongs to none gets no manifests at all. It files an external:owned/external finding for every owned key a document carries itself, reading "jira" is owned by manifest guides-meta.yaml (collection guides); remove it from the document. On a config-corpus run it refuses (exit 2) a manifest entry naming a document the run did not load. That last check runs on the same invariant the corpus checks use and has no flag of its own. A positional path is what turns it off.

When a schema marks a top-level property x-manni-location, validate reports a value stored on the other side as a warning. The exit code does not move. location:external/location sits at the page line of a key whose schema prefers external metadata: "owner" is stored in the page; steward.schema.json prefers external metadata. Run manni meta relocate. A key a manifest owns is skipped there, because external:owned already fails it. location:page/location sits at the manifest line of a key a manifest supplies and whose schema prefers the page. On a terminal, after the report, validate asks once per collection whether to move them, and on yes runs relocate with its output on stderr. Nothing is asked off a terminal, under --no-config, or for stdin, and a baselined finding is never offered. Keep maintainer metadata out of delivered pages is the journey.

With derive: in config, validate derives every managed field once for the run and compares each file’s stamp against the evidence. A stamp that disagrees is a derived:stale/derived finding at the field’s line. A source that cannot answer, such as a shallow clone or a gh that is not logged in, is exit 2 naming the fix. It is never a skipped check. --no-derive opts out for one run.

A baseline records the violations that exist today so that a run fails only on findings that are new. That is what lets a field become required immediately on a repo with a large backlog, instead of after a cleanup. The rule applies to everything written from now on, and the debt stays visible rather than blocking the build.

Terminal window
manni meta validate --write-baseline # record today's findings, then exit 0
manni meta validate --baseline # from now on, fail only on new ones

The file is JSON, path-keyed, with a sorted array of violation fingerprints per document, so it diffs and merges legibly:

.manni-baseline.json
{
"version": 1,
"generatedWith": "3.4.2",
"entries": {
"docs/api/legacy.md": ["a1b2c3d4e5f60718", "9f8e7d6c5b4a3210"],
"docs/guides/old.md": ["1122334455667788"]
}
}

Commit it. Reviewers should see it grow or shrink in a diff. A baseline recorded before the rename as .docmeta-baseline.json is still read when no .manni-baseline.json exists, with a warning on stderr to rename it.

A fingerprint is 16 hex characters of sha256(schema + NUL + instancePath + NUL + keyword + NUL + subject), the four machine-stable fields of a violation. It deliberately excludes three things. The line number, so editing a document does not invalidate it. The message prose, so an Ajv upgrade that rewords a message does not invalidate every entry at once. And the file path, which is already the key.

Three things do change a fingerprint, and each is recovered with one --write-baseline:

  • Renaming a document. Entries are path-keyed, so a rename moves findings to a key with no entry and every one of them reads as new. The N no longer occur line in the summary is the hint that a rename, not a regression, is what happened.
  • Re-pointing a schema. The schema reference is part of the identity, so switching google:okf:0.1 to a URL serving the same schema changes every fingerprint. manni meta cannot tell it is the same contract.
  • Editing a pattern, enum, or minLength rule. Those keywords have no stable subject, so a violation of the edited rule keeps its fingerprint. A violation that moves to a different keyword does not.

A local file schema reference is measured relative to the config file’s directory, and so is a baseline: path. The same repo then produces the same fingerprints whether the command runs from the repo root or from a subdirectory.

Findings the baseline already holds do not fail the run and are reported instead:

✓ docs/api/legacy.md (2 baselined)
1 file checked, 1 passed, 0 failed, 0 errors
3 baselined findings, 1 no longer occurs — run --write-baseline to prune

Entries that no longer occur are never fatal. They are only reported, so a run that fixes violations stays green. --write-baseline reports its change in both directions:

Baseline written to .manni-baseline.json
14 findings recorded (+2 new, -12 no longer occur)
Terminal window
manni meta validate # every configured collection
manni meta validate --collection guides # one of them
manni meta validate docs/ # walk a directory
manni meta validate "**/*.md" -f github # CI annotations
manni meta validate "**/*.md" -f sarif > out.sarif # code scanning
manni meta validate "**/*.md" -f junit > junit.xml # the CI "Tests" tab
manni meta validate page.md -s google:okf:0.1 -s ./my.schema.json
cat page.md | manni meta validate - --as markdown # read from stdin
manni meta validate --write-baseline # record today's backlog
manni meta validate --baseline # fail only on new findings

Print the values of one or more metadata fields from each file. Useful for scripted extraction. get reads and reports values; it does not validate.

With derive: in config, get prints the resolved value of each field. That is the asserted value when the document carries the key, and the derived one otherwise, with the origin on the same line. Derivation is on by default, and --no-derived turns it off. A field no source can state consults nothing, so get title docs/ spawns no process whatever the config says. get never writes, and its exit code is unchanged.

Terminal window
manni meta get --fields <list> [paths...] [options]
manni meta get [fields] [paths...] [options]
Argument Description
[fields] Comma-separated metadata field references to print, when --fields is not used. Each is a top-level key (title), or a dot-notation path into nested objects and arrays (author.name, tags.0). A JSON Pointer works too, when prefixed with / (/author/name, /tags/0). See naming the fields.
[paths...] Files, directories, or globs to read. Use - to read from stdin. Optional, and falls back to the configured collections. See the shared input model.

A requested field that is absent from a file is reported as unset rather than as an error.

The field list can be given either way, and there is one rule:

If --fields is present, every positional is a path. Otherwise the first positional is the field list.

Terminal window
manni meta get title,type docs/intro.md # positional field list
manni meta get --fields title,type docs/intro.md # the flag; both positionals are paths
manni meta get --fields title # no paths: the collections apply

--fields is the same flag fill takes, and it is the unambiguous spelling: with it, nothing about a path can be mistaken for a field.

Naming no fields at all is an error (exit 2) rather than an empty report. So is a path in the field-list position, which is the mistake the positional form invites:

Terminal window
$ manni meta get docs/intro.md
manni: "docs/intro.md" looks like a path, not a field list. Pass fields first
(manni meta get title docs/intro.md) or use --fields.
exit=2

A token is read as a path when it exists on disk, is a glob, ends in a supported extension, or contains a path separator. A comma-separated list and a leading-slash JSON Pointer are recognized as fields, so a normal field list is not mistaken for a path. “Exists on disk” is checked first, though, so a pointer that happens to match a real absolute file is read as a path. --fields is the unambiguous spelling whenever the guess would be wrong.

A field reference descends into nested objects and arrays. Two syntaxes are accepted:

  • Dot-notation: author.name, tags.0. Concise, but a key containing a literal . cannot be addressed this way.
  • JSON Pointer (RFC 6901): any reference beginning with /, e.g. /author/name or /tags/0. This is the same pointer validate prints in error locations, so a path copied from a validation failure works verbatim. Use it to address keys that contain dots (/odd.key) or slashes (/a~1b for the key a/b).

Requesting a parent key (author) returns the whole nested value. A path that descends into a scalar, or whose segment is missing, is reported as unset. Only a key’s own properties resolve; inherited members such as toString or __proto__ are treated as missing.

On Git Bash/MSYS2 (Windows), quote a leading-slash pointer, '/author/name', so the shell does not rewrite it as a filesystem path. Other shells are unaffected.

Option Argument Default Description
--fields <list> the [fields] positional Comma-separated fields to print. When given, every positional is a path. See naming the fields.
--no-derived n/a off Print only what the document stores. No source is consulted, no origin is annotated, and the output is what get printed before config derive: existed.
--derived n/a off Accepted and inert. It was the opt-in before derivation became the default. A script that passes it still runs, and prints what a run without it prints.
--no-cache n/a off Ask GitHub or GitLab again rather than reading the review cache. Only a run that derives consults it, and only for reviewed-by and last-reviewed. A merged change’s approvals never change, so the cache is on by default; this is the way past an answer recorded wrongly.
--ext <list> supported extensions Comma-separated extensions used when expanding directories and globs.
--exclude <glob> n/a Glob to exclude from directory and glob walks. Repeatable. Merged with the default ignores. A collection’s own exclude: is a membership rule and never filters a path you type; --exclude is the only thing that does.
--as <format> n/a Force an input format (extractor name). Required when reading from stdin.
-f, --format <pretty|json> pretty Output format. sarif and junit are validate-only, and github is available on validate, fill, and derive --check. An unknown value is an error (exit 2).
-c, --config <path> discovered Path to a config file. When set, the file must exist or manni meta exits 2.
--no-config n/a off Ignore any discovered config file and run on the built-in default schema set. -c and --no-config set the same option, so the one written later on the command line wins.
--collection <name> every collection Run over a configured collection rather than the whole corpus. Repeatable, one name per occurrence, never comma-separated; repeats collapse, and a name must match a declared collection exactly. Cannot be combined with positional paths, and needs a config file to select from (exit 2 for both). See collections as inputs.
-q, --quiet n/a off In pretty output, hide files where every requested field is unset. A file with any value present still prints, (unset) included, because quiet hides files and never values. Has no effect on json.
--allow-empty n/a off Treat zero matched files as success instead of an error (exit 2). See empty input sets.
--no-gitignore n/a on Read files .gitignore covers as well. Filtering is on by default; see default ignores. Overrides config respectGitignore:.
--offline n/a off Accepted for parity with validate and fill. It has no effect here: get prints extracted values and never loads a schema, so it makes no network requests to suppress.

In pretty format, get prints one <file>: <field>=<value> line per requested field per file. Unset fields print as (unset). In json format it prints an array of { file, present, values } objects, where values maps each requested field to its value. A requested field that is unset is omitted from values. present reports whether the file had a metadata block at all.

With config derive:, the value on each line is the resolved one and an annotation says where it came from. There are four shapes, and --no-derived drops the annotation entirely:

Terminal window
$ manni meta get title,owner,last-updated docs/install.md
docs/install.md: title=Install the operator (asserted)
docs/install.md: owner=@platform-docs (derived, codeowners: .github/CODEOWNERS:12)
docs/install.md: last-updated=2026-08-20 (asserted; git says 2026-09-07, body changed in 424f71a)
$ manni meta get title docs/install.md --no-derived
docs/install.md: title=Install the operator

A field neither the document nor any source has prints (unset), with no annotation after it. The third line above is the drift case. The document asserts a value, the evidence disagrees, and the asserted one is what get prints, because that is what the page publishes. validate reports the same disagreement as derived:stale.

In json, each file’s object gains a resolved record and an origin record beside the existing values, and a derived record carrying the raw derived values and their evidence. values is untouched, so a consumer reading only what the document stores sees exactly what it saw before. --quiet hides a file when every requested field is unset after resolving, so a page whose only owner comes from CODEOWNERS still prints.

Stdin derives nothing, and that is not an error. A piped document has no path, so no source can speak for it, and cat page.md | manni meta get title - --as markdown resolves to the document’s own values.

A file whose metadata block could not be read at all is reported against that file, rather than ending the run. That means frontmatter that is not valid YAML, or that parses to something other than a mapping. In pretty it prints as <file>: (parse) <reason>; in json that file’s object carries an extra error string, with present: false and an empty values. Every other file in the run is read and reported as usual, and the run exits 1.

error is set only for a block that could not be read. A file with no metadata block, and a file where every requested field is unset, are both answers rather than the absence of one. They carry no error and leave the exit code at 0. Objects for files that parsed are unchanged, so a consumer that never looks at error sees exactly what it saw before. --quiet never hides a file carrying one.

Terminal window
manni meta get title,type docs/intro.md
manni meta get --fields title,type docs/intro.md
manni meta get author.name,/author/email docs/intro.md
manni meta get type "**/*.md" -f json
manni meta get owner "docs/**/*.md" -q # only the files that resolve one
manni meta get owner docs/install.md --no-derived # what the page stores, nothing else
cat page.md | manni meta get title - --as markdown

Run one SQL statement over the metadata of every input file at once. Where get answers per file, query answers across the corpus, with filters, aggregation, and joins. That includes the cross-file rules no single-document schema can express. Every author: names an author page that exists, no two pages share a slug:, and nothing published is stale. With --check, returned rows are findings and the exit code says so, which makes any such rule a CI gate.

This section is the flag surface. The command’s full contract is the query command reference. That covers the docs table and its columns, and the DML and DDL vocabulary. It also covers collections as views, the DDL type bridge, the column convention for findings, and every refusal.

Terminal window
manni meta query --query <sql> [paths...] [options]
manni meta query [sql] [paths...] [options]
Argument Description
[sql] One SQL statement, run against the docs table described below, when --query is not used. A second statement after a ; is refused rather than silently ignored. Optional when --db is given, because exporting needs no query.
[paths...] Files, directories, or globs to load. Use - to read from stdin. Optional, and falls back to the configured collections. See the shared input model.

The same rule as get’s field list applies: if --query is present, every positional is a path; otherwise the first positional is the SQL. A token in the SQL slot that looks like a path is an error naming the remedy, and - is always stdin, never SQL.

One row per input file. Your top-level metadata keys become columns, plus four of manni meta’s own, _path, _format, _present, and _data, which frontmatter cannot shadow. The database is built in memory on every run and discarded; --db exports a copy.

Statements are judged by their effects on that disposable projection: reads return rows, and everything else maps back into file space. Like get, query resolves no schema on plain reads and DML, and makes no network requests. A statement naming a collection resolves nothing either, because a view’s membership is its collection’s own globs. Only a DDL statement consults resolution.

The full contract covers every column, how values are encoded, the registered SQL functions, and the vocabulary. It is in the docs table.

Option Argument Default Description
--query <sql> the [sql] positional The SQL statement. When given, every positional is a path.
-s, --schema <ref> resolved per file The schema set a DDL statement evolves; repeatable. CLI precedence applies to the DDL planner only. The per-file resolution walk is skipped and the deduped refs are the set, with every DDL guard unchanged inside it. Collection views and plain reads are untouched. A statement that produces no schema-evolving effects under -s is an error (exit 2), refused before any file or schema write. A --db export target, written before the statement runs, persists and reflects the statement. See writing back.
--check n/a off Treat returned rows as findings, so exit 1 if the query returns any and 0 if it returns none. The pretty verdict line shows ✓/✗.
--param <name=value> n/a Bind a named SQL parameter ($name, :name, or @name) as a string; name:=value parses the value as JSON for a typed bind. Repeatable. See bound parameters.
--db <path> n/a Also write the built database to this file, and the SQL is then optional. The file is a regenerated artifact, so an existing SQLite database at the path is overwritten and anything else is refused. See exporting the database.
--dry-run n/a off Preview a mutating statement instead of applying it, so you see the per-file diff it would make with nothing touched. --check implies it, because a check judges and never mutates. See writing back.
--no-cache n/a off Ask GitHub or GitLab again rather than reading the review cache. Only a statement naming the derived or resolved table consults it, and only for reviewed-by and last-reviewed. A merged change’s approvals never change, so the cache is on by default; this is the way past an answer recorded wrongly.
--ext <list> supported extensions Comma-separated extensions used when expanding directories and globs.
--exclude <glob> n/a Glob to exclude from directory and glob walks. Repeatable. Merged with the default ignores. A collection’s own exclude: is a membership rule and never filters a path you type; --exclude is the only thing that does.
--as <format> n/a Force an input format (extractor name). Required when reading from stdin.
-f, --format <pretty|json|csv|github|sarif|junit> pretty Output format. pretty, json, and csv render result rows unconditionally (CSV output describes the dialect). github, sarif, and junit render findings, so they are legal only with --check and only when the result carries a path column. See rows as findings. An unknown value, a findings format without --check, or a --check result without path is an error (exit 2).
-c, --config <path> discovered Path to a config file. When set, the file must exist or manni meta exits 2.
--no-config n/a off Ignore any discovered config file. -c and --no-config set the same option, so the one written later on the command line wins.
--collection <name> every collection Run over a configured collection rather than the whole corpus. Repeatable, one name per occurrence, never comma-separated; repeats collapse, and a name must match a declared collection exactly. Cannot be combined with positional paths, and needs a config file to select from (exit 2 for both). See collections as inputs.
--allow-empty n/a off Treat zero matched files as success instead of an error (exit 2). See empty input sets.
--no-gitignore n/a on Load files .gitignore covers as well. Filtering is on by default; see default ignores. Overrides config respectGitignore:.
--offline n/a off Accepted for parity with the other commands. It has no effect here. Reads and DML load no schema, and DDL resolves its set from disk and the bundled built-ins only. A URL ref refuses with “vendor it first” before anything could fetch, so there is no network request to suppress.

There is no -q/--quiet. get uses it to hide files and validate to hide passes, and a query result has no analogous noise. The rows are exactly what was asked for.

A script that feeds a runtime value into a query should bind it, never splice it into the SQL text. One apostrophe in a spliced value breaks the statement:

Terminal window
manni meta query --param author="O'Brien" \
"SELECT _path FROM docs WHERE author = \$author" docs/

--param name=value binds a string, referred to as $name, :name, or @name; name:=value parses the value as JSON for a typed bind. A parameter the SQL references with nothing bound, and a --param the statement never references, are both errors (exit 2). The full contract, shell-quoting pitfalls included, is in bound parameters.

--db <path> writes the same table, collection views included, to a SQLite file for any other tool to open. With no SQL, the export is the whole job:

Terminal window
manni meta query --db docs.db docs/
datasette docs.db # a browsable UI and JSON API over the corpus

The file is a regenerated artifact, so manni meta overwrites a SQLite database or an empty file at that path and refuses anything else. See exporting the database.

An UPDATE against docs edits the corpus, through the same comment-preserving, self-verifying writer fill uses:

Terminal window
# Preview: the per-file diff, nothing touched
manni meta query --dry-run "UPDATE docs SET draft = false WHERE draft IS NULL" docs/
# Apply it
manni meta query "UPDATE docs SET draft = false WHERE draft IS NULL" docs/
# Hold it in CI: pending changes fail until someone applies them
manni meta query --check "UPDATE docs SET draft = false WHERE draft IS NULL" docs/

A mutating statement applies by default, matching fill’s convention. One --dry-run flag means “show, don’t do” across every command that writes. --check implies the dry run, so a CI drift gate is always a read-only step. ALTER TABLE docs goes further and evolves the resolved schema itself, which is the M2 ratchet in one statement:

Terminal window
manni meta query "ALTER TABLE docs
ADD COLUMN reviewed TEXT NOT NULL DEFAULT 'pending'" docs/

The query reference carries the rest. It covers the write polarity and its all-or-nothing apply. It covers the DML vocabulary and what each statement means in file terms. It covers what an ALTER edits and every refusal it raises. It covers the declared-type bridge that turns formats and CHECK lists into schema constraints, and -s/--schema.

A key an external-metadata manifest owns is readable in every row and written to that manifest. The page is left alone, and the pretty line names the manifest: docs/auth.md: owner: platform -> identity [docs-meta.yaml]. Three writes still refuse (exit 2) before any file is written. One is an ALTER TABLE … RENAME COLUMN of an owned key. One is a change to the join field of a document that has an entry. The third is any write to a key a URL manifest owns. A key whose schema prefers external metadata and that no manifest owns is written to the page with one warning on stderr. On a terminal, it is offered to relocate. The query reference has each statement’s manifest meaning.

With --check and -f github|sarif|junit, result rows render as findings. That is one ::error annotation, SARIF result, or JUnit <failure> per row, through the same column convention named checks: use. Findings carry the rule id check:query/check, and JUnit testcases ship under the classname manni.query. A findings format without --check, or a result without path, is an error (exit 2). See rows as findings.

The named home for a rule your team keeps is config checks:, which validate runs with the same convention. query --check with a findings format is the ad-hoc spelling of the same pipeline.

-f csv renders result rows as CSV, for the single-table hop into a spreadsheet or a pandas one-liner (--db remains the heavyweight export). A header row is always emitted, line endings are LF, SQL NULL is an empty field, and arrays and objects stay JSON text. Quoting is RFC 4180, and -f csv --check is legal. CSV refuses a statement that produced changes, and a --db-only export (exit 2, naming pretty/json). The dialect in full is the CSV dialect.

In pretty format, an aligned table (header row, then values; SQL NULL prints as (null)) is followed by a row count. With --check, a ✓/✗ verdict line follows instead. A mutating statement renders as its diff instead: <file>: <key>: <from> -> <to> per change, then the mode’s verdict (applied, dry-run hint, or the --check ✓/✗). In json format, the bare array holds row objects for a read, and change objects for an edit. The --check verdict travels in the exit code, not the envelope. In csv format: the CSV dialect above, rows only.

With --db and no SQL, the export summary (Wrote <path> …) is the report. With both, the rows own stdout and the export note goes to stderr as a diagnostic.

Exit 0 means the query ran. With --check, it also returned no rows or pending changes; a mutating statement also applied cleanly. Exit 1 comes only from --check, when rows or pending changes came back (--check never applies, so its findings are always still pending). Exit 2 is operational, including SQL that cannot be prepared and every write refusal. A broken or refused statement is a usage error, not a finding.

Terminal window
manni meta query "SELECT _path, title FROM docs WHERE draft = 1" docs/
manni meta query "SELECT t.value tag, count(*) n FROM docs, json_each(docs.tags) t GROUP BY tag ORDER BY n DESC" docs/
manni meta query --check "SELECT slug, count(*) n FROM docs WHERE slug IS NOT NULL GROUP BY slug HAVING n > 1" docs/
manni meta query --check "SELECT _path AS path, 'duplicate slug ' || slug AS message FROM docs WHERE slug IN (SELECT slug FROM docs GROUP BY slug HAVING count(*) > 1)" docs/ -f github
manni meta query --check "SELECT d._path, d.author FROM docs d LEFT JOIN docs a ON a._path GLOB 'authors/*' AND a.slug = d.author WHERE d.author IS NOT NULL AND a._path IS NULL" docs/ authors/
manni meta query "SELECT _path FROM docs WHERE _present = 0" docs/ # nothing to validate yet
manni meta query -f csv "SELECT _path, title, last_reviewed FROM docs" docs/ > stale.csv
manni meta query --param author="O'Brien" "SELECT _path FROM docs WHERE author = \$author" docs/
cat page.md | manni meta query "SELECT title FROM docs" - --as markdown

Infer values for the metadata properties that are missing or invalid, and write back the ones the model is confident about. fill resolves the same schema set as validate, so the schema you already maintain is what determines which properties get proposed.

Terminal window
manni meta fill [paths...] [options]

fill sends the page and its schema to an LLM provider. By default it detects one, taking the first of these that your machine can actually use:

  1. anthropic, when ANTHROPIC_API_KEY is set.
  2. openai, when OPENAI_API_KEY is set.
  3. claude-cli, when a claude CLI on your PATH runs and claude --version succeeds. Sign-in is not checked, so an unauthenticated CLI is still selected and fails at the first call.
  4. llama-cpp, a local model that needs no key and costs nothing.

Pass --provider to pin one instead. fill reports which provider and model it used, in both output formats.

Argument Description
[paths...] Files, directories, or globs to fill. Use - to read from stdin, which prints the filled document to stdout and writes nothing to disk. Optional, and falls back to the configured collections. See the shared input model.
Option Argument Default Description
-s, --schema <ref> n/a Schema to fill against. Repeatable. When given, it overrides $schema and config for every file.
--ext <list> supported extensions Comma-separated extensions used when expanding directories and globs.
--exclude <glob> n/a Glob to exclude from directory and glob walks. Repeatable.
--as <format> n/a Force an input format. Required when reading from stdin.
--fields <list> all candidates Comma-separated top-level fields to fill. Everything else is left alone.
--confidence <n> 0.7 Minimum self-reported confidence, from 0 to 1, needed to write a value. A non-numeric or out-of-range value is an error (exit 2).
--dry-run n/a off Report proposals without writing them.
--provider <name> auto Inference provider: auto, anthropic, openai, claude-cli, llama-cpp, or mock. auto detects one; see above. An unknown name is an error (exit 2).
--model <model> provider default Model override. Needs a named provider, from either --provider or config fill.provider. A model name does not say which provider owns it, so pairing it with auto (including the default) is an error (exit 2).
--no-cache n/a cache on Bypass the proposal cache in .manni/meta/cache.
--local n/a off Run inference on this machine. Refuses a hosted provider even when auto would have picked one, claude-cli included, whose binary is local but whose inference is not. Errors (exit 2) if no local model is available.
--max-turns <n> n/a Stop after this many inference calls. Counts calls, not files: a document too long for one call is split across several.
--chunk-chars <n> 12000 Characters of document sent per call. The whole file is always sent; this decides how many calls that takes.
--concurrency <n> 4 Files inferred in parallel.
-f, --format <pretty|json|github> pretty Output format. github emits one annotation per required property that could not be filled. sarif and junit stay validate-only: they describe findings in files, and a skipped optional property is a proposal with a confidence score, not a finding. An unknown value is an error (exit 2).
-c, --config <path> discovered Path to a config file.
--no-config n/a off Ignore any discovered config file and run on the built-in default schema set. -c and --no-config set the same option, so the one written later on the command line wins.
--collection <name> every collection Run over a configured collection rather than the whole corpus. Repeatable, one name per occurrence, never comma-separated; repeats collapse, and a name must match a declared collection exactly. Cannot be combined with positional paths, and needs a config file to select from (exit 2 for both). See collections as inputs.
-q, --quiet n/a off In pretty output, hide files with nothing written and nothing left undone. A file whose required property could not be filled, or that errored, always prints. Those are the files that make the run exit 1. Has no effect on json or github.
--allow-empty n/a off Treat zero matched files as success instead of an error (exit 2). See empty input sets.
--no-gitignore n/a on Fill files .gitignore covers as well. Filtering is on by default; see default ignores. Overrides config respectGitignore:.
--offline n/a off Never fetch a remote schema, exactly as on validate. It governs schema loading only, because the inference provider is a separate network dependency. Pass --local to keep document content on this machine; the two flags bound different boundaries and are usually wanted together. Overrides config offline:.

A schema property is a candidate when it is missing from the file’s metadata, or present but invalid. A property that is already present and valid is never touched, and neither is $schema, which is schema wiring, not metadata. A candidate an external-metadata manifest owns is written into that manifest’s entry for the page, and the page is left alone. The pretty line ends with the manifest, /owner platform 0.92 → site-meta.yaml, and json adds destination. A candidate a URL manifest owns is a per-file error, because a fetched file cannot be written: "owner" is owned by manifest https://example.com/owners.yaml, which is fetched and cannot be written; set it in that repository.

A candidate whose schema prefers external metadata (x-manni-location) and that no manifest owns is written to the page, with one warning on stderr naming relocate. On a terminal, fill first offers to give it a manifest, before its first model request.

Every proposal carries a self-reported confidence between 0 and 1. On its own that is a weak signal. It is therefore the last of four gates. Three mechanical checks run first, and a high confidence cannot override any of them:

  1. The value must satisfy the property’s own subschema. fill builds the response schema out of your schema’s property definitions. A timestamp that is not a valid date-time is rejected before it is ever scored. When several schemas in the set define the same property, every one of their rules applies. -s order does not change what the model may propose.
  2. The property must be one manni meta asked about. The model cannot introduce keys your schema does not define.
  3. The document must still validate afterwards. Any value that would leave the page failing its own schema is reverted and reported.

Only then is --confidence applied. Values below the threshold are skipped, and reported by name and score so you know what still needs a human. The confidence and the model’s reasoning stay in the report; they never reach your document.

Because the cache stores the proposal before gating, re-running with a different --confidence re-scores the cached proposals and costs nothing. That is the intended way to tune the threshold on a real docset.

The cache lives in .manni/meta/cache relative to the working directory. It is machine-local and safe to delete at any time. Add .manni/meta/ to your .gitignore. Pass --no-cache to bypass it entirely.

fill can only write formats it can round-trip without disturbing the rest of the document. markdown and mdx are always writable. rst and asciidoc are writable only when the file already has a fenced front matter block. Their native docinfo and header syntax is lossy to read back, and a bare --- means something else in both languages. xml (including .dita and .ditamap) and html are writable. Run manni meta schemas to see the current writability of each format. A file manni meta cannot write is reported as a per-file error, not a failed run.

fill exits 1 when a property your schema lists as required could not be filled confidently, or when any file errored. That is work still to do. Skipped optional properties are a normal outcome and leave the exit code at 0. Usage and operational failures exit 2, as elsewhere.

-f github reports the work fill could not do. That is one workflow command per property the schema requires that was not filled. It is exactly the set that drives exit 1, and optional skips stay silent, matching the exit-code rule.

::error file=docs/api/legacy.md::[fill] /description is required and was not filled (confidence 0.42 is below the 0.7 threshold)

There is no line=. A proposal is about a property that is missing from the document, so there is nothing in the file to point at. GitHub anchors a file-only annotation to line 1.

The format is available whether or not --dry-run is set. Annotations describe what is still missing, which is as useful on a run that writes as on one that previews.

For each file where fill writes at least one field, it records those fields in meta-provenance in the same write. --dry-run reports the entry it would write. The entry names the run’s model, the JSON Pointer of each field written, and each one’s confidence. A pointer is escaped per RFC 6901, so a key a/b is /a~1b. Where the page already has an entry for the model, the new pointers are appended to it. A person deletes the entry once the values are reviewed.

✓ docs/limits.md
/description How many requests a token may send, and how bursts work. 0.91
/intent Stay under the rate limit 0.84
meta-provenance claude-sonnet-4-5: /description, /intent

The entry is a side record. It never changes the summary or the exit code. Three cases leave it out while the fields are still written. In json, metaProvenance is then {"written": false, "skipReason": "<reason>"}, and manifest-owned adds "manifest" with the manifest’s URL:

Case pretty line skipReason
Checked with the entry and without it, the page’s schemas report an error only the entry brings, such as additionalProperties, unevaluatedProperties or propertyNames. meta-provenance not written: this page's schemas do not allow it schema-mismatch
A URL manifest in one of the page’s collections owns meta-provenance. meta-provenance not written: owned by manifest https://example.com/meta.yaml, which is fetched and cannot be written manifest-owned
The format’s writer cannot hold a list of entries, as in an HTML or XML attribute. meta-provenance not written: html metadata cannot hold it unwritable

A written entry is {"written": true, "entry": {...}}. When a local manifest owns meta-provenance, the entry is merged into the page’s entry there, the pretty line ends with → private/meta.yaml, and json adds "destination": "private/meta.yaml". The key is absent when no field was written. Neither provenance nor meta-provenance is ever a candidate, and fill leaves both out of what it sends a model.

Terminal window
manni meta fill docs/ --dry-run # preview, write nothing
manni meta fill docs/ --confidence 0.9 # only near-certain values
manni meta fill page.md --fields description,tags
manni meta fill docs/ -f github # annotate what it could not fill
manni meta fill docs/ --dry-run -q # only files with something to show
cat page.md | manni meta fill - --as markdown # filled document to stdout

Stamp the managed fields into each document from evidence. The evidence is git history, a CODEOWNERS file, and the pull request or merge request history GitHub or GitLab keeps, read through the gh or glab CLI. For any other field, it is the output of a command the config names. Which fields are managed comes from config derive.fields, or from --fields for one run. derive is the only command that writes a managed field. validate is what reports a stamp that has gone stale.

One managed field is a list rather than a value. provenance records which body lines a machine wrote, as ranges pinned by an integrity hash. It has its own option and positional form, below, and its own journey.

Terminal window
manni meta derive [paths...] [options]
Argument Description
[paths...] Files, directories, or globs to stamp. Optional, and falls back to the configured collections. See the shared input model. - is refused (exit 2), because a piped document has no history behind it: cannot derive <stdin>: no history behind it. A file may carry a range, <path>:L or <path>:L1-L2, in file lines. The range names the lines --generated-by attributes, and is legal only beside it. Several ranges of one file attribute each of them. The file must be one the run reads, so a range on a directory or a glob is refused (exit 2). It is split as manni cite add splits a page argument, at the last :L suffix, so a Windows drive letter is not a range. The refusals are in exit 2 from a derive source.
Option Argument Default Description
--fields <list> config derive.fields Comma-separated managed fields to stamp this run. Each must be one of the seven derivable fields or a key with an entry in config derive.commands. An unknown or non-derivable name is an error (exit 2) listing the seven and pointing at derive.commands. A field a collection’s local external-metadata manifest owns is written into that manifest, and the pretty report ends its line with → docs-meta.yaml:14 (json adds destination and destinationLine). A field a URL manifest owns is refused (exit 2), the same way loading the config refuses it in derive.fields. The message is "owner" is owned by manifest https://example.com/owners.yaml, which is fetched and cannot be written; set it in that repository. No config derive.fields and no flag is an error (exit 2): nothing to derive: set derive.fields in manni.config.yaml or pass --fields.
--sources <list> config derive.sources, else all five Comma-separated sources to consult: git, codeowners, github, gitlab, command. Only the one of github and gitlab that matches the origin remote is consulted. command covers every field with an entry in config derive.commands. A field whose only sources are excluded derives null and is left alone. This is the opt-out for a checkout that cannot answer a source, such as a shallow clone.
--generated-by <name> MANNI_GENERATED_BY The machine that wrote the uncommitted body lines, recorded in provenance. With a <path>:L1-L2 positional, it attributes those lines instead, committed or not, unless git evidence names another machine for any of them (exit 2). Without the flag, MANNI_GENERATED_BY is read. The flag wins whenever it is given, even empty. Both values are trimmed, and a blank one is unset. The flag, or a range, is an error (exit 2) on a run whose fields leave out provenance. The message is --generated-by attributes provenance, which is not in --fields. Add provenance, or drop --generated-by. The variable alone never is, so an agent session can export it once. A file where the flag finds no uncommitted body lines gets a notice on stderr, and the exit code is unaffected. The notice is docs/limits.md: no uncommitted body lines; --generated-by attributes only what is not yet committed.
--dry-run n/a off Report what would change and write nothing.
--check n/a off Implies --dry-run. A managed field that is stale or unset is a finding, and the run exits 1 if there is any. A provenance range that only moved is not a finding. This is the CI spelling.
-f, --format <pretty|json|github|sarif|junit> pretty Output format. github, sarif, and junit describe findings, so they are legal only with --check. Without it they are an error (exit 2): sarif is a findings format, which only --check produces. An unknown value is an error (exit 2).
--no-cache n/a cache on Bypass the GitHub or GitLab review cache in .manni/meta/review-cache. Only a merged pull request’s answer is ever cached, so this matters when a merged answer was recorded wrongly, never for an open one.
--ext <list> supported extensions Comma-separated extensions used when expanding directories and globs.
--exclude <glob> n/a Glob to exclude from directory and glob walks. Repeatable. Merged with the default ignores. A collection’s own exclude: is a membership rule and never filters a path you type; --exclude is the only thing that does.
--as <format> n/a Force an input format (extractor name, e.g. markdown).
--collection <name> every collection Run over a configured collection rather than the whole corpus. Repeatable, one name per occurrence, never comma-separated; repeats collapse, and a name must match a declared collection exactly. Cannot be combined with positional paths, and needs a config file to select from (exit 2 for both). See collections as inputs.
-c, --config <path> discovered Path to a config file. When set, the file must exist or manni meta exits 2.
--no-config n/a off Ignore any discovered config file. -c and --no-config set the same option, so the one written later on the command line wins. With no config there is no derive.fields, so --fields is required.
--allow-empty n/a off Treat zero matched files as success instead of an error (exit 2). See empty input sets.
--no-gitignore n/a on Stamp files .gitignore covers as well. Filtering is on by default; see default ignores. Overrides config respectGitignore:.

A managed field is written when its derived value is non-null and differs from what the document carries. Lists compare as sets, so a reordered authors is not a change. A field whose evidence is missing is left alone and reported as unknown. That covers a page with no approval yet, a file not yet committed, and a path outside any repository. The document is edited through the same comment-preserving writer fill uses, so a format fill cannot write is reported as a per-file error here too. The rules for each field are in the derivable fields.

provenance is judged range by range. A range whose pinned text changed, a range git attributes to another machine, and machine lines with no entry are each re-derived and written. A range that only moved gets its lines rewritten. A range nothing contradicts is kept. Where an external-metadata manifest owns provenance, the entries go into that manifest and the page is not touched. A format whose metadata is not a fenced block, such as HTML, XML or DITA, cannot hold the record on the page. That file is a per-file error, exit 1, and the manifest is where its record goes.

Every other managed field works the same way. A field a local manifest owns is compared against the manifest’s value and written there, and the pretty line ends with the destination:

docs/install.md
created (unset) → 2026-09-13 (git: added in a32e411 (2026-09-13))
owner ["@old-team"] → ["@platform-docs"] (codeowners: .github/CODEOWNERS:1) → site-meta.yaml:2

A field whose schema prefers external metadata (x-manni-location) and that no manifest owns is stamped on the page. One warning goes to stderr: manni: wrote owner to 2 pages in collection site; the schema prefers external metadata, and no manifest owns it. Run manni meta relocate to move it. On a terminal, and outside --dry-run and --check, derive first offers to create the manifest.

A requested source that cannot answer is an error (exit 2) naming the fix, never a skipped field. A skipped field would make a stale stamp read as current, which is the false green the comparison exists to end.

Situation Message
The checkout is shallow, as actions/checkout leaves it by default. git source unavailable: this checkout is shallow; use actions/checkout with fetch-depth: 0, or --sources codeowners,github,gitlab
gh or glab is not on PATH. github source unavailable: gh is not on PATH (origin is github.com); install gh and run gh auth login, or drop reviewed-by from --fields
--sources names only the one of github and gitlab the origin remote does not match. the origin remote is github.com, which is GitHub; add github to sources, or drop reviewed-by and last-reviewed from the managed fields
There is no origin remote. no origin remote to tell GitHub from GitLab
The origin host names neither platform, and sources does not list exactly one of the two. the origin remote is git.example.com, which names neither GitHub nor GitLab; list exactly one of github, gitlab in derive.sources to say which it is
A configured command exited non-zero. command source unavailable: `jq -r .version package.json` failed (exit 2): jq: error: Could not open package.json; narrow --sources or --fields
A configured command’s program is not on PATH. command source unavailable: `jq` is not on PATH (derive.commands.verified-against); narrow --sources or --fields
A configured command ran past its timeout. command source unavailable: `node scripts/source-for.mjs docs/install.md` timed out after 60s; narrow --sources or --fields

A gh or glab that is present and not logged in is refused the same way, naming gh auth login or glab auth login. So is a config codeowners: path that is not there, naming the path. Narrow --sources, or config derive.sources, when a source is genuinely out of reach. A field whose only source is excluded derives null and is never stale.

In pretty format, each changed file lists its fields as <field> <from> → <to> (<source>: <evidence>), an unchanged file prints current, and a summary line counts files, changes, and fields written. With --dry-run the summary says so. With --check a stale or unset field renders as a finding in the chosen format, under the rule id derived:stale/derived, at the field’s line. In json format the run is { results, summary, dryRun, check, sources, frame }, where each result carries its fields with asserted, derived, source, evidence, status, and written.

provenance prints one line per range that changes. The lines are file lines of the file as derive read it:

docs/limits.md
provenance lines 9-11: (unset) → claude-fable-5 (git: uncommitted)
provenance lines 21: moved from 19 (git: pin)
provenance lines 29: claude-sonnet-5 → Claude Opus 5 (git: blame 6683e73)
provenance lines 16-18: claude-fable-5 → re-derived (git: pin)

The four shapes are a new range, a moved one, one git attributes to another machine, and one whose pinned text changed. When a manifest holds the record, the file header names it, as private/provenance.yaml (for docs/limits.md). The summary counts ranges rather than fields when provenance is among the fields, as 1 file, 1 changed, 1 range written. Under --check, each changed, stale or unset range is its own finding at the range’s first file line.

In json format the provenance field adds ranges, one object per range. Each has lines in file lines, generated-by, integrity, status, evidence and written. status is current, moved, changed, stale or unset, and a moved or stale range adds from. The field adds manifest when a manifest holds the record, and summary adds ranges, the count of ranges written.

$ manni meta derive
docs/install.md
last-updated 2026-08-20 → 2026-09-07 (git: body changed in 424f71a)
owner (unset) → ["@platform-docs"] (codeowners: .github/CODEOWNERS:12)
docs/faq.md current
2 files, 1 changed, 2 fields written

Exit 0 means the run applied, or had nothing to do. Exit 1 means a file could not be read or written (an unparseable document, a read-only format, or provenance on a page whose metadata is not fenced), with or without --check. Under --check alone, it also means the run filed a finding, for a managed field that is stale or unset. For provenance that is a range that changed, is stale or is unset. A range that only moved passes. Exit 2 is usage or operational, including every source that could not answer.

Terminal window
manni meta derive # stamp config derive.fields over every collection
manni meta derive --collection guides # stamp one configured collection
manni meta derive --dry-run docs/install.md # what would change, nothing written
manni meta derive --check -f github # CI: a stale stamp is an annotation, exit 1
manni meta derive --fields reviewed-by,last-reviewed # after an approval, stamp who and when
manni meta derive --sources git,codeowners # no gh on this machine
manni meta derive --fields owner --no-cache docs/ # re-read GitHub or GitLab
manni meta derive --fields verified-against # stamp the version a config command reads
MANNI_GENERATED_BY=claude-fable-5 manni meta derive --fields provenance # attribute what an agent just wrote
manni meta derive docs/limits.md:12-31 --generated-by claude-fable-5 # attribute named lines, committed or not

Move each value to where it belongs. A schema says where with x-manni-location. page keeps a top-level key in the document’s own metadata, and external keeps it in a collection’s external-metadata manifest. Out of the page go the values a schema prefers external and the values a manifest already owns. Into the page go the manifest values a schema prefers on the page, and the key leaves the manifest’s keys:.

Terminal window
manni meta relocate [paths...] [options]

A value with no manifest gets one. A page in a collection uses the first one it belongs to. With no collections, relocate creates one named default whose paths: are the targets you typed (docs/ becomes docs/**). With exactly one collection, the target naming the page is appended to its paths:. With several, and the page in none, the value stays. The manifest is the collection’s first local file: entry, or <collection>.metadata.yaml beside the config file. With no config file, manni.config.yaml is created at the git root, else the working directory. Config edits keep comments and key order.

A change to a manifest’s keys: moves that key for every page in the collection, including pages outside the paths you named. The output says how many. A path appended to paths: moves every key the manifest owns out of the pages it brings in, whatever --fields names. A key leaving keys: stays on a page that already carries it. A field join’s own field never moves. When the page and the manifest both hold a value and the two agree, the copy on the wrong side is dropped. If a write fails, relocate puts back every file it already wrote, so the run lands whole or not at all.

Argument Description
[paths...] Files, directories, or globs to relocate. Optional, and falls back to the configured collections. See the shared input model. - is refused (exit 2): relocate moves values between documents and a collection's manifest, and stdin is not a document on disk.
Option Argument Default Description
--fields <list> every field Comma-separated fields to move. Each must carry an x-manni-location mark, or be manifest-owned, for some file in the run; otherwise exit 2: "nope" has no x-manni-location mark and no owning manifest for any file in this run.
--collection <name> every collection Run over a configured collection. Repeatable, one name per occurrence. Cannot be combined with positional paths (exit 2).
-s, --schema <ref> n/a Schema whose marks decide each preference. Repeatable. When given, it overrides $schema and config for every file.
--dry-run n/a off Report what would move and what would be created, and write nothing. Manifest lines are omitted.
-f, --format <pretty|json> pretty Output format. Anything else is an error (exit 2): relocate --format must be pretty or json; got "sarif".
--ext <list> supported extensions Comma-separated extensions used when expanding directories and globs.
--exclude <glob> n/a Glob to exclude from directory and glob walks. Repeatable.
--as <format> n/a Force an input format (extractor name, e.g. markdown) for the files your paths match. Other pages a keys: or paths: change reaches are read by extension.
-c, --config <path> discovered Path to a config file. When set, the file must exist or manni meta exits 2.
--no-config n/a off Refused (exit 2), because relocate writes the config: relocate writes collections: and externalMetadata: to the config file, and --no-config rules one out.
--allow-empty n/a off Treat zero matched files as success instead of an error (exit 2). See empty input sets.
--no-gitignore n/a on Relocate files .gitignore covers as well. Overrides config respectGitignore:.

In pretty format, the first lines say what the config gains or loses. Then each page lists its values as <key> → <manifest>:<line> (out of the page), <key> ← <manifest> (into the page), or <key> stays: <reason>. A page reached only through a keys: change is counted, not listed, unless a value on it stays. The last line counts files and values.

Using manni.config.yaml (.)
Created site.metadata.yaml; collections[site].externalMetadata[0] owns owner, authors.
docs/faq.md
owner → site.metadata.yaml:2
docs/install.md
authors → site.metadata.yaml:4
owner → site.metadata.yaml:6
2 files, 3 values moved to 1 manifest

Under --dry-run a config line leads with Would create, Would add or Would remove, and says would own. The summary says would move, and no manifest line is printed. A dry keys: change reads Would add owner to docs-meta.yaml's keys, moving it out of every page in collection site. The first lines take one of these shapes:

Config change Line
A manifest is created Created site.metadata.yaml; collections[site].externalMetadata[0] owns owner, authors.
A collection is created (no collections before) Created collection default (paths: docs/**, guides/intro.md) and default.metadata.yaml; it owns authors, owner.
A target is added to the one collection’s paths: Added notes/** to collection site's paths; docs-meta.yaml now owns owner, authors.
A key is added to keys: Adding owner to docs-meta.yaml's keys moves it out of every page in collection site, 2 beyond the paths you named. The count after the comma appears only when there are such pages.
A key is removed from keys: Removing title from docs-meta.yaml's keys moves it into every page in collection site.
keys: is left empty docs-meta.yaml no longer owns any keys and is no longer declared; delete it when you are ready. The file is left on disk.

A value that stays names its reason:

reason in json pretty line
no-home owner stays: this document is in none of the 2 collections, so it has no manifest
read-only-format owner stays: the rst format cannot write this document's metadata
no-join-value owner stays: this document has no id, which api-meta.yaml joins on
values-differ owner stays: the page and docs-meta.yaml hold different values
url-manifest owner stays: https://example.com/owners.yaml is fetched and cannot be written, in either direction
unreadable owner stays: this document could not be parsed: Invalid YAML frontmatter: <parser message>

Only a page outside the paths you named stays as unreadable. A named page that cannot be parsed stops the run with exit 2. A key cannot leave keys: while an unreadable page may still take its value from the manifest.

In json format the run is one object:

Field Shape
dryRun boolean
config { file, created, collectionsCreated, pathsAdded }
manifests[] { file, collection, created, keysAdded, keysRemoved, undeclared }
files[].moved[] { key, to, reason } plus manifest and line when to is manifest, or from when to is page. to is page or manifest. reason is preferred (a mark) or owned (a manifest already owns the key). line is omitted under --dry-run.
files[].stayed[] { key, reason }, with a reason from the table above
summary { files, moved, stayed, manifestsCreated }

Exit 0 when every value is where it belongs afterwards, or there was nothing to do (0 files, nothing to move). Exit 1 when at least one value stayed. Exit 2 for an operational error:

Cause stderr
- among the paths manni: relocate moves values between documents and a collection's manifest, and stdin is not a document on disk.
--no-config manni: relocate writes collections: and externalMetadata: to the config file, and --no-config rules one out.
A --fields name nothing marks or owns manni: "nope" has no x-manni-location mark and no owning manifest for any file in this run.
An unknown --collection manni: no collection named "nope" in manni.config.yaml. Configured: site.
--collection with paths manni: --collection selects a configured collection; it cannot be combined with paths.
A format other than pretty or json manni: relocate --format must be pretty or json; got "sarif".
The manifest path to create exists and is not declared manni: site.metadata.yaml already exists and is not a manifest of collection site; declare it under externalMetadata, or move it.
No paths and no collections manni: No files to relocate. Pass paths/globs, or declare a collection under `collections:` in manni.config.yaml.
A file changed between planning and writing, for example while a writer’s prompt waited manni: docs/install.md changed after the relocation was planned, so nothing was written. Run the command again.
A write failed, and every file already written was put back manni: Could not write docs/faq.md: <reason>. Nothing was changed: the 2 files already written were restored.
A write failed, and a file could not be put back manni: Could not write docs/faq.md: <reason>. The run was rolled back, but manni.config.yaml could not be restored: <reason>. Restore it from version control.
Terminal window
manni meta relocate # every collection, as config declares it
manni meta relocate --dry-run # what would move and be created
manni meta relocate docs/install.md --fields owner
manni meta relocate docs/ guides/intro.md # no collections yet: creates collection default
manni meta relocate -f json --dry-run # the scripting form

List the built-in schemas and the supported input formats. On its own it takes no positional arguments and reads no files. Its infer subcommand reads your documents; its vendor subcommand is the one that reaches the network and writes to the repository.

Terminal window
manni meta schemas [options]
Option Argument Default Description
-f, --format <pretty|json> pretty Output format. Anything else is a usage error (exit 2), including github, sarif, and junit. See which command produces which format.

In pretty format, schemas lists each built-in schema by id and title. It then lists each input format with its extensions, whether it is implemented, and whether fill can write to it. In json format it prints { builtins, formats }, where builtins is an array of { id, title, description } and formats is an array of { name, extensions, implemented, writable }.

Terminal window
manni meta schemas
manni meta schemas -f json

Report what metadata your documents already carry, and draft a schema from it. Purely statistical and offline, with no inference provider, no network, and no model. It reads the metadata blocks, counts them, and prints a table.

Terminal window
manni meta schemas infer [paths...] [options]
Argument Description
[paths...] Files, directories, or globs to scan. Use - to read from stdin. Optional, and falls back to the configured collections. See the shared input model.
Option Argument Default Description
--out <path> n/a Write the draft schema to this file. Refuses to overwrite an existing file, and refuses a path .gitignore covers; see below.
--min-coverage <pct> 0 Hide keys below this coverage percentage. The default shows everything, deliberately: the long tail is where “one team’s convention” hides.
--ext <list> supported extensions Comma-separated extensions used when expanding directories and globs.
--exclude <glob> n/a Glob to exclude from directory and glob walks. Repeatable. Merged with the default ignores. A collection’s own exclude: is a membership rule and never filters a path you type; --exclude is the only thing that does.
--as <format> n/a Force an input format (extractor name, e.g. markdown). Required when reading from stdin.
-f, --format <pretty|json> pretty Output format. Anything else is a usage error (exit 2).
-c, --config <path> discovered Path to a config file. When set, the file must exist or manni meta exits 2.
--no-config n/a off Ignore any discovered config file. -c and --no-config set the same option, so the one written later on the command line wins.
--collection <name> every collection Run over a configured collection rather than the whole corpus. Repeatable, one name per occurrence, never comma-separated; repeats collapse, and a name must match a declared collection exactly. Cannot be combined with positional paths, and needs a config file to select from (exit 2 for both). See collections as inputs.
--allow-empty n/a off Treat zero matched files as success instead of an error (exit 2). See empty input sets.
--no-gitignore n/a on Scan files .gitignore covers as well. Filtering is on by default; see default ignores. Overrides config respectGitignore:.
--offline n/a off Accepted and ignored, so one flag set covers every command. infer resolves no schema and contacts no provider, so it is already offline and there is nothing for the flag to suppress.
1,204 files scanned · 38 with no metadata block
key coverage types sample
title 99.8% string "Getting started"
type 61.4% string (7 enum) guide | reference | how-to | …
owner 12.0% string "docs-team"
lastReviewed 3.2% string (date) "2026-04-01"

Each column answers one question:

Column What it tells you
coverage Near 100% means you can make it required today. Middling means require it behind a baseline ratchet. Low single digits means it is one team’s convention, not a standard.
types A distribution with counts. string ×900, number ×4 is four data errors, not a type union. The four are listed by file and line beneath the table.
sample What the values look like, which is what tells you whether the key means what its name suggests.

Files with no metadata block get their own line, not a quieter denominator. They pass a require-nothing schema and fail the moment any key becomes required, so their count is the number you need before you ratchet.

infer always exits 0 when it produced a report. It has no findings and no verdict; it is a measurement, and the decisions are yours.

--out writes a schema that requires nothing and constrains only what was observed. That is the dominant type per key. It adds minLength: 1 on a string key where no empty value was ever seen. It adds an enum where the values are plainly a vocabulary.

Two more rules keep the draft honest:

  • Dominant type, never a union. A key that is a string in 900 files and a number in 4 gets "type": "string". The four are named in the report as the data errors they are. A union would encode the typo as the contract.
  • enum only when the values are a vocabulary. At most 20 distinct values, and at most 5% of the files scanned. Both conditions: a 30-file repo where every title is unique passes a ratio test alone and would produce a 30-value enum for prose.

--out refuses to overwrite an existing file. The draft is a starting point you then edit, and clobbering it would throw the edits away. It also refuses a path .gitignore covers, for the same reason vendor does. A generated schema you cannot commit validates on your machine and is simply absent in CI.

Only top-level keys are reported. Whether author is present across the docset is the standard-level question; whether author.name exists is a schema-authoring detail you settle while editing the draft.

Terminal window
manni meta schemas infer docs/
manni meta schemas infer docs/ --min-coverage 5
manni meta schemas infer docs/ --out ./schemas/permissive.json
manni meta schemas infer docs/ -f json

Download a remote schema into this repository and pin it in config. The contract then lives in your own history, instead of depending on a host staying up.

Terminal window
manni meta schemas vendor <url> [options]
Argument Description
<url> The http(s) URL to download. Required.
Option Argument Default Description
--dir <path> ./schema Directory for the committed copy. Must be a path the repository tracks; see below.
-c, --config <path> discovered Path to the config file to rewrite.

Vendoring writes two things. One is the schema file itself. The other is a schemas: entry naming it, where it came from, and the digest of the bytes that were fetched.

schemas:
- ref: ./schema/2.1.json
source: https://schemas.example.com/house/2.1.json
integrity: sha256-392da4339d91f59e3161a083a5d95913b6c7f98668e2bdfbebae53dc123e8994

Commit both. From then on the schema resolves from the local copy, so the run no longer depends on the origin being reachable. Any later change to those bytes is an integrity failure, rather than a contract that shifted without anyone noticing.

validate, get, query, fill, derive, and schemas infer resolve their inputs the same way.

  • Positional paths are files, directories, or globs. A named file is always read. A directory or glob is expanded and filtered to the extensions in --ext. By default that is the extensions of the implemented formats. It is then filtered to what your repository does not ignore.
  • - reads stdin. Stdin requires --as <format> to choose an extractor, because there is no filename extension to detect from. Without --as, manni meta exits 2. derive refuses - outright, because a piped document has no history to derive from.
  • The configured collections are the fallback. When no positional paths are given, manni meta runs over every collection the config declares, in declaration order, or over the ones --collection names. Positional paths, when present, replace that entirely.
  • No input and no collection is an error. With no paths and nothing under collections:, manni meta exits 2 with an operational error naming both remedies. Empty output is never produced silently. See collections as inputs.
  • Matching no files is also an error. A named path that does not exist exits 2, and so does an input set resolving to zero files. --allow-empty suppresses both. See empty input sets.

A collection is a named set of documents, declared once at the top level of manni.config.yaml and read by every tool in the family. With no positional paths, a run covers every declared collection, in declaration order. --collection <name> narrows it to the ones you name.

Terminal window
manni meta validate # every collection
manni meta validate --collection guides # one of them
manni meta validate --collection guides --collection blog # two

The flag repeats, one name per occurrence, and never splits on commas. One separator per list is the rule across the whole CLI.

  • A positional variadic is space-separated.
  • A <list> option is comma-separated and given once.
  • A repeatable option takes one value per occurrence.

So --collection guides,blog is a single name, and no collection has it:

Terminal window
$ manni meta validate --collection guides,blog
manni: no collection named "guides,blog" in manni.config.yaml. Configured: guides, blog.
exit=2

Naming a collection twice is not an error, because repeats collapse. Naming one that is not declared is, since a typo that silently narrowed a run to nothing would read as a clean pass. The other two refusals are structural. The flag selects from the config, so it can neither take paths beside it nor run without a config to select from.

Terminal window
$ manni meta validate --collection guides docs/x.md
manni: --collection selects a configured collection; it cannot be combined with paths.
exit=2
$ manni meta validate --collection guides --no-config
manni: --collection needs a config file to select from.
exit=2

- (stdin) is not a path in that sense and is allowed beside --collection.

With no paths, no --collection, and nothing declared under collections:, there is nothing to run over, and that is an operational error rather than empty output. The verb matches the command. It is validate, read for both get and query, fill, derive, and scan for schemas infer:

manni: No files to validate. Pass paths/globs, or declare a collection under `collections:` in manni.config.yaml.

A file is a member of a collection when it matches one of that collection’s paths: and none of its exclude:. Both are matched relative to the config file’s directory. Membership is decided per file and it is what external metadata attaches to. A file that is a member of no collection gets no manifests, and a file that is a member of two gets both collections’ manifests. Where the file came from, the walk or the command line, makes no difference. Membership is arithmetic on the path, so it costs nothing per file and never touches the filesystem. Stdin, and any path outside the config’s directory, are members of nothing.

That makes a collection’s exclude: a membership rule and nothing more. It does not filter a path you type. manni meta validate docs/ reads every document under docs/, including ones a collection excludes, because you named docs/. --exclude is the spelling for “and skip these”.

Corpus checks run only when the resolved file set is the whole configured corpus. --collection narrows it, so they are skipped, with a notice on stderr naming the collections in declaration order however they were given on the command line:

manni: corpus checks skipped: run is scoped to collections guides, blog

A check is SQL over the projection, and every unselected collection’s view would be empty. A FROM blog check would therefore pass by having nothing to fail on. Skipping and saying so is the honest answer. Every declared collection still gets its view, selected or not, so a query naming one is never a SQL error.

Resolving zero files is an error (exit 2), not a pass. Exit 0 means every file passed. With no files there is no verdict, so reporting success would turn a glob that stopped matching into a permanently green gate that checks nothing.

Two cases, both exit 2:

Situation Message
A named path that does not exist, as in manni meta validate docs/typo.md File not found: "docs/typo.md".
Everything resolved, but nothing matched, as in manni meta validate "docs/**/*.nomatch" No files matched. Patterns tried: …

A named path is reported even when other inputs did match, so manni meta validate good.md typo.md is an error rather than a partial success. --ext and --exclude filters are named in the message when they were in play. “No files matched” is baffling when the glob plainly matches files on disk. So is .gitignore, which is the one filter you never wrote on the command line:

No files matched. Patterns tried: "**/*.md".
Filters applied — .gitignore skipped 12 (pass --no-gitignore to check them).

Reading stdin with - is one input, so an empty piped document still produces a verdict and is unaffected.

Pass --allow-empty (or set allowEmpty: true) when matching nothing is expected. That covers a shared CI template running across repos that may have no docs yet. It also covers a pre-commit hook whose file list can be empty.

Directory and glob expansion always skips these globs, family-wide, in addition to anything from --exclude:

Glob Skips
**/node_modules/** Dependency directories.
**/.git/** The Git metadata directory.

Dotfiles and dot-directories are also excluded from directory and glob walks by default. An explicitly named file is always read, even if it matches an ignore glob or is a dotfile.

A collection’s own exclude: is not in this list, because it is not a walk filter. It decides membership, which is a different question from which files a walk reaches.

Directory and glob expansion also skips anything your repository already ignores. A built site (build/, _site/, .docusaurus/), a vendored copy, or a local scratch directory is not validated. Generated Markdown usually has transformed or stripped front matter, which produces violations nobody can fix in source. Restating .gitignore in exclude: also means two lists to keep in sync.

The question is put to git itself, one git check-ignore per run, so the answer is git’s. Nested .gitignore files, directory-only patterns, anchoring, .git/info/exclude, and core.excludesFile all behave exactly as they do for git status. Notably, a negation cannot re-include a file below an excluded directory. With tmp/ and then !keep.md, docs/tmp/keep.md stays ignored.

Four things bound it:

  • Explicitly named files are never filtered. manni meta validate build/generated.md validates that file. Filtering applies only to directory and glob expansion, exactly like the extension filter.
  • The count is reported. A run that skipped something says so: 1 file checked, 1 passed, 0 failed, 0 errors, 1 skipped by .gitignore, and json output carries summary.gitignoreSkipped. The count covers only files that passed the extension filter, so it answers “how many candidate documents did .gitignore take away”. --exclude removals are not counted, because those you asked for. schemas infer reports the same count on its headline (… · 1 skipped by .gitignore), where it is what tells you the coverage percentages below were computed over a smaller denominator.
  • Outside a repository, nothing is filtered. An extracted tarball, npm pack output, or a container with no git binary runs exactly as before, and silently. Set respectGitignore: true explicitly to be told on stderr when filtering was asked for and could not happen.
  • --no-gitignore turns it off for one run, and respectGitignore: false for a repository.