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.