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.
manni meta [global options] [command] [command options] [arguments]Global options
Section titled “Global options”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.
manni meta [options] [command] [command options] [arguments]Options
Section titled “Options”| 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.
meta validate
Section titled “meta validate”Validate the metadata in the given files, directories, or globs against the resolved schema set for each file. This is the default command.
manni meta validate [paths...] [options]manni meta [paths...] [options]Arguments
Section titled “Arguments”| 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. |
Options
Section titled “Options”| 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.
Baselines
Section titled “Baselines”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.
manni meta validate --write-baseline # record today's findings, then exit 0manni meta validate --baseline # from now on, fail only on new onesThe file is JSON, path-keyed, with a sorted array of violation fingerprints per document, so it diffs and merges legibly:
{ "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 occurline 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.1to a URL serving the same schema changes every fingerprint. manni meta cannot tell it is the same contract. - Editing a
pattern,enum, orminLengthrule. Those keywords have no stablesubject, 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 errors3 baselined findings, 1 no longer occurs — run --write-baseline to pruneEntries 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)Examples
Section titled “Examples”manni meta validate # every configured collectionmanni meta validate --collection guides # one of themmanni meta validate docs/ # walk a directorymanni meta validate "**/*.md" -f github # CI annotationsmanni meta validate "**/*.md" -f sarif > out.sarif # code scanningmanni meta validate "**/*.md" -f junit > junit.xml # the CI "Tests" tabmanni meta validate page.md -s google:okf:0.1 -s ./my.schema.jsoncat page.md | manni meta validate - --as markdown # read from stdinmanni meta validate --write-baseline # record today's backlogmanni meta validate --baseline # fail only on new findingsmeta get
Section titled “meta get”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.
manni meta get --fields <list> [paths...] [options]manni meta get [fields] [paths...] [options]Arguments
Section titled “Arguments”| 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.
Naming the fields
Section titled “Naming the fields”The field list can be given either way, and there is one rule:
If
--fieldsis present, every positional is a path. Otherwise the first positional is the field list.
manni meta get title,type docs/intro.md # positional field listmanni meta get --fields title,type docs/intro.md # the flag; both positionals are pathsmanni 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:
$ manni meta get docs/intro.mdmanni: "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=2A 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.
Nested fields
Section titled “Nested fields”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/nameor/tags/0. This is the same pointervalidateprints 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~1bfor the keya/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.
Options
Section titled “Options”| 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. |
Output
Section titled “Output”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:
$ manni meta get title,owner,last-updated docs/install.mddocs/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-deriveddocs/install.md: title=Install the operatorA 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.
Examples
Section titled “Examples”manni meta get title,type docs/intro.mdmanni meta get --fields title,type docs/intro.mdmanni meta get author.name,/author/email docs/intro.mdmanni meta get type "**/*.md" -f jsonmanni meta get owner "docs/**/*.md" -q # only the files that resolve onemanni meta get owner docs/install.md --no-derived # what the page stores, nothing elsecat page.md | manni meta get title - --as markdownmeta query
Section titled “meta query”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.
manni meta query --query <sql> [paths...] [options]manni meta query [sql] [paths...] [options]Arguments
Section titled “Arguments”| 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.
The docs table
Section titled “The docs table”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.
Options
Section titled “Options”| 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.
Bound parameters
Section titled “Bound parameters”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:
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.
Exporting the database
Section titled “Exporting the database”--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:
manni meta query --db docs.db docs/datasette docs.db # a browsable UI and JSON API over the corpusThe 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.
Writing back
Section titled “Writing back”An UPDATE against docs edits the corpus, through the same
comment-preserving, self-verifying writer fill uses:
# Preview: the per-file diff, nothing touchedmanni meta query --dry-run "UPDATE docs SET draft = false WHERE draft IS NULL" docs/# Apply itmanni meta query "UPDATE docs SET draft = false WHERE draft IS NULL" docs/# Hold it in CI: pending changes fail until someone applies themmanni 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:
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.
Rows as findings
Section titled “Rows as findings”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.
CSV output
Section titled “CSV output”-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.
Output and exit codes
Section titled “Output and exit codes”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.
Examples
Section titled “Examples”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 githubmanni 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 yetmanni meta query -f csv "SELECT _path, title, last_reviewed FROM docs" docs/ > stale.csvmanni 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 markdownmeta fill
Section titled “meta fill”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.
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:
anthropic, whenANTHROPIC_API_KEYis set.openai, whenOPENAI_API_KEYis set.claude-cli, when aclaudeCLI on yourPATHruns andclaude --versionsucceeds. Sign-in is not checked, so an unauthenticated CLI is still selected and fails at the first call.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.
Arguments
Section titled “Arguments”| 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. |
Options
Section titled “Options”| 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:. |
Which properties get filled
Section titled “Which properties get filled”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.
The confidence gate
Section titled “The confidence gate”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:
- The value must satisfy the property’s own subschema.
fillbuilds the response schema out of your schema’s property definitions. Atimestampthat is not a validdate-timeis rejected before it is ever scored. When several schemas in the set define the same property, every one of their rules applies.-sorder does not change what the model may propose. - The property must be one manni meta asked about. The model cannot introduce keys your schema does not define.
- 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.
Which formats can be written
Section titled “Which formats can be written”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.
Exit codes
Section titled “Exit codes”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.
github annotations
Section titled “github annotations”-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.
The meta-provenance entry
Section titled “The meta-provenance entry”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, /intentThe 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.
Examples
Section titled “Examples”manni meta fill docs/ --dry-run # preview, write nothingmanni meta fill docs/ --confidence 0.9 # only near-certain valuesmanni meta fill page.md --fields description,tagsmanni meta fill docs/ -f github # annotate what it could not fillmanni meta fill docs/ --dry-run -q # only files with something to showcat page.md | manni meta fill - --as markdown # filled document to stdoutmeta derive
Section titled “meta derive”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.
manni meta derive [paths...] [options]Arguments
Section titled “Arguments”| 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. |
Options
Section titled “Options”| 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:. |
What gets written
Section titled “What gets written”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:2A 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 source that cannot answer
Section titled “A source that cannot answer”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.
Output and exit codes
Section titled “Output and exit codes”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 derivedocs/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 current2 files, 1 changed, 2 fields writtenExit 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.
Examples
Section titled “Examples”manni meta derive # stamp config derive.fields over every collectionmanni meta derive --collection guides # stamp one configured collectionmanni meta derive --dry-run docs/install.md # what would change, nothing writtenmanni meta derive --check -f github # CI: a stale stamp is an annotation, exit 1manni meta derive --fields reviewed-by,last-reviewed # after an approval, stamp who and whenmanni meta derive --sources git,codeowners # no gh on this machinemanni meta derive --fields owner --no-cache docs/ # re-read GitHub or GitLabmanni meta derive --fields verified-against # stamp the version a config command readsMANNI_GENERATED_BY=claude-fable-5 manni meta derive --fields provenance # attribute what an agent just wrotemanni meta derive docs/limits.md:12-31 --generated-by claude-fable-5 # attribute named lines, committed or notmeta relocate
Section titled “meta relocate”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:.
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.
Arguments
Section titled “Arguments”| 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. |
Options
Section titled “Options”| 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:. |
Output and exit codes
Section titled “Output and exit codes”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:2docs/install.md authors → site.metadata.yaml:4 owner → site.metadata.yaml:62 files, 3 values moved to 1 manifestUnder --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. |
Examples
Section titled “Examples”manni meta relocate # every collection, as config declares itmanni meta relocate --dry-run # what would move and be createdmanni meta relocate docs/install.md --fields ownermanni meta relocate docs/ guides/intro.md # no collections yet: creates collection defaultmanni meta relocate -f json --dry-run # the scripting formmeta schemas
Section titled “meta schemas”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.
manni meta schemas [options]Options
Section titled “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 }.
manni meta schemasmanni meta schemas -f jsonmeta schemas infer
Section titled “meta schemas infer”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.
manni meta schemas infer [paths...] [options]Arguments
Section titled “Arguments”| 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. |
Options
Section titled “Options”| 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. |
Reading the report
Section titled “Reading the report”1,204 files scanned · 38 with no metadata block
key coverage types sampletitle 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.
The draft schema
Section titled “The draft schema”--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
stringin 900 files and anumberin 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. enumonly 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 everytitleis unique passes a ratio test alone and would produce a 30-valueenumfor 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.
manni meta schemas infer docs/manni meta schemas infer docs/ --min-coverage 5manni meta schemas infer docs/ --out ./schemas/permissive.jsonmanni meta schemas infer docs/ -f jsonmeta schemas vendor
Section titled “meta schemas vendor”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.
manni meta schemas vendor <url> [options]Arguments
Section titled “Arguments”| Argument | Description |
|---|---|
<url> |
The http(s) URL to download. Required. |
Options
Section titled “Options”| 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-392da4339d91f59e3161a083a5d95913b6c7f98668e2bdfbebae53dc123e8994Commit 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.
Shared input model
Section titled “Shared input model”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.deriverefuses-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
--collectionnames. 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-emptysuppresses both. See empty input sets.
Collections as inputs
Section titled “Collections as inputs”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.
manni meta validate # every collectionmanni meta validate --collection guides # one of themmanni meta validate --collection guides --collection blog # twoThe 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:
$ manni meta validate --collection guides,blogmanni: no collection named "guides,blog" in manni.config.yaml. Configured: guides, blog.exit=2Naming 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.
$ manni meta validate --collection guides docs/x.mdmanni: --collection selects a configured collection; it cannot be combined with paths.exit=2
$ manni meta validate --collection guides --no-configmanni: --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.Membership, and what exclude: filters
Section titled “Membership, and what exclude: filters”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 and a narrowed run
Section titled “Corpus checks and a narrowed run”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, blogA 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.
Empty input sets
Section titled “Empty input sets”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.
Default ignores
Section titled “Default ignores”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.
.gitignore
Section titled “.gitignore”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.mdvalidates 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, andjsonoutput carriessummary.gitignoreSkipped. The count covers only files that passed the extension filter, so it answers “how many candidate documents did.gitignoretake away”.--excluderemovals are not counted, because those you asked for.schemas inferreports 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 packoutput, or a container with nogitbinary runs exactly as before, and silently. SetrespectGitignore: trueexplicitly to be told on stderr when filtering was asked for and could not happen. --no-gitignoreturns it off for one run, andrespectGitignore: falsefor a repository.