Introduction
Grimoire is an OCI-backed package manager for AI-agent configuration. Its
binary, grim, installs, updates, and publishes the skills and rules
that steer coding agents — distributing them through ordinary
OCI registries the same way container images are shipped.
The problem
Reusable agent configuration — skills, rules, prompt templates — is copied by
hand between repositories today. A useful rule written for one project is
pasted into the next, then drifts: no version, no provenance, no upgrade path.
There is no npm install for an agent skill.
The solution
Grimoire treats a skill or rule as a versioned, content-addressed artifact and
stores it in a registry you already run. You declare what you want in
grimoire.toml, pin exact digests in grimoire.lock, and materialize the
files into your AI client of choice. Upgrading is grim update; sharing is
grim release.
Because the transport is plain OCI, you inherit a registry’s authentication, TLS, and replication for free — there is no bespoke server to operate. GitHub Container Registry, Docker Hub, or a private Distribution instance all work unchanged.
Status: Grimoire is young. The CLI documented here is real and tested, but the surface is still moving toward 1.0 — pin a version when you depend on it.
Where to next
- Installation — get the
grimbinary. - Quick Start — install your first skill in five commands.
- Concepts — skills versus rules, scopes, locks, and clients.
- Command Reference — every subcommand and flag.
Installation
grim is a single self-contained binary. Once it is on your PATH there is
nothing else to configure. Pick the method that fits your setup — every one of
them lands the same grim binary.
Recommended: install with ocx
ocx is an OCI-native package manager for pre-built binaries — the same
idea as Grimoire, applied to executables instead of agent config. It is the
recommended way to install grim: ocx resolves the right build for your
platform, keeps the binary versioned in a local store, and upgrades it in
place. Every Grimoire release is published to ocx.sh/grim.
Install ocx once — its installer wires up your shell so ocx-managed binaries
land on PATH:
curl -fsSL https://setup.ocx.sh/sh | sh
On Windows, install ocx with PowerShell 7.4 or newer:
irm https://setup.ocx.sh/pwsh | iex
Then install grim and make it the current version:
ocx package install --select ocx.sh/grim
ocx.sh/grim resolves to the newest release; re-run the same command to
upgrade. The grim package page lists every published version.
Install script
If you would rather not adopt ocx, the Grimoire site hosts a one-line
installer. It detects your platform, downloads the matching archive from the
GitHub release, verifies its SHA-256 checksum, and drops grim onto your
PATH.
On macOS or Linux:
curl --proto '=https' --tlsv1.2 -LsSf https://michael-herwig.github.io/grimoire/install.sh | sh
On Windows, run it from PowerShell:
irm https://michael-herwig.github.io/grimoire/install.ps1 | iex
The installer is generated by cargo-dist and installs to
~/.cargo/bin by default; set GRIMOIRE_INSTALL_DIR to choose another
directory. The archives it downloads are exactly the pre-built
binaries listed below — reach for those when you want to pick the
platform by hand or check the checksums yourself.
Pre-built binaries
Every release publishes archives for macOS, Linux, and Windows on both
aarch64 and x86_64, each accompanied by a SHA-256 checksum and a
CycloneDX software bill of materials. Download the latest from the
releases page.
| Platform | Asset |
|---|---|
| macOS (Apple Silicon) | grimoire-aarch64-apple-darwin.tar.xz |
| macOS (Intel) | grimoire-x86_64-apple-darwin.tar.xz |
| Linux (ARM64) | grimoire-aarch64-unknown-linux-gnu.tar.xz |
| Linux (x86-64) | grimoire-x86_64-unknown-linux-gnu.tar.xz |
| Windows (ARM64) | grimoire-aarch64-pc-windows-msvc.zip |
| Windows (x86-64) | grimoire-x86_64-pc-windows-msvc.zip |
On Linux or macOS, download the right archive, extract it, and move the grim
binary onto your PATH. Each archive also carries the license, README, and
changelog alongside the binary:
curl -LO https://github.com/michael-herwig/grimoire/releases/latest/download/grimoire-x86_64-unknown-linux-gnu.tar.xz
tar -xf grimoire-x86_64-unknown-linux-gnu.tar.xz
install -m 0755 grim ~/.local/bin/grim
On Windows, unzip the archive and place grim.exe somewhere on PATH.
Build from source
With a Rust toolchain installed (Grimoire targets the stable 2024 edition), install straight from the repository:
cargo install --git https://github.com/michael-herwig/grimoire grimoire
Or clone and build a release binary at target/release/grim:
git clone https://github.com/michael-herwig/grimoire.git
cd grimoire
cargo build --release
Verify
grim --version
If the command prints a version string, you are ready for the Quick Start.
Quick Start
This walkthrough declares a skill from a registry, installs it into a project,
and then upgrades it. It assumes grim is on your PATH (see
Installation) and that you can reach an OCI registry that
hosts Grimoire artifacts.
1. Create a project config
grim init writes a fresh grimoire.toml in the current directory. Seed it
with the registry you pull from so short references resolve without repeating
the host:
grim init --registry ghcr.io/acme
2. Declare an artifact
grim add records a skill or rule in grimoire.toml and immediately pins it
in grimoire.lock. The only required argument is the reference to fetch; the
kind is inferred from the artifact’s com.grimoire.kind manifest annotation
and the binding name defaults to the reference’s last path segment:
grim add ghcr.io/acme/code-review:1
The reference is registry/repo:tag (or registry/repo@sha256:… to pin an
exact digest). A floating tag like :1 tracks the newest 1.x release, which
is what makes grim update meaningful later.
3. Install into your AI client(s)
grim install materializes every locked artifact into your AI client’s
configuration directory. By default it targets Claude Code; pass
--client to select opencode or GitHub Copilot, or
supply a comma-separated list to install into several AI clients at once:
grim install
grim install --client claude,copilot
4. Check the state
grim status reports each declared artifact as installed, outdated, locally
modified, or missing — the same model the TUI paints in colour.
grim status
5. Upgrade
When the publisher ships a newer version behind the same floating tag,
grim update re-resolves the tag, rolls the lock forward, and re-materializes
only what changed:
grim update # everything
grim update code-review # one binding by name
Undo
To take an artifact back out completely — files, install record, and config
entry — use grim uninstall. To browse what a registry offers
before declaring anything, launch the interactive browser with
grim tui.
Concepts
Grimoire borrows its mental model from package managers you already use, then swaps the transport for an OCI registry. This page covers the handful of ideas that make the commands feel obvious.
Skills, rules, and agents
Grimoire distributes three kinds of installable artifact. A skill is a
directory — a SKILL.md plus any supporting scripts or references — that
teaches an agent a capability. A rule is a Markdown file that states a
standard or constraint the agent should always follow. An agent is a
Markdown file that defines a delegatable assistant — a system prompt with a
name, description, and tool access; see Agent Artifacts.
All three are declared the same way and travel through the same pipeline; the
differences are shape on disk (a folder versus a file) and the kind argument
(skill, rule, or agent) you pass to commands like
grim add.
Rules with a support directory
A rule is often an index that points at extra context — a worked example, a
JSON schema, a script — that would clutter the rule itself. The convention
across AI-config tooling is to put that next to the rule in a sibling folder
with the same name (rules/my-rule.md referencing ./my-rule/examples.md).
A bare index whose links resolved to nothing would be useless, so a rule may
carry an optional support directory: a folder beside the index sharing its
stem (my-rule.md + my-rule/). Grimoire packs the index and every file under
that folder into the one artifact, and installs them beside each other so the
index’s relative links resolve:
.claude/rules/my-rule.md
.claude/rules/my-rule/examples.md
.claude/rules/my-rule/schema.json
A rule with no support directory is unchanged — it remains the single
my-rule.md file. See Publishing for how
the directory is packed.
Artifacts as OCI content
Under the hood every skill, rule, or agent is packed into an OCI artifact and
addressed by content digest, exactly like a container image layer. Identical
content is stored once and is immutable: a sha256:… digest always names the
same bytes.
Each artifact declares its kind through a com.grimoire.kind manifest
annotation — skill, rule, agent, or bundle — so grim (or any
OCI-aware tool that reads manifest annotations) can tell a Grimoire artifact
apart without unpacking it. The manifest’s config descriptor is the OCI empty
config, which keeps the wire format acceptable to every registry, including
GitLab (see Registry compatibility).
This is why Grimoire needs no server of its own. Any registry that speaks the distribution spec — GHCR, Docker Hub, a private Distribution — is a complete backend.
References, tags, and digests
You name an artifact with a reference: registry/repository:tag, or
registry/repository@sha256:… for an exact digest. A floating tag such as
:1 points at the newest 1.x release and moves over time; a digest never
moves.
You declare floating tags for convenience and let Grimoire pin them to digests for reproducibility — which is the job of the lock.
The lock
grimoire.lock records the exact digest each declared tag resolved to, so an
install is byte-for-byte reproducible until you deliberately upgrade.
grim lock resolves the floating tags in
grimoire.toml; grim update re-resolves them and
rolls the pins forward when a newer version appears behind the same tag.
The lock also stores a hash of the declaration it was generated from, so
Grimoire can tell when grimoire.toml has drifted ahead of the lock.
Bundles
Declaring the same dozen skills and rules in every repository does not scale.
Teams end up copying a block of grimoire.toml between projects, and when the
approved set changes someone has to chase down every copy.
A bundle is a curated set of members — skills, rules, and agents —
published as its own OCI artifact. You declare the bundle once in [bundles],
and on
grim lock it expands into its members, which are
pinned into the lock exactly like a direct declaration. Update the published
bundle, re-lock, and every project that declares it moves together.
Each locked member records its provenance — direct for something you
declared yourself, or the bundle it came from — which grim status
surfaces so you always know why an artifact is installed.
Adding and dropping members
The bundle’s member list is authoritative on every resolve, so membership tracks
the published bundle. When a new bundle version adds a member, the next
grim lock expands it into the lock and the next install
materializes it. When a version drops a member, that member leaves the lock —
and grim update prunes its materialized files, unless
you have edited them locally, in which case it is kept until you re-run with
--force. This is the same reconciliation any artifact leaving the lock
receives; bundles just make it routine.
Conflict policy
Because a member is keyed by (kind, name), two sources can name the same slot.
Grimoire resolves that deterministically:
- a direct
[skills]/[rules]/[agents]declaration always wins over any bundle — this is how you override a single member without forking the bundle; - two bundles that name a member at the same identifier coalesce to one entry;
- two bundles that disagree fail closed:
grim lockstops with a conflict error and asks you to declare the member directly to choose one.
Failing closed is deliberate. Silently picking a winner would let an unrelated bundle bump change what a project installs without anyone noticing.
Floating versus pinned members
A bundle’s members can themselves be floating tags or exact digests. A floating
member is re-resolved fresh on every consumer grim lock, so reproducibility
comes from the consumer’s own lock. Publishing with
grim release --pin instead freezes every floating
member to a digest at publish time, so the bundle is reproducible on its own —
the stronger guarantee for air-gapped or tunneled networks that cannot
re-resolve a tag. See Publishing.
Scopes
Grimoire works in two scopes. The project scope is the grimoire.toml
discovered from the current directory — per-repository configuration that lives
beside your code. The global scope is a single config under $GRIM_HOME
for artifacts you want everywhere.
Most commands operate on the discovered project by default and switch to the
global scope with --global. The TUI can flip between the
two at runtime.
Clients
An installed artifact has to land somewhere the agent reads. Grimoire calls that destination a client target and ships three: Claude Code, opencode, and GitHub Copilot. The same skill is transformed into each client’s native layout on install.
grim install writes to the targets listed in the
clients option in your config, defaulting to ["claude"]; --client
overrides it and accepts a comma-separated list to install into several AI
clients at once.
The catalog
grim search and the TUI read a
catalog — an index of the artifacts a registry offers, cached locally under
$GRIM_HOME so repeat browsing is fast and works offline. Pass --refresh to
rebuild it from the registry.
Online by default, offline on demand
By default Grimoire is online: every floating-tag lookup resolves fresh
against the registry, and the resolved digest is cached as a write-through so
later offline runs still work. A floating tag therefore never serves a stale
pin — there is no “use the cache first” mode to surprise you, and no --remote
flag to remember.
Pass --offline to flip to cache-only: Grimoire forbids all network access
and fails rather than touch a registry — useful in sealed CI or an air-gapped
network. Warm the cache with a normal online grim lock (or grim update)
before going offline. --offline has an environment-variable equivalent
described in Configuration.
Command Reference
Every command follows the same shape: parse references into typed values, run
the operation, and report what actually happened. Structured output renders as
an aligned table by default or as JSON with --format json, so the same
command serves humans and scripts.
Run grim <command> --help for the authoritative, always-current flag list.
Global options
These apply to every subcommand:
| Flag | Effect |
|---|---|
--format <plain|json> | Output format for structured results (default plain). |
--global | Operate on the global scope instead of the discovered project. |
--config <path> | Use an explicit project config file. |
--registry <ref> | Registry for short identifiers and the browse set. Repeatable / comma-separated (--registry a,b); the first value is the default. |
--offline | Disable all network access; work from the cache only and fail rather than reach a registry. |
--log-level <level> | Override the tracing log level (warn, info, debug). |
The lifecycle commands
| Command | Purpose |
|---|---|
grim init | Create a fresh grimoire.toml. |
grim config | Read and write grimoire.toml settings and registries. |
grim add | Declare a skill/rule/agent and lock it. |
grim lock | Resolve declared floating tags to pinned digests. |
grim install | Materialize the locked artifacts into your AI client(s). |
grim update | Re-resolve floating tags and re-materialize changes. |
grim status | Report the state of every declared artifact. |
grim remove | Undeclare an artifact (config + lock only). |
grim uninstall | Fully remove an artifact (files + record + config). |
grim search | Search the registry catalog. |
grim tui | Browse the catalog interactively. |
grim build | Validate and pack a local artifact. |
grim release | Validate, pack, and push an artifact. |
grim publish | Validate and batch-release all packages from a manifest. |
grim login | Authenticate to a registry and store the credential. |
grim logout | Remove a stored registry credential. |
grim schema | Print the JSON Schema for grimoire.toml or publish.toml. |
grim mcp | Run a local STDIO MCP server for AI agent integration. |
grim init
Writes a fresh grimoire.toml in the current directory. --registry <ref>
seeds the default registry as a [[registries]] entry with default = true;
without the flag, a set GRIM_DEFAULT_REGISTRY is snapshotted the same way
(the built-in default registry is never written — it keeps floating with the
binary). --global creates the global config at $GRIM_HOME/grimoire.toml
instead of a project-local one.
grim init --registry ghcr.io/acme
grim config
grim config reads and writes grimoire.toml, modeled on git config. Before it existed, querying a setting or scripting a config change required hand-editing TOML and relying on the next command run to catch typos.
The command covers two areas of the file: settings (the [options] and [options.tui] tables) and named registries (the [[registries]] array). Declarations — the [skills], [rules], [agents], and [bundles] tables — remain under grim add and grim remove, which must re-resolve the lockfile on every change.
Scope follows the same rule as every config-aware command: without a flag, grim config discovers and edits the project grimoire.toml by walking up from the working directory; --global targets $GRIM_HOME/grimoire.toml; --config <path> selects an explicit project file.
Every write re-runs registry validation before touching the file, so the at-most-one-default constraint and alias rules always hold. The serializer is shared with grim add and grim remove — comments and the #:schema directive are not preserved on any write.
Settings
Four verbs operate on dotted keys:
grim config get options.clients
grim config set options.clients claude,opencode
grim config unset options.tui.default_view
grim config list
get prints the bare value on a single line with no key name or table header, so $(grim config get options.clients) works directly in shell. A valid-but-unset key exits 1 with no stdout — the same contract as git config: grim config get options.clients || echo default. An unknown key (typo or unsupported leaf) exits 64 without reading the config.
set and unset print a one-row confirmation table with Action, Key, Value, and Scope columns.
list shows every explicitly-set key and value for the active scope — keys at their default or absent values are omitted. Each invocation reads from exactly one scope, so origin is implicit in the scope flag used. Scopes are never merged: grim config --global list shows only global values, project list shows only project values.
The supported dotted keys are:
| Key | Value type | Notes |
|---|---|---|
options.clients | comma-separated client names | e.g. claude,opencode. Empty string clears the list. |
options.default_registry | string | Legacy field — prefer grim config registry use for new configs. |
options.tui.default_view | flat or tree | Other values exit 65. |
options.tui.group_by_type | true or false | false is the default; setting it to false removes the key, so a subsequent get exits 1 (consistent with list, which omits default values). |
options.tui.tree_separators | comma-separated single-character strings | Each character must be non-control and non-whitespace; other values exit 65. |
registry.<alias>.url | string | The registry entry must already exist. Cannot be unset (URL is required); use grim config registry rm <alias> to remove the whole entry. |
registry.<alias>.default | true or false | Setting to true clears all other entries’ default flag, the same as grim config registry use. |
Registry dotted keys require the entry to already exist — only grim config registry add creates entries. Passing registry.<alias> without a trailing field to unset removes the whole entry, equivalent to grim config registry rm <alias>.
Registry lifecycle
grim config registry manages the [[registries]] array through dedicated lifecycle verbs:
grim config registry add acme --url ghcr.io/acme
grim config registry add acme --url ghcr.io/acme --default
grim config registry use acme # mark as default; clears the prior default
grim config registry show acme # print one registry's fields
grim config registry rm acme
grim config registry list
registry add requires --url. Adding an alias that already exists exits 64 — update the URL with grim config set registry.<alias>.url <new-url>, or remove and re-add.
registry use is the correct way to change the default registry. It sets the target entry’s default flag and clears the flag on all others in one atomic write. Dotted grim config set registry.<alias>.default true routes through the same logic.
registry list shows all [[registries]] entries in the scope. Entries without an alias (url-only entries hand-authored before aliases were introduced) appear with an empty Alias cell and are not addressable by dotted key — assign them an alias to manage them with grim config.
JSON output
Add --format json to any subcommand for machine-readable output. The shapes are:
| Subcommand | JSON shape |
|---|---|
get (value set) | {"key":"…","value":"…","set":true,"scope":"project"|"global"} |
get (unset, exits 1) | {"key":"…","value":null,"set":false,"scope":"project"|"global"} |
set / unset / registry add, rm, use | {"action":"…","key":"…","value":string or null,"scope":"…"} |
list | array of {"key":"…","value":"…"} |
registry list | array of {"alias":string or null,"url":"…","default":bool} |
registry show | {"alias":"…","url":"…","default":bool} |
The action field in write confirmations takes one of: set, unset, registry-added, registry-removed, registry-default. The scope field is project or global.
Exit codes
| Situation | Code |
|---|---|
| Success | 0 |
get of a valid-but-unset key (no stdout) | 1 |
| Unknown key name / missing or duplicate alias / bad subcommand args | 64 |
| Invalid value (bad enum, non-boolean, bad separator character) | 65 |
| Write or lock I/O failure | 74 |
| Concurrent write that can’t acquire the config lock | 75 |
| Config file parse failure | 78 |
Explicit --config <path> not found, or required config absent | 79 |
grim add
grim add [--kind <skill|rule|agent|bundle>] [--name <name>] <reference>
declares a skill, rule, agent, or bundle and immediately pins it
in the lock. <reference> is the only required argument —
registry/repo:tag or registry/repo@sha256:….
When --kind is omitted, the kind is inferred from the artifact’s
com.grimoire.kind manifest annotation set at release time (artifacts
published by older grim are still typed from their legacy artifactType). When
--name is omitted, the binding name defaults to the reference’s last path
segment. If the kind cannot be inferred (for example, a non-Grimoire image),
add errors and asks you to supply --kind explicitly.
grim add ghcr.io/acme/code-review:1
grim add --kind rule --name rust-style ghcr.io/acme/rust-style:2
grim add --kind bundle ghcr.io/acme/python-stack:1
Adding a bundle declares it in [bundles] and expands
its members into the lock. grim remove bundle <name> undeclares the bundle and
drops the members it contributed — a member another still-declared bundle also
contributes only loses this bundle’s provenance entry and stays locked.
If the reference is deprecated, add
prints the publisher’s notice on stderr and still completes the add.
grim lock
Resolves the floating tags declared in grimoire.toml to concrete digests and
writes grimoire.lock. Run it after editing the config by hand; grim add
already locks what it declares.
grim install
Materializes every locked artifact into your AI clients’ configuration
directories. --client <list> selects AI clients (claude, opencode,
copilot, comma-separated), overriding the config clients option. When
neither selects a client, the detected clients for the scope are
targeted — every client whose vendor directory or marker is present —
falling back to all clients when none are detected. --force overwrites a
locally modified artifact instead of refusing it.
grim install
grim install --client claude,copilot
grim update
grim update [names…] re-resolves floating tags, rolls the lock forward, and
re-materializes only what changed. With no names it updates everything; pass
binding names to scope it. Shares --client and --force with install.
grim update
grim update code-review rust-style
Because update reconciles the workspace to the freshly-resolved lock, it also
prunes artifacts that have dropped out of the lock — most often a
bundle member that the bundle stopped including. A
clean, unmodified orphan is deleted (files and install record) and reported with
the removed action. An orphan you have edited locally is kept and reported
as kept-modified, so an accidental bundle change never silently discards your
work; re-run with --force to prune it anyway. This mirrors the install
integrity gate, where a locally modified artifact is refused rather than
overwritten without --force.
Pruning happens only on update. grim install materializes the current lock
but never deletes — like grim remove, it leaves files on disk.
grim status
Reports each declared artifact’s state — installed, outdated, locally modified,
integrity-missing, or not installed. The Source column shows each artifact’s
provenance: direct or the bundle it came from. Pair
with --format json to drive automation.
grim remove
grim remove <kind> <name> undeclares an artifact from grimoire.toml and the
lock. It leaves already-installed files on disk — use
grim uninstall to remove those too.
Removal acts on the effective declaration, fully offline: the lock entry
is dropped only when no remaining declaration holds the artifact. Removing a
direct declaration while a declared bundle still names the artifact at the
same identifier keeps the entry — its provenance flips to the bundle. If
the bundle names it at a different identifier, the correct pin cannot be
derived offline: the entry is dropped, the lock is left stale, and grim tells
you to run grim lock — never a silently incomplete fresh lock.
grim uninstall
grim uninstall <kind> <name> is the full inverse of install: it deletes the
materialized files, drops the install record, and undeclares the artifact from
the config and lock. The interactive TUI’s delete action reuses the same seam.
The lock follows the same effective-declaration rule as
grim remove: when a declared bundle still names the artifact at
the same identifier, the files are deleted (that is what you asked for) but
the lock entry survives via the bundle — the next grim install
rematerializes it.
grim search
grim search [query] searches the registry catalog by case-insensitive
substring against repository, summary, description, and keywords; an empty
query lists the whole catalog. When [[registries]] are configured, all
of them are browsed and the results are flattened into one table.
--refresh forces a catalog rebuild; --registry <ref> collapses the
browse to exactly the registries it names — repeatable and comma-separated
(--registry a,b or --registry a --registry b), first value is primary.
GRIM_DEFAULT_REGISTRY is only the
short-id resolution default — it does not restrict the browse set when
[[registries]] is configured.
The plain table shows each entry’s short summary (com.grimoire.summary),
falling back to the description when no summary is set. On an interactive
terminal that column is truncated to fit the width; piped output and
--format json keep the full description. The JSON output also carries a
repository field — the artifact’s authored
repository URL, or null when the
artifact has none.
A deprecated entry is flagged in the
Status cell with a comma-suffixed deprecated (e.g. installed,deprecated),
and JSON carries the notice in a deprecated field (null when the artifact
is not deprecated).
grim search review
grim search --refresh --registry ghcr.io/acme
grim tui
grim tui opens an interactive browser over your declared registries’
catalogs. It shows the catalog with live install state in colour, toggling
between a flat kind-grouped list and a collapsible tree (press t). When
more than one registry is configured, the flat list adds a leading Registry
column showing the configured alias (or the raw URL when no alias was set), and
the Repo cell is shortened to the registry-relative path so names stay readable.
It supports multi-select with batch install, update, and delete. Press ? in the TUI
for the full key map; highlights are t to toggle tree/flat view, v to
pick a version, o to open the selected entry’s repository URL in the
browser, g to switch scope, and space to mark rows.
Tree view — pressing t switches the catalog between flat list mode and
a collapsible tree grouped by registry host and repository path. In tree mode:
| Key | Action |
|---|---|
t | Toggle between flat list and tree view. |
→ | Expand the selected group (reveal its children). Tree mode only. |
← | Collapse the selected group. On an already-collapsed group or on a leaf entry, jump to the parent group instead (ARIA-style navigation). Tree mode only. |
Enter on a group | Fold or unfold the group (same as →/← toggle); on a leaf entry, open the detail pane as usual. |
space on a group | Mark every descendant leaf in the subtree. The group’s mark glyph turns filled (▣) when all descendants are marked. |
i / u / d on a group | Install, update, or uninstall every leaf in the subtree (when no other rows are individually marked). Batch behavior follows the same selection precedence as the flat view. |
Each group row shows a rollup glyph reflecting the worst install state of
its descendants — ↑ when any descendant is outdated, ✱ when any is
locally modified, and so on — so a collapsed tree still surfaces what needs
attention.
Compact namespaces — a run of namespace segments that never branches
collapses into one row whose label is the joined path, the same idea as VS
Code’s “compact folders” folding a/b/c when each level
holds a single child. The join merges namespace groups into each other only —
never a namespace into the package row directly below it — and stops where the
path branches, so a registry holding only acme/team/skills/lint and
acme/team/skills/fmt shows acme/team/skills as one group above the lint
and fmt leaves. A registry root always keeps its own row.
Bundle member expansion — when the selected row is a bundle leaf, pressing
→ (or Enter) reveals its members as indented child rows badged
(via bundle). Member rows are read-only: they reflect what a bundle
declares, derived from the registry (or the lock snapshot when offline).
Bundle members cannot be individually marked, installed, or uninstalled from
the tree — use the parent bundle row for batch operations.
An active search (started with /) reveals matching entries even when their
parent group is collapsed — the tree stays navigable in search mode and does
not force a switch to flat view.
Three config fields under [options.tui] in grimoire.toml let you set
the opening view mode and control how paths are split into groups. See
[options.tui] for the full reference.
Like grim search, the TUI browses every registry declared in
[[registries]], grouping entries under one collapsible root per registry.
When exactly one registry resolves, its root prefix is elided to keep names
short; with several, the roots are ordered by resolution precedence, and a
registry that is empty or offline still appears as an empty 0/0 root so the
full configured set stays visible. An explicit --registry flag collapses the
browse to exactly the registries it names — repeatable and comma-separated
for several at once. GRIM_DEFAULT_REGISTRY is only the
short-id resolution default — it does not collapse the browse set when
[[registries]] is configured; in that case both grim search and grim tui
browse all declared registries regardless of whether the env var is set.
When the active scope has no grimoire.toml yet, the TUI offers to create
one before starting, as popup dialogs: confirm the init, then accept or
edit the registry. The input is pre-filled with the effective default — the
--registry flag, then GRIM_DEFAULT_REGISTRY, then the global config, then
the built-in grim.ocx.sh fallback — and the accepted value is persisted as a
[[registries]] entry with default = true in the new config (clearing the
input seeds nothing). Cancelling closes the TUI.
enter opens the detail pane for the selected row: the centered artifact
reference, its Summary: and Description: sections, and a Metadata:
block with the keywords and the
repository URL (version and install
status stay on the catalog row). While the pane is open, ↑/↓ (or
j/k) scroll it instead of moving the selection; esc returns to the
list. pgup/pgdn scroll the pane from any mode — no need to open it
first. Scrolling is clamped at both ends: it saturates at the top and
stops when the content’s last line reaches the pane’s bottom edge.
A TUI install or update goes through the same seams as the commands: it
declares the entry in the active scope’s grimoire.toml and relocks it (like
grim add), then materializes just that artifact (like
grim install). Delete is the full inverse via the
grim uninstall seam. Installing a version older than the
registry’s latest flips the row to outdated right after the install
completes.
A bundle row works the same way at the bundle level. Install declares it
under [bundles], expands it into its members (like
grim add --kind bundle), and materializes exactly those members; the row’s
state aggregates the member states. Delete removes the member files and
records, evicts the members from the lock, and undeclares the bundle. A
member shared with another still-declared bundle is spared: its files stay
on disk and its lock entry only loses the deleted bundle’s provenance.
grim tui --registry ghcr.io/acme
grim build
grim build <path> validates and packs a local skill directory, rule .md
file, agent .md file, or bundle .toml file without pushing
it — a dry run for authors. --kind <skill|rule|agent|bundle> forces the
artifact kind instead of auto-detecting it from the path. An agent always
needs --kind agent — a bare .md packs as a rule. --git embeds
git provenance (commit revision, commit
date, and the origin remote) so the preflight reflects what a release would
stamp.
grim release
grim release <path> <reference> validates, packs, and pushes an artifact.
A full semver reference (e.g. 1.2.3) applies cascade tags — 1.2.3, 1.2,
1, and latest are all moved. A non-version tag (e.g. canary, edge)
publishes only that exact tag with no cascade. A reference with no tag at all
is an error. --dry-run prints the push plan without pushing; --force
moves an existing exact-version tag that points at a different digest;
--skip-existing (conflicts with --force) turns a release whose
exact-version tag already exists into a success no-op that pushes nothing —
for manifest-driven publishers that re-run blanket releases and only want
bumped versions pushed. A .toml path publishes a
bundle; --pin then freezes its floating members to
digests. --git embeds git provenance
(commit revision, date, and origin remote) as OCI annotations; it is
off by default so an ordinary re-release stays idempotent. See
Publishing for the full workflow.
Pointing grim release at a publish.toml (a file with a top-level
registry key) produces a hint to use grim publish instead. The mirror
also holds: pointing grim publish at a bundle TOML (flat name = "reference"
entries) produces a hint to use grim release --kind bundle.
grim release ./code-review ghcr.io/acme/code-review:1.2.3 --dry-run
grim release ./python-stack.toml ghcr.io/acme/python-stack:1.0.0 --pin
grim publish
grim publish reads a publish.toml manifest and releases every declared
package in kind order (skills → rules → agents → bundles, alphabetical
within kind). It validates the whole manifest before any push, then
composes grim release per entry.
The default behavior skips entries whose exact-version tag already exists,
making the command idempotent: re-running after a partial failure pushes only
the remaining entries. Pass --force to move existing exact-version tags
instead. The two modes are mutually exclusive.
--dry-run validates the manifest and prints the full push plan without
touching the registry. --only <name> (repeatable) filters to a single
entry; a name absent from the manifest exits 65. --tag <tag> overrides
the published tag with a movable channel tag (e.g. canary); semver values
are rejected with exit 65, keeping all semver releases in the manifest. A
channel tag always moves on re-publish — no skip, no --force needed.
--manifest <path> selects a manifest other than the default ./publish.toml.
--git embeds git provenance on every
published entry (forwarded to each release); a non-git path fails (65).
The global --registry flag overrides the manifest’s
registry value for staging runs or acceptance tests without editing the file.
GRIM_DEFAULT_REGISTRY and the config-file default_registry do not
override the manifest — the manifest’s registry field is explicit input, and
only the flag tier wins.
Exit codes from the release path propagate per entry. Validation failures
exit 65 (data error). The report renders for all completed entries plus
the first failed entry; re-run with --only for surgical recovery.
grim publish --dry-run
grim publish
grim publish --only grim-usage
grim publish --tag canary
See Batch publishing with a manifest for the manifest schema, source layout conventions, and disambiguation from bundle files.
grim login
grim login [registry] authenticates to a registry and stores the credential
in the Docker-compatible credential store, so later pulls and pushes reuse it.
Pass the username with -u/--username (prompted on a terminal when omitted)
and the password via --password-stdin or a hidden terminal prompt — there is
no --password <value> flag, by design. --allow-insecure-store permits a
base64 plaintext entry when no credential helper is configured. With no
positional registry, it resolves --registry, then default_registry, then
GRIM_DEFAULT_REGISTRY. See Authentication for storage
details.
echo "$TOKEN" | grim login ghcr.io -u alice --password-stdin
grim logout
grim logout [registry] removes a stored credential. It is idempotent —
logging out when nothing is stored exits 0 — and resolves the registry the
same way grim login does.
grim logout ghcr.io
grim schema
grim schema --kind <config|publish> prints a JSON
Schema for one of the two author-facing TOML files
to stdout. --kind config describes grimoire.toml; --kind publish
describes publish.toml. The schema is generated from grim’s own parser, so it
accepts exactly what grim accepts.
grim schema --kind config > grimoire-config.schema.json
grim schema --kind publish | jq .title
The same schemas are published to the docs site; see Editor schema
support for the hosted URLs and the
#:schema directive that wires an editor up to them.
grim mcp
grim mcp runs a local Model Context Protocol server over
STDIO. An AI agent host — Claude Code, OpenCode,
or any MCP-compatible client — connects to it over stdin/stdout
and gains structured access to Grimoire’s catalog and install state without
running shell commands.
The server is read-only by default. Mutating tools (add, install,
update, uninstall) are gated behind --allow-writes and are not yet
registered; the flag reserves the gate for a later release.
The install scope is fixed at server start: --global operates on the
global scope; --config <path> points at a specific project config.
Individual tool calls cannot redirect the scope.
Because stdout carries the JSON-RPC channel, the server writes no diagnostic output there — all tracing goes to stderr. The server shuts down when the client closes stdin (EOF).
| Flag | Effect |
|---|---|
--allow-writes | Enable mutating tools when they land (currently no-op — server is read-only). |
--global | Fix the scope to the global config for the server’s lifetime. |
--config <path> | Use an explicit project config (scope resolution for status tools). |
Tools exposed today:
| Tool | Description | Equivalent CLI |
|---|---|---|
grim_search | Browse/search the configured registries (no registry override — the configured set is the boundary). Args: query?, refresh?. | grim search --format json |
grim_status | Install status of every declared artifact in the fixed scope. | grim status --format json |
The JSON payload each tool returns is identical to the --format json
output of the corresponding command — one source of truth for both the CLI
and the MCP surface.
Registering with Claude Code — add to .mcp.json in the project root
(or register globally via claude mcp add):
{
"mcpServers": {
"grimoire": {
"command": "grim",
"args": ["mcp"]
}
}
}
Pass --global to the args array when you want the server to operate on
the global scope rather than the discovered project:
{
"mcpServers": {
"grimoire": {
"command": "grim",
"args": ["mcp", "--global"]
}
}
}
Configuration
Grimoire keeps configuration in two small files and a handful of environment
variables. Settings ([options], [options.tui]) and named registries
([[registries]]) are managed through grim config; declarations
([skills], [rules], [agents], [bundles]) stay under grim add
and grim remove. You can also hand-edit either file directly,
but note that any grim write — grim config, grim add, grim remove — uses a
lossy serializer: comments and the #:schema directive are removed on every
write.
grimoire.toml
The declaration file. An [options] table holds defaults, and [skills] /
[rules] / [agents] map each binding name to a reference:
#:schema https://michael-herwig.github.io/grimoire/schemas/grimoire-config.schema.json
[[registries]]
url = "ghcr.io/acme"
default = true
[options]
clients = ["claude", "opencode"]
[skills]
code-review = "ghcr.io/acme/code-review:1"
commit-helper = "ghcr.io/acme/commit-helper:1"
[rules]
rust-style = "ghcr.io/acme/rust-style:2"
[agents]
code-reviewer = "ghcr.io/acme/code-reviewer:1"
The [[registries]] entry with default = true sets the primary registry short references expand against; clients selects which
AI clients grim install and grim update materialize
into. It accepts a TOML array of client names (claude, opencode, copilot);
when absent, the detected clients for the scope are targeted — every client
whose vendor directory or marker is present — falling back to all clients when
none are detected. Unknown keys are rejected on parse, so a typo surfaces
immediately rather than silently doing nothing.
[options.tui]
The optional [options.tui] sub-table tunes the interactive catalog browser
launched by grim tui. All three fields are opt-in —
an absent [options.tui] leaves the TUI at its built-in defaults.
[options.tui]
default_view = "tree"
group_by_type = true
tree_separators = ["/", "-"]
| Field | Type | Default | Description |
|---|---|---|---|
default_view | "flat" or "tree" | "flat" | The view mode the browser opens in. "tree" starts in the collapsible grouped tree; "flat" starts in the plain list. An unrecognised value is a config parse error — the enum is strict. The runtime t key still toggles between modes ephemerally; the config is never auto-rewritten. |
group_by_type | boolean | false | When true, inserts an extra type-level group — skill, rule, agent, or bundle — between the registry root and the repository path segments in tree view. Has no effect in flat mode. |
tree_separators | array of single-character strings | (absent or []) | The characters on which a repository path is split into nested tree groups. Omitting the field (or setting it to []) leaves the array empty in the config file; at runtime, an empty array normalizes to ["/"]. Add "-" to split on hyphens as well, so code-review becomes code → review. Each entry must be exactly one character; empty or multi-character entries are a parse error. |
Configuration parse errors — including an unrecognised default_view value or an invalid tree_separators entry — exit 78 (EX_CONFIG).
The registry host is always the tree root. When the browsed registry matches the configured default registry, the host node is elided from the display so leaf names stay short.
An optional [bundles] table declares bundles, each
mapping a binding name to a bundle reference. A bundle expands into its member
skills, rules, and agents at lock time:
[bundles]
python-stack = "ghcr.io/acme/python-stack:1"
[skills]
# A direct declaration overrides a bundle member of the same name.
code-review = "ghcr.io/acme/code-review:2"
Bundle references follow the same rules as skills and rules — a bare reference
defaults to :latest. Per (kind, name), a direct declaration wins over any
bundle, agreeing bundles coalesce, and disagreeing bundles fail closed; see the
conflict policy.
Multiple registries
A project that pulls artifacts from more than one registry can declare all
of them in a [[registries]] array instead of juggling --registry flags.
When the array is present it becomes the authoritative browse set for
grim search, the MCP server, and
the TUI — grim tui browses all declared registries, one
collapsible root per registry. An explicit --registry flag still collapses the
browse to exactly the registries it names — repeatable and comma-separated
(--registry a,b) for several at once. GRIM_DEFAULT_REGISTRY does not
collapse the browse set — it is the short-id resolution default and only
applies as the single-registry fallback when no [[registries]] array is
declared.
Each entry has one required field and two optional fields:
| Field | Required | Description |
|---|---|---|
url | yes | Registry host and optional namespace, e.g. ghcr.io/acme. Same form as [options].default_registry. |
alias | no | Short name for use in qualified references. Must be unique across the array. The TUI uses the alias as the display label in the flat list’s Registry column and as the tree registry-root row label; entries without an alias fall back to the raw URL. |
default | no | Marks this entry as the primary registry short identifiers expand against. At most one entry may set it; when none do, the first entry is primary. |
#:schema https://michael-herwig.github.io/grimoire/schemas/grimoire-config.schema.json
[[registries]]
alias = "acme"
url = "ghcr.io/acme"
default = true
[[registries]]
alias = "internal"
url = "registry.corp.example/team"
The same [[registries]] array can appear in the global config
($GRIM_HOME/grimoire.toml). Project entries take precedence over global
entries; duplicate URLs are deduped, first occurrence wins.
Backward compatibility: a config that omits [[registries]] entirely
behaves exactly as before — [options].default_registry, the environment
variable GRIM_DEFAULT_REGISTRY, and the --registry flag still drive the
single-registry path. The two approaches do not mix: when any [[registries]]
entry is declared, [options].default_registry is ignored for browse purposes
(the default = true entry, or first entry, takes its role). The field is still
read for back-compat and never destroyed on re-serialize, but grim init now
writes the [[registries]] shape for new configs — [options].default_registry
is deprecated for new writes.
Known limitation: grim login / grim logout with no positional argument
or --registry flag resolve the registry from the --registry flag,
GRIM_DEFAULT_REGISTRY, and the built-in default only — they do not consult
[[registries]]. Pass the registry explicitly (grim login ghcr.io/acme) when
your config uses [[registries]]-only.
At-most-one default = true: declaring two [[registries]] entries with
default = true is a parse error (exit 78). When none set it, the first entry
is the primary.
Qualified references
When registries have aliases, a reference can be qualified with
alias/repo[:tag] to expand the alias to its configured URL. For example,
with the config above:
grim add acme/code-review:1.2
# expands to: grim add ghcr.io/acme/code-review:1.2
grim add internal/lint-rules:stable
# expands to: grim add registry.corp.example/team/lint-rules:stable
The qualified form uses a slash separator (alias/repo), not a colon —
alias:repo would be ambiguous with repo:tag. A reference whose leading
/-segment does not match any alias is treated as a multi-segment
repository path under the primary registry, exactly as without aliases
configured.
Short references with no alias and no explicit registry still expand against the primary (or only) registry, unchanged from the single-registry behavior.
Registry compatibility
grim search and the TUI browse a registry’s catalog through the
host-level OCI _catalog endpoint. Not all registries expose it —
multi-tenant SaaS registries such as GitHub Container Registry
and the GitLab Container Registry gate the endpoint
for namespace-privacy reasons. When a registry does not support
_catalog, a browse comes back empty.
An empty browse result on these registries is expected behavior, not an error. Install, add, release, and publish work through explicit references and are unaffected — every registry in the table below supports explicit-reference operations.
| Registry | _catalog browse (grim search, TUI) | Explicit-ref ops (install / add / release / publish) |
|---|---|---|
registry:2 (local) | yes | yes |
| Zot | yes | yes |
| Harbor | yes | yes |
grim.ocx.sh | yes | yes |
| GitHub Container Registry (GHCR) | no | yes |
| Docker Hub | no | yes |
| GitLab Container Registry (SaaS) | no | yes |
When an online browse comes back empty, grim prints a hint pointing to
this section so you can confirm whether the registry supports _catalog.
grimoire.lock
The lockfile pins every declared tag to an exact digest and records the
scope’s declaration hash so drift is detectable. It is
generated by grim lock, grim add, and the
TUI’s install action; treat it as machine-owned and
commit it alongside grimoire.toml:
[metadata]
lock_version = 1
generated_by = "grim 0.1.0"
[[skill]]
name = "code-review"
pinned = "ghcr.io/acme/code-review@sha256:…"
[[rule]]
name = "rust-style"
pinned = "ghcr.io/acme/rust-style@sha256:…"
[[agent]]
name = "code-reviewer"
pinned = "ghcr.io/acme/code-reviewer@sha256:…"
A member that came from a bundle additionally carries
bundle and bundle_tag fields recording its origin; a directly-declared entry
omits them, so a bundle-free lock is byte-identical to one written before
bundles existed. A member that several declared bundles contributed (an
agreeing overlap) records every contributor in a bundles sub-table array
([[skill.bundles]] rows with repo and tag) instead of the single pair —
removing one bundle then only strips its provenance entry, and the member
stays locked until the last contributing bundle is removed. The same
compatibility holds for agents: an agent-free lock carries no [[agent]]
array at all and is byte-identical to one written before agents existed.
A lock with declared bundles also caches each bundle’s expansion result in a
[[bundle]] section — binding name, repo, tag, the resolved manifest
digest, and the member list as [[bundle.member]] rows:
[[bundle]]
name = "starter-pack"
repo = "ghcr.io/acme/bundles/starter-pack"
tag = "1"
pinned = "ghcr.io/acme/bundles/starter-pack@sha256:…"
[[bundle.member]]
kind = "skill"
name = "code-reviewer"
id = "ghcr.io/acme/code-reviewer:1"
This cache is what lets grim remove and grim uninstall work offline
on the effective declaration: before applying an edit they compute the set
of artifacts the declaration implies before and after, drop only what no
remaining declaration holds, and keep everything else. A bundle-free lock
carries no [[bundle]] section at all.
Editor schema support
Both author-facing files ship a published JSON Schema,
so an editor can autocomplete keys and flag a mistyped table name the moment
you save — instead of surfacing the error at the next grim run. The schemas
are generated from grim’s own parser, so they accept exactly what grim accepts.
| File | Schema URL |
|---|---|
grimoire.toml | https://michael-herwig.github.io/grimoire/schemas/grimoire-config.schema.json |
publish.toml | https://michael-herwig.github.io/grimoire/schemas/grim-publish.schema.json |
Taplo and the Even Better TOML VS Code extension bind a file to its schema through a first-line directive:
#:schema https://michael-herwig.github.io/grimoire/schemas/grimoire-config.schema.json
To regenerate or inspect a schema locally, use grim schema:
grim schema --kind config prints the grimoire.toml schema and
grim schema --kind publish prints the publish.toml one.
Scopes on disk
A project config is the grimoire.toml discovered from the working
directory. The global config lives at $GRIM_HOME/grimoire.toml and is
selected with --global. See Concepts for when each
applies.
Environment variables
| Variable | Purpose | Default |
|---|---|---|
GRIM_HOME | Root data directory (cache, global config, global install state at $GRIM_HOME/state/global.json). Project install state lives at <workspace>/.grimoire/state.json, not here. | ~/.grimoire |
GRIM_DEFAULT_REGISTRY | Default registry for short references. | unset |
GRIM_OFFLINE | Disable all network access (same as --offline). | false |
GRIM_INSECURE_REGISTRIES | Comma-separated registries reachable over plain HTTP — for local or in-cluster registries without TLS. | unset |
DOCKER_CONFIG | Directory holding the Docker-compatible config.json that grim login reads and writes. | ~/.docker |
By default Grimoire resolves floating tags fresh from the registry, then caches
the result, so a floating tag never serves a stale pin. Pass --offline (or set
GRIM_OFFLINE) to work from the cache alone and fail rather than reach the
network.
A command-line flag always wins. Registry resolution operates on two separate precedences depending on context:
Browse-set (what grim search, the TUI, and grim mcp browse): --registry
flag → project [[registries]] → global [[registries]] → single default
(GRIM_DEFAULT_REGISTRY → project [options].default_registry → global
[options].default_registry → built-in grim.ocx.sh). The single-default tier
applies only when no [[registries]] array is declared anywhere. Only the
--registry flag collapses browse — to exactly the registries it names
(repeatable / comma-separated); GRIM_DEFAULT_REGISTRY does
not restrict the browse set when [[registries]] is configured.
Short-id resolution (expanding a bare name:tag to a full registry URL):
--registry flag → GRIM_DEFAULT_REGISTRY → project [options].default_registry
(or the primary entry of project [[registries]]) → global config → built-in
grim.ocx.sh.
The --offline toggle has no config-file counterpart — the flag or its GRIM_OFFLINE variable applies.
Data layout
The resolved-artifact content store, the catalog cache that
grim search and the TUI read, and
the global install state ($GRIM_HOME/state/global.json) all live under
GRIM_HOME. Keeping cache and global state under one directory means installs
can use atomic, same-filesystem operations.
Project install state is separate: it lives at
<workspace>/.grimoire/state.json, co-located with grimoire.toml. The
workspace directory is the key, so two projects sharing the same GRIM_HOME
volume cannot collide. Grim writes a self-managed .grimoire/.gitignore
(contents: *) the first time it creates the .grimoire/ directory, so the
state file is kept out of version control without touching your root
.gitignore.
Authentication
Most public skills and rules pull anonymously, but a private registry wants
to know who you are before it hands anything over. Grimoire does not invent
its own login system for that — it reads and writes the same credential
store Docker and oras already use, so a single
docker login (or grim login) covers every tool on the machine.
This page covers how Grimoire finds credentials when it talks to a registry,
how grim login and grim logout manage them, and
where they are stored on disk.
How credentials are resolved
Every registry request starts the same way: Grimoire looks up a credential for the target registry and falls back to anonymous access when it finds none. A missing credential is never an error — public artifacts keep working with no setup.
The lookup reads ~/.docker/config.json (or $DOCKER_CONFIG/config.json),
the Docker config file. If a credential
helper is configured for the registry, Grimoire asks
the helper; otherwise it reads the base64 entry under auths. The registry
key is normalized first — the scheme and any /v2/ API suffix are stripped,
and docker.io is mapped to its canonical https://index.docker.io/v1/
form — so a credential stored by docker login resolves the same way under
grim.
Credentials can also come from the environment for GRIM_INSECURE_REGISTRIES
and other CI setups; see Configuration.
grim login
grim login [registry] authenticates to a registry and stores the
credential so later pulls and pushes reuse it. With no positional argument
it resolves the registry the same way every command does — --registry,
then the default_registry option, then GRIM_DEFAULT_REGISTRY.
The username comes from --username/-u, or an interactive prompt when
omitted on a terminal. The password is read from a hidden terminal prompt,
or from standard input with --password-stdin. There is intentionally no
--password <value> flag: a secret on the command line leaks through the
process list and shell history.
# Interactive: prompts for the password without echoing it.
grim login ghcr.io -u alice
# Non-interactive (CI): read the token from stdin.
echo "$GITHUB_TOKEN" | grim login ghcr.io -u alice --password-stdin
Where the credential lands depends on what is configured — see Where
credentials are stored. When no credential helper is configured,
Grimoire refuses to write a plaintext credential unless you opt in with
--allow-insecure-store, which stores a base64 entry (not encryption) in
config.json. The file is created with owner-only (0600) permissions.
Grimoire stores the credential without first contacting the registry, which
matches docker login with a credential helper. A wrong password therefore
surfaces on the next pull or push, not at login time.
grim logout
grim logout [registry] removes a stored credential. It resolves the
registry exactly like grim login.
Logout is idempotent: removing a credential that was never stored exits 0,
matching docker logout and oras logout so a
CI cleanup step never fails on a fresh runner.
grim logout ghcr.io
Where credentials are stored
Grimoire writes to the Docker-compatible config at
$DOCKER_CONFIG/config.json, defaulting to ~/.docker/config.json. Set
DOCKER_CONFIG to point both Grimoire and Docker at an isolated directory —
useful for tests and per-job CI credentials.
The destination follows the same precedence Docker uses, highest first:
| Tier | Config key | Storage |
|---|---|---|
| Per-registry helper | credHelpers[registry] | The named OS keychain helper. |
| Default helper | credsStore | The named OS keychain helper. |
| Plaintext fallback | auths[registry] | base64-encoded, gated by --allow-insecure-store. |
A credential helper is a small program named docker-credential-<name> on
your PATH that stores secrets in the OS keychain — for example
osxkeychain, wincred, or secretservice/pass on Linux. The
docker-credential-helpers project ships the common
ones. When a helper is configured, the secret never touches config.json;
only the helper name does.
Unlike docker login, Grimoire does not auto-detect and silently enable
a platform helper on first use. It writes only what is already configured,
or the explicit plaintext fallback — so a shared machine never gains a
sticky credsStore entry behind your back.
Credentials in CI
A headless runner usually has no terminal and no keychain. Pipe the token in
and opt into the plaintext store scoped to a per-job DOCKER_CONFIG:
export DOCKER_CONFIG="$RUNNER_TEMP/docker"
echo "$REGISTRY_TOKEN" | grim login "$REGISTRY" -u "$REGISTRY_USER" \
--password-stdin --allow-insecure-store
grim release ./code-review "$REGISTRY/acme/code-review:1.2.3"
grim logout "$REGISTRY"
Because Grimoire shares the Docker config, a prior docker login
step in the same job is enough on its own — grim reuses whatever Docker
stored.
Publishing Skills and Rules
Consuming artifacts is only half of Grimoire. The other half is producing them:
turning a local skill directory or rule file into a versioned OCI artifact that
others can grim add.
Author locally
A skill is a directory containing a SKILL.md and any supporting files; a
rule is a Markdown file, optionally with a
sibling support directory; an
agent is a Markdown file defining a delegatable assistant;
a bundle is a .toml file listing members.
Grimoire detects which one you mean from the path — a directory packs as a
skill, a .md file as a rule, a .toml file as a bundle — and --kind
overrides the guess when you need to. An agent requires --kind agent:
its .md shape is indistinguishable from a rule, and grim never guesses from
content (see Agent Artifacts).
Rules with a support directory
An index rule often references extra context — examples, a schema, a script — that does not belong inside the rule body. Put those in a folder beside the rule that shares its stem, and Grimoire packs both into the one artifact:
rules/
my-rule.md # the index you pass to build/release
my-rule/ # optional support directory, same stem
examples.md
schema.json
You still point grim build and
grim release at the index .md file — the sibling
directory is discovered automatically when it exists:
grim release ./my-rule.md ghcr.io/acme/my-rule:1.0.0
Every file under my-rule/ rides along in the same layer and installs beside
the index (.claude/rules/my-rule.md + .claude/rules/my-rule/…), so the
index’s relative links resolve on the consumer. Support files are copied
verbatim for every client — only the index is ever
transformed. A rule with no support directory packs to exactly the single
my-rule.md it always did.
Catalog metadata
grim search and the TUI list
every match in a table. To make a result legible and findable, an artifact
carries five pieces of catalog metadata, all optional:
| Field | Annotation | Purpose |
|---|---|---|
summary | com.grimoire.summary | One-line blurb shown in the catalog (preferred over the description). |
keywords | com.grimoire.keywords | Comma-separated terms that search matches. |
description | org.opencontainers.image.description | The full description. |
repository | org.opencontainers.image.source | HTTPS URL of the artifact’s source repository (details). |
deprecated | com.grimoire.deprecated | A deprecation notice; marks the package deprecated and flags it everywhere (details). |
grim search shows the summary in place of the description, truncated to
fit the terminal; the full description stays in --format json and in piped
output. Search matches the repository, summary, description, and keywords,
so a query hits regardless of which one carries the term. Omit summary and
the catalog falls back to the description.
You author this metadata in the source file, so a grim release always
publishes whatever the file currently says — no separate flags to remember.
Where it lives differs by kind.
In a skill
A skill puts catalog metadata under the metadata map of its SKILL.md
frontmatter (the map the Agent Skills
format defines), separate from the top-level description:
# code-review/SKILL.md
---
name: code-review
description: A thorough multi-pass reviewer that checks correctness, security, and style across the whole diff.
metadata:
summary: Multi-pass code reviewer
keywords: review,quality
repository: https://github.com/acme/code-review
---
In a rule
A rule has no description field — that is derived from the body’s first
heading or paragraph. summary and keywords sit at the top level of its
frontmatter:
# rust-style.md
---
paths: ["**/*.rs"]
summary: Idiomatic Rust style rules
keywords: rust,lint
repository: https://github.com/acme/rust-style
---
# Rust Style
…
In an agent
An agent authors catalog metadata in its metadata map, like a skill; the
required description doubles as the full catalog description:
# code-reviewer.md
---
name: code-reviewer
description: Reviews diffs for correctness, security, and style.
metadata:
summary: Multi-pass diff reviewer
keywords: review,quality
repository: https://github.com/acme/code-reviewer
---
In a bundle
A bundle sets the same keys at the top level of its .toml, above
the member tables. Here description overrides the otherwise-automatic
grimoire bundle of N members:
# python-stack.toml
summary = "Python dev stack"
keywords = "python,lint,test"
description = "Skills and rules for Python work"
repository = "https://github.com/acme/python-stack"
[skills]
code-review = "ghcr.io/acme/code-review:1"
[rules]
rust-style = "ghcr.io/acme/rust-style:2"
Keywords are a string
keywords is always a single comma-separated string — in every kind — because
an OCI annotation value is itself a string. A YAML or TOML list is not
accepted; write keywords: rust,lint, not keywords: [rust, lint].
Repository URL
repository links a published artifact back to the source repository it
came from. The value must be an https:// URL (GitHub, GitLab, or any
forge) — a git@… or http:// value fails the release with exit 65, the
same hard gate that guards vendor metadata.
The URL must not carry embedded credentials: an authored
https://token@host/owner/repo fails the release with exit 65 rather than
publishing the secret in the manifest. (grim never strips an authored
credential silently — only a git-derived origin remote is sanitized.)
On the wire it travels as the standard org.opencontainers.image.source
annotation, so registries that honor the key link the package to its
repository. When no repository is authored, grim keeps its previous
behavior and stamps the tagless release reference there instead. The
TUI shows the URL in the detail pane and opens it
with the o key; grim search --format json exposes it as the
repository field.
Deprecating a package
deprecated retires a package without unpublishing it. Author a short
notice — ideally naming the replacement — and the package keeps resolving
and installing, but grim flags it at every point a consumer might reach for
it. The notice is the message; an empty or whitespace-only value means not
deprecated, so no annotation is emitted.
# code-review/SKILL.md (skill / agent: under the metadata map)
metadata:
deprecated: use acme/code-review-2 instead
# rust-style.md (rule: top-level, like summary)
deprecated: superseded by rust-style-2
# python-stack.toml (bundle: top-level)
deprecated = "migrate to python-stack-2"
Because the notice rides the com.grimoire.deprecated annotation on the
manifest, every surface reads it back without unpacking the artifact:
grim searchappends a comma-suffixeddeprecatedto the result’sStatuscell (e.g.installed,deprecated) and exposes the message as adeprecatedfield in--format json.- The TUI appends a yellow
⚠ deprecatedafter the install-status label in theStatuscolumn (explained in the legend) and shows the full notice in the detail pane. grim addprints the notice on stderr when you acquire a deprecated reference (the add still succeeds).
A re-release with the notice removed clears the deprecation — the annotation simply stops being emitted.
Validate before you push
grim build validates and packs an artifact without
pushing it. Run it while iterating to catch a malformed skill before anyone
else sees it:
grim build ./code-review
grim build ./rust-style.md --kind rule
grim build ./code-reviewer.md --kind agent
Release
grim release validates, packs, and pushes to a
registry in one step. Give it the source path and the release reference:
grim release ./code-review ghcr.io/acme/code-review:1.2.3
Cascade tags
A release does more than push one tag. From a 1.2.3 version it also moves the
floating tags that consumers track — 1, 1.2, and latest — to the new
digest. That is what lets a consumer who declared :1 pick up 1.2.3 with a
plain grim update.
Dry runs and overwrites
Preview the exact push plan — every tag and the digest each will point at — without touching the registry:
grim release ./code-review ghcr.io/acme/code-review:1.2.3 --dry-run
An exact-version tag is immutable by default: if 1.2.3 already exists and
points at different bytes, the release refuses rather than rewrite history.
Pass --force only when you deliberately mean to move it.
Git provenance
A published artifact rarely records which commit it was built from. Without that link, tracing a registry tag back to the source — for an audit, a rebuild, or a “why did this change” investigation — means guessing from timestamps.
The opt-in --git flag closes that gap. Pass it to grim build,
grim release, or grim publish and grim reads the artifact’s git working
tree and stamps three standard OCI annotations onto the manifest:
| Annotation | Value |
|---|---|
org.opencontainers.image.revision | the HEAD commit SHA, suffixed -dirty when tracked files differ from HEAD |
org.opencontainers.image.created | the commit date (RFC3339) — the commit’s date, not a build clock |
org.opencontainers.image.source | the origin remote, normalized to an https:// URL — conditional: the git-derived URL is not used when you authored a repository value (the authored URL wins) or the repo has no HTTPS-resolvable remote. The annotation itself is still emitted from the usual fallback (authored repository, else the tagless release reference) |
grim release ./code-review ghcr.io/acme/code-review:1.2.3 --git
The git remote only fills source when you did not author a
repository value — an authored URL always wins, so
the two never collide. Any credentials embedded in the remote
(https://token@host/...) are stripped before the URL is written, so a token
in your origin URL never reaches the annotation. A path that is not inside a
git repository (or a host with no git) fails the release with exit 65 rather
than silently dropping the provenance you asked for.
There is a third outcome between those two. A repository with no origin
remote — or one whose remote does not resolve to an HTTPS URL (an SSH-only
host grim cannot rewrite, a file:// remote, a bare local path) — is not
an error: revision and created are still stamped, and source is simply
omitted (falling back to whatever the authored repository or the tagless
release reference supplies). Only an absent repository or a missing git
fails.
Why it is opt-in
By default a re-release of identical content produces the same manifest
digest, so re-running a release is a harmless no-op (the
overwrite guard recognizes it). Embedding the
commit ties the digest to that commit: a re-release from a different commit
now changes the digest and is refused unless you pass --force. That is the
correct behavior — the provenance genuinely changed — but it is why git
provenance is something you ask for, not a silent default. The commit date
(not a wall-clock build time) keeps a re-release from the same commit
fully idempotent.
Every read surface shows the provenance back: the TUI
detail pane adds Revision: and Created: rows, and
grim search --format json exposes revision and
created fields.
Publishing bundles
A bundle groups skills, rules, and
agents so consumers declare one reference instead of a dozen.
You author it as a small TOML file whose [skills]/[rules]/[agents]
tables list the members — the same shape as a grimoire.toml:
# python-stack.toml
[skills]
code-review = "ghcr.io/acme/code-review:1"
[rules]
rust-style = "ghcr.io/acme/rust-style:2"
[agents]
code-reviewer = "ghcr.io/acme/code-reviewer:1"
grim build validates it (a .toml path packs as a
bundle), and grim release pushes it with the same
cascade tags as any other artifact:
grim build ./python-stack.toml
grim release ./python-stack.toml ghcr.io/acme/python-stack:1.0.0
Floating or pinned members
By default the bundle stores its members exactly as written — floating tags stay
floating, and each consumer’s grim lock re-resolves them
fresh. Add --pin to resolve every floating member to a digest at release time
and freeze it into the published bundle:
grim release ./python-stack.toml ghcr.io/acme/python-stack:1.0.0 --pin
A pinned bundle is reproducible on its own: it always expands to the exact same
member digests, even on an air-gapped or tunneled network that cannot re-resolve
a tag. Re-run the release (a cron job tracking :stable, say) to roll the
pinned members forward.
Batch publishing with a manifest
When a repository contains more than one package, releasing them one by one
with grim release means maintaining a shell script (or CI job) that
re-invents version tracking, ordering, and idempotent re-runs. That is a
generic capability dressed as project-specific tooling.
grim publish is the built-in alternative: it reads a publish.toml
manifest, validates the whole set before touching the registry, then
releases each entry in a fixed order.
The publish.toml format
A manifest has one required top-level field — registry — and up to four
kind tables. Each table entry is a sub-table keyed by name with a required
version field:
#:schema https://michael-herwig.github.io/grimoire/schemas/grim-publish.schema.json
registry = "grim.ocx.sh" # required; overridden by --registry
[skills.grim-usage]
version = "0.1.1" # required, strict X.Y.Z
[rules.custom-rule]
version = "0.2.0"
path = "shared/custom-rule.md" # optional — overrides the conventional path
[agents.helper]
version = "0.1.0"
[bundles.grim-essentials]
version = "0.1.0"
pin = true # optional, bundle entries only; default false
The registry value is a plain host (e.g. grim.ocx.sh, ghcr.io), not a
full reference. All entries in the manifest publish to the same registry.
Entry names must start with a character in [a-z0-9] and contain only
[a-z0-9._-] in the remainder. Uppercase letters, slashes, and ..
components are all rejected at validation time (exit 65) — they would
produce an invalid OCI repository segment or a path traversal hazard. Unknown
fields in the manifest or in any entry sub-table are a hard parse error
(deny_unknown_fields): a typo like versions instead of version exits
immediately rather than silently using a default.
The first line above is a Taplo /
Even Better TOML
#:schema directive that binds the manifest to its published JSON
Schema,
so a supporting editor autocompletes keys and flags a typo before you ever run
grim publish. The schema is generated from grim’s own manifest parser — see
Editor schema support for both schema URLs
and grim schema to print one locally.
Repository namespace
By default, each entry pushes to {kind-subdir}/{name} under the
manifest’s registry — a skill named hearth publishes to
registry/skills/hearth. Most self-hosted or single-user registries
work fine with this convention. Multi-tenant SaaS registries — such as
the GitLab Container Registry — require every image
to live under a group-and-project path, making the default layout
inaccessible.
Two optional fields let you replace the {kind-subdir} segment with an
arbitrary namespace path, so a publish manifest can target any registry
layout.
Manifest-level repository_prefix — a string applied to every entry
that does not set its own repository. The published repository becomes
{repository_prefix}/{name}; the prefix replaces the conventional
{kind.subdir()} segment. Registry-relative, no tag.
Per-entry repository — a string inside a [skills.<name>],
[rules.<name>], [agents.<name>], or [bundles.<name>] sub-table.
The value is used verbatim as the full repository path; the entry name is
not appended — the same way grim release uses the repository portion
of its positional registry/repo:version reference verbatim. Wins over
repository_prefix when both are set.
Resolution precedence per entry (highest first):
- per-entry
repository(full path, name not appended) - manifest
repository_prefix→{prefix}/{name} - default →
{kind.subdir()}/{name}(unchanged backward-compatible behavior)
#:schema https://michael-herwig.github.io/grimoire/schemas/grim-publish.schema.json
registry = "registry.gitlab.com"
repository_prefix = "durzn-technology/hearth/skill"
[skills.hearth]
version = "0.2.0"
# publishes to: registry.gitlab.com/durzn-technology/hearth/skill/hearth
[skills.other-skill]
version = "0.1.0"
repository = "durzn-technology/hearth/skill/other-skill"
# per-entry form — identical effect for this entry, wins over repository_prefix
The reporter’s working example: registry registry.gitlab.com, prefix
durzn-technology/hearth/skill, skill hearth → resolves to
registry.gitlab.com/durzn-technology/hearth/skill/hearth.
Charset rules — each /-separated segment of both fields must match
the OCI name grammar: runs of [a-z0-9] joined by a single . or _, a
double __, or a run of -, with no leading, trailing, or doubled
separator. A leading or trailing /, empty // segments, . or ..
segments, an embedded :, uppercase, and a path longer than 255 characters
are all rejected at manifest validation time with exit 65 (data error). An
invalid prefix or repository aborts the whole manifest before any push.
A manifest with neither field is unchanged: grim.ocx.sh/skills/grim-usage
style paths are the default and remain fully backward compatible.
Conventional source layout
When path is omitted, grim derives the source path from the entry name and
kind, relative to the manifest’s directory:
| Kind | Conventional path |
|---|---|
| skill | skills/{name}/ |
| rule | rules/{name}.md |
| agent | agents/{name}.md |
| bundle | bundles/{name}.toml |
The path field overrides this convention for entries whose source lives
elsewhere.
Kind ordering
Entries publish in a fixed kind order — skills, then rules, then agents, then bundles — alphabetical within each kind. Bundle entries land last by design: a bundle holds references to already-published members, and consumers resolve those members at lock time. Publishing a bundle before its members would produce a bundle that references artifacts that do not yet exist.
Skip-existing default and –force
By default, grim publish skips any entry whose exact-version tag already
exists on the registry — the push is a success no-op and nothing moves. This
makes the command safe to re-run from the top: only entries whose version was
bumped in the manifest since the last run actually push anything.
--force replaces the default with the opposite behavior: it moves an
existing exact-version tag that points at a different digest. The two modes
are mutually exclusive — --force and skip-existing cannot be combined.
--force also cannot be combined with --tag: a channel-tag run always
moves the tag, so --force would be redundant — passing both is rejected
as a usage error.
Flags
| Flag | Description |
|---|---|
--manifest <path> | Manifest file to read (default: ./publish.toml). |
--dry-run | Validate and plan without pushing. Prints what would be pushed. |
--force | Move existing exact-version tags instead of skipping them. Cannot be combined with --tag. |
--only <name> | Publish only the named entry (repeatable). A name absent from the manifest exits 65. |
--tag <tag> | Override the published tag with a movable channel tag (e.g. canary). Must be non-semver — semver values exit 65, keeping all semver releases in the manifest where the repo can track them. A channel tag always moves: re-publishing with --tag overwrites the existing tag without skipping and without --force. |
--registry <ref> | The global --registry flag overrides the manifest’s registry value for this run. GRIM_DEFAULT_REGISTRY and the config-file default_registry do not override the manifest — registry is explicit input, like a fully-qualified reference. Only the flag tier wins. |
Validation and fail-fast
grim publish validates the whole manifest before any push: every version
must be strict X.Y.Z semver, every source path must exist, and pin = true
is rejected on non-bundle entries (exit 65 for each). Only after the full
manifest passes does the first network call happen.
Two additional conditions exit 65 at validation time:
- Empty manifest — a manifest that declares no entries in any kind table exits 65 with “no packages declared in manifest”. Grim treats this as a likely wrong-file mistake rather than a valid no-op.
- Oversized manifest — a manifest file larger than 64 KiB is rejected before parsing. This is an unconditional limit, not a warning.
During the release run the command is fail-fast: the first failing entry
stops the batch. The report still renders — completed entries show their
status (pushed, skipped, or dry-run), the failed entry shows failed,
and remaining entries are unreported. Because skip-existing is the default,
re-running from the top after a fix pushes only what is left.
Example run
# Preview the full publish plan — zero writes
grim publish --dry-run
# Release everything in publish.toml, skip already-published versions
grim publish
# Release only one package
grim publish --only grim-usage
# Push a movable canary tag (manifest versions untouched)
grim publish --tag canary
Manifest vs bundle disambiguation
A publish.toml and a bundle .toml are structurally different: a manifest
has a top-level registry string and per-entry sub-tables with version; a
bundle has flat name = "reference" strings in its kind tables. The schemas
are disjoint and each parser rejects the other’s input.
If you point grim publish at a bundle file, the command detects the shape
and reports: “this looks like a bundle source file; use grim release --kind bundle”. If you point grim release at a publish manifest, the bundle
reader returns the mirror hint. Neither silently misparses the other’s format.
Authenticate
Grimoire pushes over standard OCI, so it reuses your existing registry
credentials — the same login your container tooling uses. Authenticate once
with your registry (for example, docker login against GitHub Container
Registry) and grim release inherits it.
Agent Artifacts
Skills teach an agent a capability and rules constrain it; an agent artifact defines an agent itself — a named, delegatable assistant with its own system prompt, model, and tool access.
Every major AI client has grown such a definition format: Claude Code subagents, OpenCode agents, and Copilot CLI custom agents. All three read a Markdown file with YAML frontmatter whose body is the system prompt — but each with its own field names, its own directory, and its own quirks. Teams end up copy-pasting near-identical agent files between repositories and editing three variants by hand.
Grimoire treats an agent like any other artifact: author one canonical
file, publish it once, and let grim install project it into each
client’s native format — the same model that powers
vendor-specific metadata for skills and rules.
The canonical format
An agent is a single .md file. Unlike a rule, the
frontmatter is required — every client needs at least a description
to route work to the agent:
# code-reviewer.md
---
name: code-reviewer
description: Reviews diffs for correctness, security, and style.
model: sonnet
tools: Read,Grep,Bash
metadata:
summary: Multi-pass diff reviewer
keywords: review,quality
claude.memory: project
opencode.mode: subagent
opencode.temperature: "0.2"
---
You are a code reviewer. Analyze the diff and report specific,
actionable findings.
The body below the frontmatter is the agent’s system prompt and installs verbatim for every client.
Common fields
| Field | Required | Type | Validation |
|---|---|---|---|
name | yes | string | Must equal the file stem (code-reviewer.md ⇒ code-reviewer); lowercase letters, digits, hyphens |
description | yes | string | Free text — when a client should delegate to this agent |
model | no | string | Passed through verbatim to each client; no alias translation |
tools | no | string | Comma-separated tool list, projected into each client’s native shape |
metadata | no | string→string map | Catalog keys (summary, keywords) plus vendor-namespaced keys (<vendor>.<field>) |
The name-equals-stem rule exists because OpenCode derives an agent’s identity from its filename; Grimoire enforces the rule for every client so the identity is consistent everywhere.
Everything a single vendor understands — Claude’s permissionMode,
OpenCode’s temperature, Copilot’s tool restrictions — is authored as a
<vendor>.<field> string key inside metadata. The full key tables live
in the vendor metadata reference.
Override precedence
The common model and tools fields are defaults. When a vendor key
lifts to the same native field, the vendor key wins for that vendor —
silently, because the collision is the documented escape hatch:
model: sonnet
metadata:
claude.model: opus # Claude installs model: opus
opencode.model: anthropic/claude-sonnet-4-5 # OpenCode gets this instead of "sonnet"
This matters most for model: Claude Code reads
aliases like sonnet, while OpenCode expects a
provider/model-id string. Grimoire deliberately does not translate
between the two — set opencode.model when the common value is not what
OpenCode needs.
What each client receives
On install, grim projects the canonical file per client:
| Canonical field | Claude Code | OpenCode | Copilot CLI |
|---|---|---|---|
name | kept | dropped (filename is the identity) | kept |
description | kept | kept | kept |
model | kept | kept (see precedence) | dropped (no documented field) |
tools | kept (comma string) | dropped (deprecated upstream) | emitted as a YAML list |
plain metadata / unknown keys | kept | dropped | dropped |
| body | verbatim | verbatim | verbatim |
| provenance comment | none | yes | yes |
The canonical format is Claude Code’s native subagent format, so a
plain agent — one with no <vendor>.<field> metadata keys — installs for
Claude byte-identical to the published file (generated: false). The
OpenCode and Copilot files are always generated transforms and carry a
provenance comment; editing them by hand is detected as
drift, exactly like any generated file.
Install locations
Project scope:
| Client | Path |
|---|---|
| Claude Code | .claude/agents/<name>.md |
| OpenCode | .opencode/agents/<name>.md |
| Copilot CLI | .github/agents/<name>.md |
Global scope (native user-level discovery directories, honoring each client’s directory-override variable — the same resolution as skill discovery):
| Client | Path | Env override |
|---|---|---|
| Claude Code | ~/.claude/agents/<name>.md | $CLAUDE_CONFIG_DIR/agents/ |
| OpenCode | ~/.config/opencode/agents/<name>.md (XDG) | $OPENCODE_CONFIG_DIR/agents/ |
| Copilot CLI | ~/.copilot/agents/<name>.md | $COPILOT_HOME/agents/ |
Unlike global rules, Copilot agents have a real user-level home — no inert-install warning applies.
Publishing
grim build and grim release need --kind agent for an agent file:
grim build ./code-reviewer.md --kind agent
grim release ./code-reviewer.md ghcr.io/acme/code-reviewer:1.0.0 --kind agent
The flag is required because a bare .md path is indistinguishable from a
rule by shape — and rules accept arbitrary frontmatter,
so guessing from content would silently flip kinds. When a file released
as a rule carries both name and description, grim warns that it looks
like an agent definition.
Publishing runs the same gate as skills and rules: every
<vendor>.<field> metadata key is validated against the vendor
registries, and an invalid literal (say claude.permission-mode: yolo)
fails the release with exit 65 before anything reaches the registry. The
artifact publishes with a com.grimoire.kind annotation of agent, so
grim add infers the kind with no flag.
Catalog metadata (summary, keywords) is authored in the metadata
map, like a skill — see catalog metadata.
Consuming
Agents ride the standard lifecycle. Declarations live in an [agents]
table of grimoire.toml; the lock carries [[agent]] entries; and
bundles accept agent members alongside skills
and rules:
grim add ghcr.io/acme/code-reviewer:1 # kind inferred from com.grimoire.kind
grim install # projects into every selected client
grim status # shows the agent row
grim uninstall agent code-reviewer # removes files + declaration
Limitations
- Object-valued vendor fields cannot be authored: the
metadatamap is string-valued by the agentskills contract, so Claude’smcpServersandhooks, OpenCode’spermission, and Copilot’smcp-serversare not projectable. Add them by editing the installed file (Claude/Copilot) or the client’s own config. - No support directory. An agent packs to exactly one
<name>.md; a sibling folder sharing the stem is ignored (unlike rules). - No model translation. The common
modelpasses through verbatim; useopencode.modelwhen the OpenCode side needs aprovider/model-idvalue.
Artifact Reference
Grimoire ships four artifact kinds — skills, rules, agents, and bundles. Each has its own source shape, frontmatter schema, and validation rules, and until now those details lived scattered across the publishing, agents, and vendor-metadata chapters.
When you author an artifact you need one page that answers: which fields exist, which are required, what values are valid, and what a correct file looks like. This page is that reference. Narrative background stays in Concepts; publishing mechanics stay in Publishing; vendor projection semantics stay in Vendor-Specific Metadata.
The four kinds
Every artifact carries its kind in a com.grimoire.kind manifest
annotation, so registries and tooling can distinguish kinds without
downloading layers.
| Kind | Source shape | com.grimoire.kind | Installs as |
|---|---|---|---|
| Skill | Directory with a SKILL.md index | skill | Directory tree under the client’s skills/ dir |
| Rule | Single .md file (+ optional sibling support directory) | rule | rules/<name>.md (+ rules/<name>/…), per-client transform |
| Agent | Single .md file | agent | One agent file per client, per-client rendering |
| Bundle | .toml member list | bundle | Never materializes itself — expands to its members |
The manifest’s config descriptor is the OCI empty config
(application/vnd.oci.empty.v1+json) — universally allow-listed, including
on GitLab Container Registry, which rejects custom config and artifactType
media types (see Registry compatibility).
Earlier releases instead stamped a custom OCI artifactType
(application/vnd.grimoire.<kind>.v1) and a per-kind config media type;
grim still reads those when present, so artifacts published before this
change resolve their kind unchanged.
grim build and grim release infer the kind from the path — a directory
is a skill, a .md file is a rule, a .toml file is a bundle. Agents are
the exception: an agent .md is indistinguishable from a rule by shape, so
--kind agent is required (see Agent Artifacts).
Names
Every skill and agent carries a name in frontmatter, and grim validates
it at build time. The same character rules apply to rule and bundle names
taken from the file stem.
A valid name:
- contains only lowercase letters, digits, and hyphens (
[a-z0-9-]), - does not start or end with a hyphen,
- does not contain consecutive hyphens,
- is not empty.
For skills the name must equal the directory name containing SKILL.md;
for agents it must equal the file stem (reviewer.md → name: reviewer).
A mismatch fails the build with exit code 65 (data error).
Skills
A skill is a directory: the SKILL.md index plus any supporting files
(scripts, templates, references). Everything in the tree is packed into a
single tar layer and installed verbatim — only SKILL.md itself is ever
re-rendered, and only when it carries vendor-namespaced metadata keys.
The frontmatter follows the agentskills specification. Parsing is forward-compatible: unknown top-level keys are preserved round-trip rather than rejected.
| Field | Required | Type | Notes |
|---|---|---|---|
name | yes | string | Must equal the skill directory name; see Names |
description | yes | string | What the skill does and when to use it |
license | no | string | SPDX-style identifier (e.g. Apache-2.0); emitted as the OCI license annotation |
compatibility | no | string | Editor/runtime hint (free text) |
allowed-tools | no | string | Comma-separated tool allowlist |
metadata | no | string→string map | Catalog keys + vendor extensions, see below |
| (any other key) | no | any YAML | Preserved verbatim (forward compatibility) |
Inside metadata, all values are strings. Three plain keys are read by
grim itself; everything else either passes through untouched or is a
vendor extension:
| Metadata key | Read by | Meaning |
|---|---|---|
summary | catalog | Short one-line blurb for grim search / the TUI |
keywords | catalog | Comma-separated tags, matched by search |
author | nothing (convention) | Attribution; passes through verbatim |
<vendor>.<field> | install renderer | Lifted into native client frontmatter, see Vendor extensions |
Example — minimal skill
The smallest valid skill is a directory with a two-field SKILL.md:
# hello-world/SKILL.md
---
name: hello-world
description: A minimal smoke-test skill that prints a greeting.
---
# Hello World
Say hello.
Example — full-featured skill
A skill using every top-level field, catalog metadata, and a Claude-only capability key:
# code-reviewer/SKILL.md
---
name: code-reviewer
description: Review a diff for SOLID/DRY violations, missing tests, and
risky changes. Use when asked to review a pull request or audit a patch.
license: Apache-2.0
compatibility: claude>=2
allowed-tools: Read,Grep,Bash
metadata:
summary: Multi-pass diff reviewer
keywords: review,quality,solid,dry,audit
author: acme-platform-team
claude.user-invocable: "true"
claude.effort: high
---
# Code Reviewer
Run the review in three passes...
The claude.* keys are string-valued here and become typed native
frontmatter (user-invocable: true, effort: high) in the file Claude
Code receives; other clients never see them. The projection rules live in
Vendor-Specific Metadata.
Rules
A rule is a single Markdown file. Frontmatter is entirely optional — a
bare .md with no --- fence is a valid rule whose body is the whole
document. When grim needs a description for the catalog it derives one
from the first Markdown heading or first non-empty line.
| Field | Required | Type | Notes |
|---|---|---|---|
paths | no | list of strings | Glob patterns the rule auto-loads on; empty/absent = always active |
summary | no | string | Short one-line blurb for the catalog |
keywords | no | string or list | Comma-separated tags (a YAML list is comma-joined) |
metadata | no | string→string map | Vendor extensions (e.g. copilot.exclude-agent) |
| (any other key) | no | any YAML | Preserved verbatim (forward compatibility) |
Note the asymmetry with skills: rule summary/keywords are top-level
frontmatter keys, not metadata entries.
A rule may also carry a sibling support directory sharing its stem
(architecture-guide.md + architecture-guide/); both pack into one
artifact and install side by side — see
Rules with a support directory.
Example — minimal rule
# commit-style.md
Use Conventional Commits. Subject ≤ 50 characters.
No fence at all — valid. The catalog description becomes the first heading-less line.
Example — path-scoped rule with catalog metadata
# rust-style.md
---
paths:
- "**/*.rs"
- "**/Cargo.toml"
summary: Idiomatic Rust style rules
keywords: rust,style,lints,quality
---
# Rust Style
Prefer `&str` over `String` parameters...
Example — rule with a vendor extension
# security-baseline.md
---
paths:
- "**/*.rs"
summary: Security review baseline
metadata:
copilot.exclude-agent: code-review
---
# Security Baseline
Validate all external input at system boundaries...
copilot.exclude-agent becomes excludeAgent: code-review in the
Copilot instructions file and is invisible to Claude and OpenCode — see
Rule-level vendor keys.
Agents
An agent is a single .md defining a delegatable assistant. Unlike rules,
agent frontmatter is required: every client needs at least a
description to decide when to route work to the agent.
| Field | Required | Type | Notes |
|---|---|---|---|
name | yes | string | Must equal the file stem; see Names |
description | yes | string | When a client should delegate to this agent |
model | no | string | Passed through verbatim, no alias translation; override per vendor via <vendor>.model |
tools | no | string | Comma-separated allowlist, projected per client (string vs. list) |
metadata | no | string→string map | Catalog keys (summary, keywords) + vendor extensions |
| (any other key) | no | any YAML | Preserved verbatim (forward compatibility) |
Like skills, agent summary/keywords live inside metadata. When a
vendor key lifts to the same native field as a common field (model,
tools), the vendor key silently wins for that client — the documented
override escape hatch
(override precedence).
Example — minimal agent
# reviewer.md
---
name: reviewer
description: Reviews a diff for correctness, style, and missing tests.
---
You are a code reviewer. Examine the diff...
Example — agent with common fields and vendor overrides
# release-bot.md
---
name: release-bot
description: Prepares release notes and version bumps on request.
model: sonnet
tools: Read,Grep,Bash
metadata:
summary: Release preparation agent
keywords: release,changelog,versioning
claude.permission-mode: plan
claude.max-turns: "20"
opencode.model: anthropic/claude-sonnet-4-5
opencode.temperature: "0.2"
copilot.tools: read,grep
---
You prepare releases. Collect commits since the last tag...
Claude Code receives model: sonnet plus permissionMode: plan and
maxTurns: 20; OpenCode receives model: anthropic/claude-sonnet-4-5
(its vendor key overrides the common model) and temperature: 0.2;
Copilot receives a tools: list of read, grep. The full emit matrix is
in Agent Artifacts.
Bundles
A bundle is a curated set of references to other artifacts. Its source is
a .toml file; the published artifact carries only a JSON members
document, so a bundle never materializes files of its own — installing it
expands to installing its members.
Top-level keys and member tables:
| Key / table | Required | Type | Notes |
|---|---|---|---|
summary | no | string | Short one-line blurb for the catalog |
keywords | no | string | Comma-separated tags |
description | no | string | Longer description; defaults to a deterministic grimoire bundle of N members |
[skills] | no | name → ref table | Skill members |
[rules] | no | name → ref table | Rule members |
[agents] | no | name → ref table | Agent members |
Each member entry maps the config binding name (the name the member is
declared under when the bundle is added) to a fully-qualified reference —
registry/repo:tag or registry/repo@sha256:…. Floating tags re-resolve
on grim update; digest pins never move
(floating versus pinned members).
Limits enforced at parse time: at most 512 members per bundle, and the members document is capped at 512 KiB. Nested bundles are invalid — a bundle member must be a skill, rule, or agent.
Example — bundle with all member kinds
# starter-pack.toml
summary = "Curated starter pack"
keywords = "starter,review,style,security"
description = "The code-review skill plus the Rust style rule and review agent"
[skills]
code-reviewer = "registry.example.com/grimoire/skills/code-reviewer:1"
[rules]
rust-style = "registry.example.com/grimoire/rules/rust-style:1"
[agents]
reviewer = "registry.example.com/grimoire/agents/reviewer@sha256:8f4b…"
Vendor extensions
Client-specific capabilities are authored as string-valued
<vendor>.<field> keys in the artifact’s metadata map and lifted into
native typed frontmatter at install time. The published artifact stays
spec-compliant; each client sees only its own namespace.
The recognized keys per vendor and kind — full type and projection detail in Vendor-Specific Metadata:
| Vendor | Skills | Rules | Agents |
|---|---|---|---|
claude.* | disable-model-invocation, user-invocable, model, effort, context, agent, argument-hint, when-to-use, arguments, disallowed-tools, shell, paths (registry) | (none today — unknown keys warn + drop) | model, tools, disallowed-tools, permission-mode, max-turns, skills, memory, background, effort, isolation, color, initial-prompt (registry) |
opencode.* | (none — universal fields only) | (none) | model, mode, temperature, top-p, steps, prompt, disable, hidden, color (registry) |
copilot.* | (none — universal fields only) | exclude-agent (registry) | tools (registry) |
Every value is authored as a string and converted at install time:
| Declared type | Accepted literals | On bad literal |
|---|---|---|
| bool | "true", "false" | hard error (exit 65) |
| enum | the closed set listed in the registry | hard error (exit 65) |
| integer | base-10 digits | hard error (exit 65) |
| float | any finite float | hard error (exit 65) |
| comma list | any; split on , into a YAML list | never fails |
| string | any | never fails |
A known key with a bad literal fails the publish. An unknown key in
your own namespace (a typo like claude.efort) warns and drops. A key in a
foreign namespace drops silently — that is how one canonical file
serves several clients
(publish-time validation).
Catalog annotations
On the wire, catalog metadata travels as OCI manifest annotations. grim emits standard OCI image-spec annotation keys plus two Grimoire-specific ones, sourced per kind as follows:
| Annotation | Source | Emitted |
|---|---|---|
org.opencontainers.image.title | artifact name | always |
org.opencontainers.image.description | description field, or derived from the rule body | always |
org.opencontainers.image.version | release version | always |
org.opencontainers.image.licenses | skill license field | when present |
org.opencontainers.image.source | authored repository HTTPS URL (skill/agent metadata.repository; rule top-level repository; bundle repository); falls back to the tagless release ref | always on release |
com.grimoire.summary | skill/agent metadata.summary; rule top-level summary; bundle summary | when present |
com.grimoire.keywords | skill/agent metadata.keywords; rule top-level keywords; bundle keywords | when present |
An authored repository must be an https:// URL — anything else fails
the publish (exit 65). Readers distinguish a real repository URL from the
legacy release-ref fallback by that https:// prefix; on registries that
honor the key (e.g. ghcr.io) the source annotation
also links the package back to its repository.
org.opencontainers.image.created is deliberately omitted so re-releasing
identical content stays byte-identical (idempotent re-release).
Vendor-Specific Metadata
Why tool keys live in metadata
A canonical SKILL.md obeys the agentskills specification.
That format defines a fixed set of top-level fields — name, description,
license, compatibility, allowed-tools — and a metadata map of
string-valued key/value pairs for everything else.
Each client tool adds its own capability fields on top of those. Claude
Code reads user-invocable, effort, context, and
others. OpenCode and GitHub Copilot
read neither of those. If every client’s fields lived at the top level,
the canonical artifact would violate the specification and become
unreadable to any other agentskills tooling.
The solution is to author capabilities as string-valued keys inside the
metadata map, namespaced by the target client. At install time grim
reads the registry for the target and converts each matching key to its
native YAML type, lifting it into the top-level frontmatter of the file
written to disk. The published artifact stays spec-compliant. Authors
maintain one SKILL.md.
Common vs. vendor-unique capabilities
Not all capabilities need the <vendor>.<field> pattern. The authoring
convention follows one rule: a capability common to several vendors is
authored once as a canonical top-level frontmatter field and projected per
vendor; a capability unique to one vendor is authored as a
<vendor>.<field> string key inside the metadata map.
paths is the clearest example of a common capability: it is a scoping
concept every client understands, even if each client stores it differently
on disk. A rule author writes paths: once in canonical frontmatter.
Claude Code receives it verbatim; GitHub
Copilot receives it joined as a single
applyTo: string. The author does not repeat themselves.
copilot.exclude-agent, by contrast, is a Copilot-only concept. There is
no parallel field for other clients. It belongs in metadata under its
vendor namespace.
keywords and summary stay top-level in every kind (skills, rules,
bundles) — they are catalog fields shared by all clients and by grim’s own
grim search display; they are not vendor-specific.
Authoring example — skill
A skill intended for Claude Code with a specific effort and invocation mode looks like this:
---
name: deep-review
description: A thorough security and correctness review.
metadata:
keywords: review,security
claude.user-invocable: "true"
claude.effort: "high"
claude.when-to-use: "when you want a thorough review of a pull request"
---
# Deep Review
…
The metadata map values are always strings — that is the agentskills
contract. grim converts them to native types at install time.
When grim installs this skill for Claude Code, it
writes a SKILL.md whose frontmatter contains user-invocable: true
(a YAML bool), effort: high, and when_to_use: "when you want a thorough review of a pull request" — native fields Claude reads.
When grim installs the same artifact for OpenCode or GitHub Copilot, those tool-namespaced keys are dropped and neither client receives them.
Authoring example — rule
A rule with Copilot-specific behavior authored
alongside canonical paths scoping looks like this:
---
paths: ["**/*.rs"]
keywords: rust,style
metadata:
copilot.exclude-agent: code-review
---
paths is top-level (common capability). copilot.exclude-agent is
inside metadata (vendor-unique capability). A vendor-namespaced key
authored at the top level is not projected — publish emits a migration
warning:
top-level rule frontmatter key 'copilot.exclude-agent' is not projected;
author it inside 'metadata' instead
keywords and summary stay top-level even in rule frontmatter — they
are not vendor-specific.
Projection semantics
The projection rules are implemented in src/install/render.rs and apply
at both install time and publish-time validation.
| Input key | Outcome |
|---|---|
Known <target>.<field> key — valid literal | Converted to native type, lifted to top-level frontmatter |
Known <target>.<field> key — invalid literal | Hard error: publish fails (exit 65 DataError), install fails with MaterializeFailed |
Unknown <target>.<field> key | Warning emitted, key dropped (typo guard) |
Foreign-namespace key (e.g. opencode.* when rendering Claude) | Dropped silently |
Plain metadata key (non-tool prefix, e.g. vendor.x) | Passes through unchanged |
| No tool-namespaced keys at all | Fast path: verbatim install, byte-identical to canonical |
The three recognized tool namespaces are claude, opencode, and
copilot. Any key whose prefix is not one of these three is plain
metadata and is never treated as a tool key.
When a namespaced key collides with a top-level key of the same name,
the namespaced key wins and a warning is emitted. This situation arises
when a legacy SKILL.md carries both a top-level field and the
namespaced form — the namespaced form is the authoritative value after
migration.
The claude.* skill registry
The table below is the authoritative list of fields grim recognizes for
Claude Code. Every row is a direct mapping from
the CLAUDE_SKILL_FIELDS constant in src/install/vendor_claude.rs.
| Key | Native field | Type | Notes |
|---|---|---|---|
claude.disable-model-invocation | disable-model-invocation | bool | "true" or "false" only; other literals are a hard error |
claude.user-invocable | user-invocable | bool | "true" or "false" |
claude.model | model | string | |
claude.effort | effort | enum | Accepted values: low, medium, high, xhigh, max |
claude.context | context | enum | Accepted values: fork |
claude.agent | agent | string | |
claude.argument-hint | argument-hint | string | |
claude.when-to-use | when_to_use | string | Note: the native key uses an underscore, not a hyphen |
claude.arguments | arguments | string | |
claude.disallowed-tools | disallowed-tools | string | |
claude.shell | shell | enum | Accepted values: bash, powershell |
claude.paths | paths | string | Comma-separated glob patterns |
hooks is not in this registry. It is an object-valued field that
cannot be expressed as a single string metadata value; a separate ADR
governs that surface.
Agent common fields and override precedence
Agents follow the same common-vs-unique rule with one addition. The
canonical agent frontmatter models four common fields — name,
description, model, tools — which grim projects per vendor (see
Agent Artifacts for the full emit matrix). Everything else
is a <vendor>.<field> metadata key.
Two of those vendor keys deliberately shadow a common field: when a
vendor’s registry lifts a key to the same native name a projected common
field uses, the vendor key overrides the common value for that vendor
— silently, with no warning, because the collision is the documented
escape hatch. Example: model: sonnet plus claude.model: opus installs
model: opus for Claude Code while
OpenCode still receives the common sonnet.
The claude.* agent registry
The table below is the authoritative list of agent fields grim recognizes
for Claude Code subagents. Every row is a direct
mapping from the CLAUDE_AGENT_FIELDS constant in
src/install/vendor_claude.rs.
| Key | Native field | Type | Notes |
|---|---|---|---|
claude.model | model | string | Overrides the common model field for Claude |
claude.tools | tools | string | Overrides the common tools field for Claude (comma-separated string, Claude’s native shape) |
claude.disallowed-tools | disallowedTools | string | |
claude.permission-mode | permissionMode | enum | Accepted values: default, acceptEdits, auto, dontAsk, bypassPermissions, plan |
claude.max-turns | maxTurns | integer | |
claude.skills | skills | comma list | Comma-separated string → YAML list |
claude.memory | memory | enum | Accepted values: user, project, local |
claude.background | background | bool | "true" or "false" |
claude.effort | effort | enum | Accepted values: low, medium, high, xhigh, max |
claude.isolation | isolation | enum | Accepted values: worktree |
claude.color | color | enum | Accepted values: red, blue, green, yellow, purple, orange, pink, cyan |
claude.initial-prompt | initialPrompt | string |
mcpServers and hooks are not in this registry — both are object-valued
fields that cannot be expressed as a single string metadata value.
The opencode.* agent registry
Unlike its empty skill registry, OpenCode has a
rich native agent frontmatter. Every row maps from the
OPENCODE_AGENT_FIELDS constant in src/install/vendor_opencode.rs.
| Key | Native field | Type | Notes |
|---|---|---|---|
opencode.model | model | string | Overrides the common model field for OpenCode — the escape hatch when the common value is not provider/model-id-shaped |
opencode.mode | mode | enum | Accepted values: primary, subagent, all |
opencode.temperature | temperature | float | |
opencode.top-p | top_p | float | Note: the native key uses an underscore |
opencode.steps | steps | integer | Maximum agentic iterations |
opencode.prompt | prompt | string | Custom system prompt reference |
opencode.disable | disable | bool | |
opencode.hidden | hidden | bool | |
opencode.color | color | string | Hex color or theme name |
permission (an object) and the deprecated object-valued tools map are
not in this registry.
The copilot.* agent registry
GitHub Copilot CLI custom agents recognize one
projectable vendor key, mapped from COPILOT_AGENT_FIELDS in
src/install/vendor_copilot.rs.
| Key | Native field | Type | Notes |
|---|---|---|---|
copilot.tools | tools | comma list | Overrides the common tools field for Copilot; comma-separated string → YAML list |
mcp-servers (an object) is not in this registry.
Empty registries for OpenCode and Copilot skills
The skill registries for OpenCode and GitHub
Copilot are intentionally empty. Both tools
read only the universal agentskills fields from a SKILL.md; neither
has client-specific skill capabilities that need projection.
Any key prefixed with opencode. or copilot. in the metadata map
of a skill is therefore always unknown. grim emits a warning and drops
it when it encounters one. This behavior is the typo guard: if you
accidentally write opencode.some-key, you get a warning at publish
time rather than silent data loss.
Because both registries are empty, OpenCode and GitHub Copilot produce byte-identical rendered skill files — the unified universal render. A skill installed by grim for Claude Code is also discovered by both other tools, which ignore the lifted Claude fields as unknown keys. This means installing for Claude effectively covers all three clients for skill discovery, with no extra work for authors.
Skill discovery locations
grim installs skills into the directories each client scans for
SKILL.md files.
Project scope (per-workspace, discovered by all three clients):
| Client | Directory |
|---|---|
| Claude Code | .claude/skills/<name>/ |
| GitHub Copilot | .github/skills/<name>/, .claude/skills/<name>/, .agents/skills/<name>/ |
| OpenCode | .opencode/skills/<name>/, .claude/skills/<name>/, .agents/skills/<name>/ |
Global scope (user-level; grim installs directly into each client’s native discovery directory, honoring the client’s own directory-override environment variable):
| Client | Directory | Env override |
|---|---|---|
| Claude Code | ~/.claude/skills/<name>/ | $CLAUDE_CONFIG_DIR/skills/<name>/ — the variable replaces the entire ~/.claude tree (claude-directory reference) |
| GitHub Copilot | ~/.copilot/skills/<name>/ | $COPILOT_HOME/skills/<name>/ — the variable replaces the entire ~/.copilot path (Copilot CLI config-dir reference) |
| OpenCode | ~/.config/opencode/skills/<name>/ (or $XDG_CONFIG_HOME/opencode/skills/<name>/) | $OPENCODE_CONFIG_DIR/skills/<name>/ — OpenCode’s additive scan directory (OpenCode config docs): the XDG default stays scanned either way; grim prefers the override as install target when set. $OPENCODE_CONFIG (a config file path) does not affect skill discovery and plays no role here |
When neither the override variable nor $HOME can be resolved (rare CI
environments), grim falls back to the workspace layout under $GRIM_HOME
for the affected client.
GitHub Copilot skills install natively to
~/.copilot/skills per the Copilot CLI add-skills
documentation. Global rules for GitHub Copilot
have no documented user-level instructions path; grim writes them under
the workspace layout and emits a warning at install time.
Rule-level vendor keys
Rule frontmatter is distinct from the agentskills metadata map. The
canonical structure follows the common-vs-unique principle: the paths
field is top-level (common across multiple clients), and any
vendor-unique capability is authored inside a metadata: map under its
<vendor>.<field> namespace.
The mapping table for rules:
| Client | Field | Source | Output field | Notes |
|---|---|---|---|---|
| Claude Code | paths | top-level | paths | Verbatim — no transform; Claude reads it directly |
| GitHub Copilot | paths | top-level | applyTo | Comma-joined into a single string (Copilot does not accept a list) |
| GitHub Copilot | copilot.exclude-agent | metadata | excludeAgent | Enum: code-review or cloud-agent (registry in src/install/vendor_copilot.rs) |
| OpenCode | — | — | — | No per-file rule frontmatter; loading is registered via opencode.json |
A rule’s paths list is native Claude Code
frontmatter and passes through verbatim. For GitHub
Copilot, grim transforms the rule into a
.instructions.md file whose frontmatter maps paths to a single
comma-joined applyTo: string, then writes the body with a provenance
comment.
The optional copilot.exclude-agent key is authored inside the metadata
map (not top-level) and may take the values code-review or
cloud-agent. Any other value is a hard error at install and publish
time.
OpenCode has no per-file rule frontmatter. grim
writes the rule body (stripping the frontmatter) with a provenance comment,
and registers a managed glob in opencode.json so OpenCode loads it.
A rule with neither paths nor copilot.exclude-agent gets no frontmatter
block in its Copilot transform.
Claude rule install behavior
Claude Code reads paths: natively in rule
frontmatter, so most rules install verbatim — the file written to disk
is byte-identical to the canonical source, and is recorded as
generated: false.
The exception is a rule that carries tool-namespaced metadata keys.
When grim detects any <vendor>.<field> entry inside the rule’s
metadata map, it re-renders the rule for Claude Code:
- Own-namespace keys (
claude.*) are looked up in the Claude Code rule registry. That registry is empty today — unknown own-namespace keys warn and drop. - Foreign-vendor keys (e.g.
copilot.exclude-agent) drop silently. - Plain metadata keys,
paths,keywords,summary, and any forward-compat extras survive unchanged.
The written file carries generated: true. If the cleaned frontmatter
would be empty after this process, the frontmatter block is omitted
entirely. This behavior mirrors how rendered skills are handled and keeps
the installed file as clean as possible.
OpenCode instructions registration
OpenCode loads instruction files through its
instructions config array. grim manages exactly one entry in that array —
a glob pointing at the directory where it writes rules — and keeps it in
sync with the install state.
The entry is added when the first OpenCode rule installs, and removed
when the last one uninstalls — together with the then-empty
.opencode/rules/ directory itself (a directory still holding files is
left alone). Install, update, uninstall, and the TUI all converge through
the same sync call.
For a project-scope install, grim edits opencode.jsonc when it
exists in the workspace root, otherwise opencode.json, and writes the
workspace-relative glob .opencode/rules/*.md.
For a global-scope install, grim edits the file at $OPENCODE_CONFIG
when that variable is set, otherwise the XDG Base Directory
default ($XDG_CONFIG_HOME/opencode/opencode.json, falling back to
~/.config/opencode/opencode.json). The glob in a global config is an
absolute path rooted at $GRIM_HOME.
Config editing is conservative. A config file that does not parse — even after stripping JSONC comments and trailing commas — is never rewritten. grim returns a sync error instead. A parseable JSONC file is rewritten as plain JSON; any JSONC comments it contained are lost. grim emits a warning when that happens.
Drift detection for rendered files
A rendered SKILL.md and a transformed rule are both recorded as
generated: true in the install state. grim computes the integrity hash
against the expected rendered bytes, not the canonical input bytes.
If you edit a rendered file by hand, grim detects the mismatch on the
next grim update or grim status and reports drift. The same drift
detection that covers verbatim-installed files applies here.
Publish-time validation
grim build and grim release run the projection for every supported
client before pushing. The full union of warnings is printed. Any invalid
literal in a known namespaced field stops the publish with exit code 65
(DataError) — the artifact never reaches the registry.
This means errors in metadata keys are caught locally, at the author’s desk, rather than discovered when a consumer tries to install.
Legacy top-level key migration
A SKILL.md that was authored before this feature may carry Claude-specific
fields as top-level frontmatter keys (e.g. user-invocable: true). Those
fields land in the extra map and install verbatim — no breakage.
grim build and grim release emit a migration-nudge warning for each
such key:
top-level frontmatter key 'user-invocable' is not an agentskills field;
author it as metadata 'claude.user-invocable' instead
This is a warning, not an error. Move the field into metadata under the
claude namespace to silence it and gain proper type conversion.