mars-schema
The reader and editor for Mars schema v1: the file format of agent definitions (agents/<slug>.md), skills (skills/<slug>/SKILL.md) the [package] table of mars.toml, and the hook.toml of a package's hooks. It holds the key tables, the validation, the tool vocabulary, the pre-v1 upgrade, the foreign-layout translation, the YAML emitter and the per-harness hook renderers. The mars-agents binary calls it directly, and the @meridian-flow/mars-schema npm package compiles it to WebAssembly, so an editor and mars sync read a file the same way.
The format is documented in Schema v1. This page covers the crate.
The crate is pure: no filesystem, network, process or clock access. Callers hand it text, or a TreeSource they implement, and take back values. cargo check -p mars-schema --target wasm32-unknown-unknown stays green.
Reading one file
use ;
let text = read_to_string.unwrap;
if let Agent = read.unwrap
read::read(path, text) picks the reader from the package-relative path (mars.toml, agents/<slug>.md or skills/<slug>/SKILL.md) and returns Err(UnrecognizedPath) for any other path. The result is a DefinitionRead<T>:
| Field | Holds |
|---|---|
frontmatter |
every top-level key in file order, unknown keys included, as OrderedValue (for mars.toml, the [package] table) |
fields |
the typed AgentProfile, SkillProfile or PackageMetadata; None when the file cannot be read at all |
body |
every byte after the closing fence; None for mars.toml |
diagnostics |
SchemaDiagnostic { code, severity, path, message, range } |
dependencies, local-dependencies |
mars.toml only (manifest::ManifestRead flattens them beside the four fields above): the entries by name in declaration order, in the file's spellings, each read by Entry::read. A table is omitted when empty |
The typed fields serialize with the spellings the file uses (kebab-case keys and values, xhigh, read-only); that serialization is the published shape, pinned by fixtures/read/. A refactor that changes it changes the contract. agent::read, skill::read and manifest::read are the three readers behind read::read, and nothing else in the workspace reads those keys. An installed .mars/ tree is read with read::installed_agent and read::installed_skill, which upgrade a file whose strict reading reports a pre-v1 spelling, because those bytes are whatever Mars version installed them.
Diagnostics are built directly with a structured path of keys and indexes. range is set only for fence and YAML-syntax errors, and no reader sets it yet; consumers locate a diagnostic by path.
Unknown keys are kept and reported as field.unknown warnings. Removed keys are field.removed errors that name the replacement.
Editing
edit::emit_value(&OrderedValue, indent) writes one value as YAML to splice after a key, and edit::rewrite_block(&[(String, OrderedValue)]) writes a whole frontmatter block. Raw values are ordered::OrderedValue, whose mappings keep authored key order; serde_json::Value would sort the keys.
- One scalar rule serves keys and strings: plain only for a safe word (
[A-Za-z_][A-Za-z0-9_./-]*, not a YAML boolean, null, number or infinity word), otherwise JSON double-quoted with every non-printable, line-separator and byte-order-mark character escaped. A multi-line string is always quoted. indentis the column of the key that owns the value. A scalar is one line with no leading newline. A non-empty collection starts with\nand its children sit atindent + 2. The output never ends in a newline.- It refuses (
EmitError) an integer beyond ±(2^53 − 1), NaN and Infinity, and a key written as more than 1024 characters. A float of any finite magnitude is written so that both Rust and JavaScript YAML parsers read the same value.rewrite_blockalso refuses a duplicate key and returns the block ending in exactly one newline.
Preparing a package
prepare::prepare(&source, context, declared_dialect) classifies one package root and returns a Prepared:
- the classification (
Schema1,MarsLegacy,SchemaNewer,Foreign,Marketplace,Invalid,Empty) and the evidence for a dialect; - the manifest reading, normalized to schema 1 (a pre-v1 manifest gains
schema = 1); - one entry per selected definition: kind, slug, source and output paths, normalized bytes, the typed reading and a
ChangeRecord(None,Generated,Editedwith the changed key paths, orFullyRewritten); - payload files as source-to-output paths, never bytes;
- findings by source path (
diagnostics) and what preparing changed in each file (changes: keys dropped, moved or repaired), withPrepared::changed_filesfor the count.
TreeSource is the read interface (read, is_file, is_dir, children) that the caller implements for a directory, an archive or memory; FileTree is the in-memory implementation and SubTree roots a tree below a directory. Preparation reads manifests, definitions and directory listings only.
PrepareContext::Package is a package root; PrepareContext::AuthoredLocal is a consumer project's .mars-src; PrepareContext::Project is a directory that is either (a mars.toml with no [package] is consumer configuration, and the tree is read as authored content). A pre-v1 package is upgraded with upgrade::upgrade_definition and upgrade::upgrade_manifest, which edit only the top-level keys that change and keep every other byte; mars migrate writes Prepared's normalized bytes back to the authored files, so it prepares with prepare_for_migration, which also applies upgrade::relocate_removed_fields to a schema 1 definition. A caller that only reads the package gets a schema 1 file exactly as written. Only mars migrate adds requires-mars = ">=0.20.0", through upgrade::ensure_requires_mars_floor, and only when the manifest has no requirement.
inspect::inspect(&source, &hint) reports what a repository is (a marketplace's plugins, a plugin's or skill tree's items, the dialect and its evidence), and normalize::normalize(&request) runs prepare on each selected root of a Claude plugin, marketplace, skill tree or other foreign layout and adds a generated mars.toml and provenance. Neither fetches anything: the caller fetches a remote marketplace plugin and inspects or normalizes the fetched tree with hint.plugin set to the plugin's entry.
Rendering hooks
hooks::read(text, dir_name, origin) reads one hook.toml into a HookDef. The Origin says whose hook it is: the project's own is read strictly, a dependency's unknown keys and events are skipped with a diagnostic. HookDef::output(target, fragment_text, origin, tokens) returns what the hook installs on one target: merged events for Claude and Codex, or generated files for OpenCode and Pi (typescript.rs writes the TypeScript). Each target is one row in hooks/targets.rs: its events, the harness version it was checked against, and the rules that render for it. Tokens fills ${MARS_HOOK_DIR} and ${MARS_HOOK_TARGET}. The @meridian-flow/mars-schema package exposes this as renderHooks. The hook.toml format is in Declaring Hooks in a Package.
Vocabulary and policy types
| Module | Holds |
|---|---|
tool_names |
the capability vocabulary and the capability(pattern) grammar; capabilities() lists them with their aliases |
tool_natives |
per-host native tool tables as data (Claude, Codex, OpenCode, Pi, Cursor, and the meridian-flow table); vocabulary() returns the published whole |
tool_policy, mcp |
the tool rules of an Agent and the mcp(server/tool) reference grammar |
mcp_server, mcp_servers, visibility |
an mcp.toml server definition, the one codec for the Claude mcpServers entry (read and rendered for Claude, Pi, Cursor and a plugin tree), and Visibility |
model_policy |
the model-policies rule and its validator |
harness |
HarnessId and the reserved consumer namespaces (meridian-flow, meridian-cli) |
hooks, visibility |
the hook.toml model, its per-target tables and renderers (above), and Visibility |
dialect |
the layouts Mars reads: the foreign ones it translates (claude, agent-skills, codex, opencode, cursor) and mars-native |
Projection onto a particular harness, overlay application, precedence and everything that touches the filesystem stay in the mars-agents binary.
Fixtures
fixtures/<suite>/<case>/ holds the corpus that fixes the behaviour: read, emit, upgrade, prepare, normalize, selection, version, vocabulary and typescript-hooks. cargo test -p mars-schema checks every case, and the npm package's run-fixtures.mjs checks the same cases through WebAssembly. Regenerate expected output with MARS_UPDATE_FIXTURES=1 cargo test -p mars-schema and review the diff: a changed reading is a change to the format.
License
MIT