magus v0.3.0 is out. See what's new
¶ View markdown source · ✎ Suggest an edit
18 min read

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) and stop (a graceful-shutdown command) are optional, as are distinct and idle (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 (via magus\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. (Service is 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-fmtgoFmt, golangci-lintgolangCILint, ruff-checkruffCheck). 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-fmt is gofmt; golangci-lint is golangci-lint. There is no magus-specific lint/fmt alias 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-fmt for an ad-hoc op-direct run (see targets). In normal use you compose go["go-fmt"](ctx) into a format target and run magus 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.
  • magus module API: magus\project.register, magus\cache.remote.
spellsoperationstoolchaincachetargetsgorustmagusfile
Last updated (a103255f)
Earlier changes on this page (7)

Full history ↗ · Blame source ↗

Glossary

Workspace

The magus root directory that owns a set of projects and shared config; the unit magus operates over. See workspace.

Project

A directory magus recognizes as a unit of work (it has a magusfile); the unit of caching, scheduling, and dependency tracking. See workspace.

Magusfile

The magusfile.buzz that declares a project's targets (as export funs) and binds its spells. See targets.

Target

A named operation (build, test, ...) you invoke with magus run <target>; it may compose a spell's tool-native operations and depend on other targets. See targets.

Operation

A single tool-native command a target composes; the middle of the work hierarchy (Spell to Operation to Target). See operations.

Spell

A language/runtime adapter (e.g. go, md) that maps generic targets onto a toolchain's real commands. See spells.

Charm

An execution modifier attached with : (lint:rw) that changes how a target runs, not which one; the built-in rw flips a check-only target to mutate in place, and ci always strips it. See charms.

Ward

A coded diagnostic that inspects a resolved op and nudges or blocks an anti-pattern before it runs. See wards.

Module

A magus stdlib namespace a magusfile imports for host capabilities: filesystem, exec, vcs, and more. See the module reference.

Buzz

The language magusfiles are written in (the .buzz engine). See engines.

Engine

The interpreter a magusfile runs on; magus embeds the Buzz engine. See engines.

Cache

The content-addressed store magus consults before running a target, so unchanged work is skipped. See cache.

Affected

The set of projects touched by a change; magus affected <target> runs a target only over them. See affected.

Sandbox

The restricted filesystem and environment a target runs in, so builds stay reproducible and side-effect-free. See sandbox.

Service

A long-running or shared process magus manages across runs, distinct from a one-shot target. See services.

Daemon

The background magus host that owns shared state such as services and the warm knowledge graph. See daemon.

CI

An ordinary magusfile-defined target you compose yourself with magus\needs - magus does not hardcode its stages. Magus.RunCI treats it specially only in that it strips the rw charm, it is the anchor magus affected ci keys off, and a selected scope with no project declaring it is a load error rather than a silent no-op. See targets.

Slot

One unit of the pool's capacity. A target acquires the slots it needs to run (most take one) and releases them when it finishes; the pool tracks capacity (total slots), running (acquired), and queued (blocked). See daemon.

Concurrency

How many targets run at once. It is bounded by the pool's capacity and set with --concurrency, MAGUS_CONCURRENCY, or the concurrency config key. See daemon.

One-off

A single magus invocation that runs a target and exits, using a per-process pool; the opposite of the long-lived daemon or a service. See daemon.

Remote cache

A CI-only backend that shares content-addressed artifacts across runners: a cold machine replays a build another runner already did instead of rebuilding. Every remote artifact must be signed by a trusted key. See remote-cache.

Snapshot

A point-in-time view of live state - the pool's occupancy or a tick of exported metrics - as opposed to accumulated history. See daemon.

MAGUS.md

The committed routing index at a workspace root, regenerated from the knowledge graph: it lists every node and points at the exact query for a given question, so it is the entry point an agent reads first. See knowledge.

Conventions

Placeholders

Angle brackets mark a value you replace with your own - never type the brackets:

magus run <target>
magus completion <shell>    # e.g. bash, zsh, fish

<target>, <path>, <shell>, <name> and the like are stand-ins, not literal text.