Spells
A Spell is a library of tool-native operations for one toolchain, plus the cache and affected-set metadata that toolchain needs. The go spell exposes go-build/go-test/go-vet/go-fmt/golangci-lint/...; the rust spell exposes cargo-build/cargo-test/cargo-clippy/cargo-fmt/.... Each op is named after the CLI command it runs (see Naming operations). A spell is bound to a project and runs nothing on its own: it contributes operations your magusfile composes into targets, and it tells the cache which files are inputs and outputs.
A spell is how a tool does something (the go-vet, cargo-clippy ops); a target is what you run (magus run lint). You bind spells and invoke targets. See Spells vs Targets.
Built-in spells
magus ships these spells. Import each with import "magus/spell/<name>"; follow a link for its ops, charms, and paste-ready examples you can dry-run in the browser.
| Spell | Language | Ops | Purpose |
|---|---|---|---|
bash |
Shell | 1 | Bash spell: shellcheck linting for shell scripts. |
buf |
Protobuf | 5 | Buf spell: protobuf build, lint, format, and code generation. |
buzz |
Buzz | 3 | Buzz spell: check and test .buzz sources, plus run them through the magus interpreter. |
cosign |
- | 3 | Cosign spell: keyless sign, attest, and verify for container artifacts. |
docker |
Docker | 5 | Docker spell: image build, build-check, buildx, and hadolint Dockerfile linting. |
go |
Go | 13 | Go toolchain spell: build, test, vet, fmt, mod-tidy, golangci-lint, and govulncheck as magus ops. |
markdown |
Markdown | 3 | Markdown docs spell: markdownlint and prettier for linting and formatting prose. |
python |
Python | 6 | Python toolchain spell: pytest, ruff check/format, and uv build/clean as magus ops. |
rust |
Rust | 6 | Rust toolchain spell: cargo build, test, clippy, fmt, and clean as magus ops. |
typescript |
TypeScript | 11 | TypeScript toolchain spell: tsc, eslint, prettier, and vitest run through the project package manager. |
Spells vs Targets
These are the two core nouns in magus, on orthogonal axes. Confusing them is the usual reason a build runs and does nothing.
| Spell | Target | |
|---|---|---|
| What it is | a library of tool-native operations (+ cache metadata) | an addressable unit of work you run |
| Answers | how a tool performs an operation | what operation runs on which project |
| Vocabulary | the tool's own CLI command (go-vet, cargo-clippy, tsc, eslint, ruff-check) |
magus's lifecycle (build, test, lint, format, clean, generate, preflight) |
| Who declares it | a built-in or a spell file (spells/*.buzz) |
an exported function in your magusfile |
| How it enters a run | bound to a project via magus\project.register |
invoked via magus run <name> |
| Runs on its own? | No: it only contributes ops + cache inputs | Yes: it is the entry point |
| Cardinality | many ops per spell; many spells per project | one function per target name per project |
| Cache role | declares needs/provides/claims (the inputs/outputs) |
the unit a cache key is computed and replayed for |
| Identity | a name + its ops |
Path + Name (see targets) |
The relationship is compositional: a target's body calls spell ops.
import "magus/spell/go";
magus\project.register(fun(p, cb) > bool { cb({ "spells": [go] }); return true; }); // bind the spell (runs nothing)
// targets are the runnable verbs; their bodies call the spell's ops. Op keys are
// the CLI command, so kebab names are reached by subscript (see Naming operations),
// and every op call takes the target's ctx as its first argument.
export fun build(ctx: magus\Context, args: [str]) > void { go["go-build"](ctx, { "cwd": "." }); }
export fun lint(ctx: magus\Context, args: [str]) > void { go["golangci-lint"](ctx, { "cwd": "." }); }
export fun test(ctx: magus\Context, args: [str]) > void { go["go-test"](ctx, { "cwd": "." }); }
magus run lint . # runs your `lint` target, which calls go's golangci-lint op
When to use which
- Reach for a target when you want a runnable verb: the thing a teammate or CI types (
magus run test api). Targets are your public surface; declare one per lifecycle step you want runnable. Until you export a target for an operation,magus run <op>is a graceful no-op. - Reach for a spell when you want to package a toolchain's operations and tell the cache which files matter. Bind a built-in or load a spell file. Call its ops from inside target bodies.
- Skip the spell entirely for a one-off step with arbitrary logic: write the target body directly with the host modules (e.g.
os\exec(...)). A spell earns its keep when an operation recurs and has cache inputs worth declaring. - Use the
::escape hatch (magus run go::go-vet api) only for ad-hoc runs or introspection. The everyday surface is your composed targets.
magus deliberately does not decide what "lint" or "format" means. A spell supplies tool-native operations in the tool's own words; your magusfile decides which op backs each lifecycle target. Toolchain knowledge lives in the spell (reusable, cacheable); policy lives in the magusfile (yours to compose).
What a spell provides
A bound spell contributes three things to its project. Only operations are "runnable"; the other two are metadata that make caching and the affected set correct.
| Contribution | Source | Purpose |
|---|---|---|
| Operations | mgs_listTargets (or ops) |
the tool-native actions a target can call |
needs |
mgs_listRequiredGlobs (or needs) |
input globs hashed into the cache key; also affected-set seeds |
provides |
mgs_listProvidedGlobs (or provides) |
output globs the cache snapshots and replays |
claims |
mgs_listClaimedGlobs (or claims) |
files this spell owns for affected-set attribution |
Binding a spell contributes its needs/claims/provides to that project's cache key and affected set even before you wire any target; it executes nothing until a target calls one of its ops.
An operation is a command or a service
An op is one of two declarative shapes, and the shape it returns is its kind:
- A command op returns a
Command: a{bin, args, charms}object naming a program on PATH, its argument vector, and any charm modifiers. magus forks it directly (no shell, no variable expansion) and runs it to completion. This is the default and the vast majority of ops. - A service op returns a
Service, a long-running process.command(required) is the process;readiness(a probe polled until it exits 0, the Kubernetes exec-probe model) andstop(a graceful-shutdown command) are optional, as aredistinctandidle(see services). A service run directly (magus run dev) is forked in the foreground and blocked on (Ctrl-C signals it). A service reached as a dependency (viamagus\needs) is instead supervised in the background: started, gated on its readiness probe, and shared by configuration fingerprint so several dependents run one instance, and, when a daemon is running, kept warm across invocations. (Serviceis a distinct return type so the op's kind is inferred from what it returns.) A service op's target is uncached: magus never replays or snapshots it, so a re-run restarts the process instead of a cache hit doing nothing.
Either way the op is declarative data, so its argv is charm-patchable, hashes into the cache key, and previews under magus describe without executing. The kind lives on the op, not the spell: one spell freely mixes command ops and service ops under one name (a node spell builds and serves). Both shapes are static data, unlike the imperative two-op-kinds split magus removed; they differ only in lifecycle (run-to-completion vs long-running).
You author an op as a function (the usual form) or a bare record; the kind is inferred from what it returns. magus doctor enforces a doc comment on each function-authored op, captured by the Buzz parser at compile time.
// command op: derive the `rw` charm from the arg list
fun goFmt(target: Target) > Command {
final args = ["-l", "."];
return Command{bin = "gofmt", args = args, charms = { "rw": set(args, "-l", "-w") }};
}
// service op: a long-running dev server `magus run` blocks on
// (command is required; readiness and stop are optional)
fun nodeServe(target: Target) > Service {
return Service{ command = Command{bin = "npm", args = ["run", "dev"]},
readiness = Command{bin = "curl", args = ["-sf", "http://localhost:5173"]} };
}
// a map that mixes op kinds is typed `any` (its values have different function types)
export fun mgs_listTargets() > any { return {"go-fmt": goFmt, "serve": nodeServe}; }
In-VM work is still not an op. Custom logic magus neither forks nor blocks on (HTTP, signing, a remote cache backend's get/put) is not an op at all. A remote cache backend is a separate contract magus's core invokes by name (see Remote caching); any other one-off logic belongs in a magusfile target body written directly with the host modules (os\exec, http, crypto).
Binding a spell to a project
A spell only takes effect when its handle is passed to magus\project.register. Importing a spell is pure; it registers nothing on its own.
Built-in
Built-in spells are compiled into the magus binary.
import "magus/spell/go";
magus\project.register(fun(p, cb) > bool { cb({ "spells": [go] }); return true; });
Available built-ins: go, typescript, python, rust, bash, buf, buzz, docker, cosign, markdown.
File spell
A workspace-local spells/<name>.buzz, imported by path (import "spells/ruby"
resolves ./spells/ruby.buzz and binds the handle under the basename; as
renames it):
import "spells/ruby" as rb;
magus\project.register("gems/", fun(p, cb) > bool { cb({ "spells": [rb] }); return true; });
Composing spells
Spells do not import one another. There is no spell-to-spell import, and a built-in spell may import only the pure-types magus/spell module (enforced by SelfContainedBuiltinSource). Composition happens one level up, at the project: bind several spells to the same project and let your targets call across them.
import "magus/spell/go";
import "magus/spell/docker";
magus\project.register(fun(p, cb) > bool { cb({ "spells": [go, docker] }); return true; }); // co-bound
export fun build(ctx: magus\Context, args: [str]) > void {
go["go-build"](ctx, { "cwd": "." });
docker.build(ctx, { "cwd": "." }); // one target, ops from two spells
}
The go/docker relationship is exactly this co-binding, not an import: both are bound to a project and their ops are composed in target bodies. The cache sees the union of every bound spell's needs/provides/claims.
Two magus APIs take a spell handle as an argument, and both are a magus call consuming a spell rather than a spell importing a spell:
magus\cache.remote(github)wires a cache-backend spell (e.g.github-actions,aws-s3) as the remote cache backend. See Remote caching.magus\ci.provider(github)wires a CI-provider spell, which teaches magus one CI system's job-log structure: fold markers around a failure, and annotations that surface on a pull request. See CI providers.
Both are extension points on purpose. magus itself knows neither a cache service's API nor a CI system's log syntax, so supporting one it has never heard of is a spell you write rather than a release you wait for.
Naming operations
An op's public name is the map key in mgs_listTargets (or the ops table). That key is what a magusfile calls and what magus run spell::op invokes. The implementation function is private. Name both after the CLI command, not after a magus lifecycle verb, so the spell is self-documenting and a developer who knows the toolchain can invoke an op without reading the magusfile.
Op key: the CLI command, kebab-case, lowercase, no flags. Write the command as you type it and replace spaces with hyphens. The golang spell:
| runs | op key | handler |
|---|---|---|
go build |
go-build |
goBuild |
go vet |
go-vet |
goVet |
go test |
go-test |
goTest |
gofmt -l |
go-fmt |
goFmt |
go mod tidy |
go-mod-tidy |
goModTidy |
golangci-lint |
golangci-lint |
golangCILint |
Naming the op golangci-lint (not lint) and go-fmt (not fmt) says exactly which tool runs: there is no go lint, and fmt here is the gofmt binary. Multi-tool spells already work this way: typescript exposes tsc/eslint/prettier/vitest, one op per tool.
Handler: the same command in lowerCamelCase, with Go-style initialisms (go-fmt → goFmt, golangci-lint → golangCILint, ruff-check → ruffCheck). The handler name is invisible to magus; it exists to tell the reader the exact binary.
Not every op is a CLI command. A no-op marker (typescript's preflight) or a cache-backend verb (github/s3 get-entry) is not a tool invocation, so keep a descriptive name.
Op keys are matched verbatim (no kebab/case normalization, unlike target names), so a kebab key is reached by subscript in a magusfile: go["go-build"](ctx), not go.build(ctx). An op whose key is a valid identifier (pytest, eslint) can use dot: py.pytest(ctx). Either way the first argument is the target's context - the ctx the target function received - and any options follow it: go["go-build"](ctx, { "cwd": "." }).
This is the one place magus has two name spaces with different rules, so it is worth stating side by side:
| Target names | Op keys | |
|---|---|---|
| Matching | normalized both sides (how) | verbatim |
goBuild finds |
go-build |
nothing |
| Magusfile call | magus run go-build |
go["go-build"]() |
| Dot access | n/a | only if the key is an identifier |
The practical consequence: a hyphen in a target name costs you nothing, while a hyphen in an op key is what forces subscript notation. That is accepted deliberately (see below) rather than overlooked, because the op key is the tool's own name and magus does not invent a second spelling for it.
Explicitness over magic (why go::go-fmt stutters)
The full-command convention is enforced even for streamlined toolchains like Go, where go-build/go-test/go-fmt all start with go. The result reads with a stutter on the CLI (magus run go::go-fmt), and that is by design:
- Consistency across a polyglot repo. Most languages split work across separate binaries (typescript:
tsc/eslint/prettier/vitest; rust:cargo/clippy/rustfmt). A rule that says "name the op after the binary, always" is one rule for every spell, instead of a special abbreviation for the few single-binary toolchains. - No invented vocabulary.
go-fmtisgofmt;golangci-lintisgolangci-lint. There is no magus-specificlint/fmtalias a reader has to learn or a magusfile has to map. The op name is the command. - The stutter shows up only in the escape hatch. You type
go::go-fmtfor an ad-hoc op-direct run (see targets). In normal use you composego["go-fmt"](ctx)into aformattarget and runmagus run format. magus favors explicitness over magic: the spell says exactly what it runs, and policy (which op is your "format") lives in your magusfile.
Authoring a custom spell
For the full contract - every
mgs_function, the built-in versus workspace-local constraint, and the provider variants - see Writing a spell.
A spell file exposes the spell contract as mgs_-prefixed functions: the required mgs_getName, plus optional mgs_listRequiredGlobs, mgs_listProvidedGlobs, mgs_listClaimedGlobs, mgs_listIgnoreDirs, mgs_getVersionProbe, mgs_isOpaque, and mgs_listTargets.
MGS functions are discovery-time declarations: they take no arguments and must be
pure, because Magus calls them before it has selected a target or started an
execution. File metadata uses generated Path values, not strings. Put
per-invocation inputs and calls to other spell operations on ordinary typed spell
functions, then compose those functions explicitly from a magusfile target.
A spell is the layer that carries logic, so it is also the layer worth testing -
unlike the magusfile that binds it, which should stay thin enough that the
question never arises. See Testing for where that line
sits and how to write in-file test "..." {} blocks.
mgs_listIgnoreDirs names the non-source directories your ecosystem generates (a Rust spell returns [Path{value = "target", isDir = true}]; a Node spell, [Path{value = "node_modules", isDir = true}]). magus prunes them from the input-hashing walk of any project this spell resolves, so a build tree never counts toward the cache key. Dot-directories are always skipped, so only non-dot names belong here.
Buzz (spells/ruby.buzz):
export fun mgs_getName() > str { return "ruby"; }
export fun mgs_listRequiredGlobs() > [Path] {
return [Path{value = "**/*.rb"}, Path{value = "Gemfile"}, Path{value = "Gemfile.lock"}, Path{value = "*.gemspec"}, Path{value = ".rubocop.yml"}];
}
export fun mgs_listProvidedGlobs() > [Path] { return [Path{value = "vendor/bundle/**/*"}]; }
export fun mgs_listTargets() > any {
return {
"bundle-install": { "cmd": "bundle", "args": ["install"] },
"rspec": { "cmd": "bundle", "args": ["exec", "rspec"] },
"rubocop": { "cmd": "bundle", "args": ["exec", "rubocop", "--check"],
"charms": { "rw": {"ops": [{"op": "replace", "path": "/2", "value": "-A"}]} } },
};
}
Then import it by path, bind it, and compose targets that call its ops:
import "spells/ruby" as rb;
magus\project.register("gems/", fun(p, cb) > bool { cb({ "spells": [rb] }); return true; });
export fun test(ctx: magus\Context, args: [str]) > void { rb.rspec(ctx, { "cwd": "gems/" }); }
export fun lint(ctx: magus\Context, args: [str]) > void { rb.rubocop(ctx, { "cwd": "gems/" }); }
For cache-correctness rules (declare every input in needs, declare provides so outputs replay, toolchain-version footguns), see Spells in the README.
What magus bounds, and what it does not
A spell is code. That is the design, not a compromise: a build system whose configuration cannot express a loop or a conditional pushes that logic into shell scripts nobody can cache, or into the CI provider nobody can run locally. Config as code means the config is code, and code runs.
So the question is not "how do we stop a spell doing things" - it is "which things does magus govern, and which are yours". Being explicit about that line is the point of this section.
What magus does not restrict
A spell has the whole host module surface:
os\exec, http, fs, crypto, the lot. It can run any command your shell
can, reach the network, and read and write files. Nothing here is sandboxed
per-spell, and importing a spell is trusting it, the same way adding a dependency
is trusting it.
magus does not attempt to make an untrusted spell safe. It cannot: the feature being asked for is arbitrary code execution. What it does instead is make the governed path the convenient one, so a spell that behaves ordinarily is automatically accounted for.
What magus bounds
| Bound | Applies to | Default |
|---|---|---|
| Concurrency slot | every os\exec, so parallel work respects -j |
on |
| Own process group | subprocesses, so Ctrl+C reaches magus and not them | on |
| Target deadline | a whole target, subprocesses included | off (target_timeout) |
| Filesystem sandbox | the magus process, where the platform supports it | per sandbox config |
| CI provider op deadline | each provider spell op | 5s, fixed |
The runaway guard
A magusfile can express a loop, so a loop that never terminates is something
someone can write by accident. Nothing else reclaims a CI runner that hit one, so
target_timeout bounds a single target:
# magus.yaml
target_timeout: 30m
The Buzz VM samples cancellation on loop back edges, so a spinning target notices promptly without the interpreter paying for a check on every instruction.
It is off by default, deliberately. The deadline covers the whole target, subprocesses included - cancelling the context kills what the target spawned - so a value set near a legitimate target's runtime turns a slow compile into a failed build. Set it well above your slowest target, as a runaway guard rather than a performance budget.
A gap worth knowing
The Buzz language stdlib ships its own os\execute, separate from magus's
os\exec. It spawns a subprocess without taking a concurrency slot, without its
own process group, and without appearing in the run log. It is the one way a
spell can run work that magus does not account for, and telling the two apart by
name alone is not obvious.
Prefer os\exec. It is the governed path, and everything in the table above
applies to it.
Lifecycle: bind → contribute → compose → run
import "magus/spell/<name>" or "spells/<name>" → a Spell handle (registers nothing)
│
▼
register(fun(p, cb){ cb({spells}) }) → the spell is bound to the project;
│ its needs/provides/claims now feed the
│ project's cache key and affected set
▼
export fun <name>(...) > void {} → a target whose body calls spell ops
│ (spell.op(ctx, {"cwd": ...})); this is the
│ runnable verb
▼
magus run <name> <project> → executes the target; spell ops fork
their commands (cached by needs/provides)
Key invariant: binding is not running. A bound spell with no target wired is inert at run time but still shapes the cache key. A target with no spell behind it is just a function you wrote (valid; call the host modules directly).
Glossary
| Term | Definition |
|---|---|
| Spell | A library of tool-native operations for one toolchain, plus its cache/affected metadata. Bound to a project; runs nothing on its own. |
| Op (operation) | One tool-native action a spell exposes, named after its CLI command (go-vet, golangci-lint). Reached by subscript on the handle (go["go-vet"](ctx)) or via spell::op on the CLI. See Naming operations. |
| Handle | The value bound by importing a spell (import "magus/spell/<name>" for a built-in, import "spells/<name>" for a workspace-local one). Inert until passed to magus\project.register. |
needs |
Input globs (mgs_listRequiredGlobs). Hashed into the cache key; also seed the affected set. |
provides |
Output globs (mgs_listProvidedGlobs). What the cache snapshots and replays on a hit. |
claims |
Files a spell owns (mgs_listClaimedGlobs), for affected-set attribution. |
| Op | A command op forks a Command ({bin, args, charms}) to completion; a service op is a long-running Service ({command, readiness?, stop?, distinct?, idle?}): foregrounded when run directly, supervised in the background when reached as a dependency. |
| Op kind | Whether an op is a command (returns a Command, run to completion, the default) or a service (returns a Service, a long-running process). Inferred from the return type; lives on the op, so one spell mixes both. |
| Target | The runnable unit a spell op is composed into. A separate concept; see targets. |
Worked examples in this repo
Read these spells under spells/ when you outgrow a plain fork spell:
| Spell | Role | What it demonstrates |
|---|---|---|
buf |
fork (built-in) | A codegen producer: needs (.proto + buf config) and provides (generated code), so editing a .proto reruns codegen and invalidates everything downstream of the generated files. |
github-actions |
cache backend | A remote cache backend over the GitHub Actions Cache API in pure Buzz: bearer auth, byte-level chunked upload/streamed download (the http byte primitives), wired with magus\cache.remote. |
aws-s3 |
cache backend | A remote cache backend for S3/MinIO/R2/B2 that signs every request with AWS SigV4 via crypto's keyed-hash primitives. |
See also
- Operations: the formal definition of an op and the Spell → Operation → Target work hierarchy.
- Anatomy of a magus Target: the unit you run, and the CLI grammar for addressing it.
- Charms: execution modifiers that spell ops and targets both honor.
- Engines: how a magusfile runs on the embedded Buzz VM and the
mgs_spell contract. - Spells (README): built-ins list, extending a built-in, and custom-spell best practices.
magusmodule API:magus\project.register,magus\cache.remote.