mars-schema 0.26.0

Pure schema reader and editor for Mars agent packages
Documentation
# mars-schema

The reader and editor for [Mars](https://github.com/haowjy/mars-agents) 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`](https://github.com/haowjy/mars-agents/tree/main/npm/@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](https://github.com/haowjy/mars-agents/blob/main/docs/config/schema-v1.md). 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

```rust
use mars_schema::read::{read, Reading};

let text = std::fs::read_to_string("agents/coder.md").unwrap();
if let Reading::Agent(reading) = read("agents/coder.md", &text).unwrap() {
    for diagnostic in &reading.diagnostics {
        eprintln!("{}: {}", diagnostic.code, diagnostic.message);
    }
    if let Some(agent) = reading.fields {
        println!("{:?}", agent.effort);
    }
}
```

`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.
- `indent` is the column of the key that owns the value. A scalar is one line with no leading newline. A non-empty collection starts with `\n` and its children sit at `indent + 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_block` also 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`, `Edited` with the changed key paths, or `FullyRewritten`);
- 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), with `Prepared::changed_files` for 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, native_hook_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](https://github.com/haowjy/mars-agents/blob/main/docs/config/mcp-and-hooks.md#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