Charms
A charm is a named modifier that changes how a target runs, not which target or project. Where a Target answers "what operation, on what project" (see targets) and a Spell answers "how a tool performs an operation" (see spells), a charm answers "in what manner."
Charms replace the one-off boolean flags (--write, --fix, --verbose) that build tools accumulate. A charm is a shared, reusable intent applied as an RFC 6902 JSON Patch over the target's argument vector. There is no bespoke per-charm DSL.
Design intent
- Shared. A charm name means the same thing everywhere:
rwmeans "mutate in place" forformat,generate, orlint. A charm useful only with one target is a smell; that is a one-off tool flag, not a charm. - Intent over implementation. A charm says what you want; each target maps it to its own tool invocation (
gofmt -w,prettier --write,golangci-lint --fix). You never memorize per-tool flags. - Composable. Charms stack: orthogonal intents like
rwanddebugcombine without special-casing. - Bounded. A charm edits arguments only; it cannot swap the command or replace the whole argv. See Charm vs Target.
Additive by design
Charms only ever add intent. You turn one on by naming it (:rw); the active set
is the union of what you named and the workspace default_charms, and nothing else.
There is no per-charm "off" switch, and that absence is deliberate.
The one subtraction magus offers is coarse: --no-default-charms drops the whole
default baseline at once, after which you re-add exactly what you want. There is no
--without-charm=rw to peel a single charm off the set.
Some task runners take the other path, with named configuration profiles you toggle on and off per invocation. That turns the active configuration into a set you are always managing: enable a default, then remember to disable it here and re-enable it there. magus keeps the active set an explicit, additive union instead - what you see named is what is on. If you do not want a charm's effect, do not add it.
This is a design decision, not a shortcoming. An additive-only model keeps a charm's meaning stable and the active set legible at a glance; a subtractive one trades both away for a convenience that usually just papers over an over-broad default. When the defaults feel like something you fight, the fix is a smaller default set, not a subtraction operator.
The mechanism: a JSON Patch over the argv
A target runs a command: a cmd (e.g. go) and a base argv (e.g. ["tool", "golangci-lint", "run", "./..."]). magus treats the argv as a JSON array and a charm as an RFC 6902 JSON Patch applied over it.
Each operation is { op, path, value?, from? }:
op |
Effect on the argv | Needs |
|---|---|---|
add |
insert value at path |
value |
remove |
delete the element at path |
|
replace |
overwrite the element at path with value |
value |
move |
delete at from, insert at path |
from |
copy |
read at from, insert at path |
from |
test |
assert the element at path equals value (else the run errors) |
value |
path and from are JSON Pointers (RFC 6901): "/0", "/1", ... for a specific index; "/-" for append (valid only as target of add/move/copy).
Most authors use the charm constructors instead of writing raw patches: they resolve a value anchor to an index at author time so you never count indices.
Applying charms on the CLI
Charms attach to a target name after :, comma-separated:
magus run format:rw # the `rw` charm on `format`
magus run lint:rw,debug api # two charms, on `lint`, in project api
magus run go::go-test:debug # spell-qualified op + a charm
The project is a positional argument, not part of the token. See the full grammar in targets.md#cli-grammar.
The rw charm
rw (read→write) is a built-in charm. It flips a target from check-only (read) to mutate-in-place:
| Target | read (default) | rw |
|---|---|---|
format |
check formatting | rewrite files (gofmt -w, prettier --write, ruff format) |
generate |
write generated outputs | |
lint |
report findings | apply autofixes where supported (e.g. golangci-lint --fix) |
rw carries no special flag. Like every other charm, you activate it with a :rw suffix (magus run format:rw). There is no -w/--write shortcut and no --write flag: the suffix is the one way to ask for it.
CI is always read-only. Magus.RunCI strips the rw charm before dispatch, so the composite ci pipeline can never mutate the tree even if a caller requests it (e.g. ci:rw). rw is the only charm with this strip status; the other built-in (cd) and every workspace charm you define are ordinary vocabulary that survive into ci.
Defaulting charms per workspace (default_charms)
Every run is read-only by default. A workspace can opt into a different baseline with default_charms in magus.yaml: charms applied to every magus run and magus x automatically, so a team that wants local autofix does not type :rw each time:
# magus.yaml
default_charms: [rw] # `magus run format` now writes; no :rw needed
Per-run charms stack on top, exactly as if you had typed the whole set. Three things keep it safe:
magus affecteddoes not apply them, so CI (which runsmagus affected ci) stays read-only regardless of the workspace default.RunCIstill stripsrw, so even a localmagus run civerifies without writing.--no-default-charmsignores the defaults for one run (magus run format --no-default-charmsto check without rewriting).
MAGUS_DEFAULT_CHARMS (comma-separated) is the environment equivalent. It only sets the default baseline; it never changes what a charm means, so has_charm("rw") in spells and targets is unaffected.
Stacking and composition
Charms are an unordered, additive set. When you pass several, they all apply:
magus run lint:rw,debug
# │ └─ debug: add verbose flags
# └──── rw: apply autofixes
The patches of all active charms are concatenated in sorted charm-name order and applied as one sequential patch over the base argv:
- Deterministic.
lint:rw,debugandlint:debug,rwproduce the same result; duplicates are insignificant. - Composable. Charms edit individual argv elements, so edits on disjoint positions compose freely. Edits targeting the same position resolve by sorted charm-name order, so one charm silently wins and the other has no effect. Because that winner is an alphabetical accident rather than a declared precedence, magus treats it as a mistake: it warns at run time and flags the overridden charm in
magus describe target ...:a,b. Two charms that must both apply should edit different arguments, or one should own the position.
Example with base go tool golangci-lint run ./...:
rw : add "--fix" at /3 → go tool golangci-lint run --fix ./...
debug : add "-v" at /- → ... -v
magus run go::golangci-lint:rw,debug # → go tool golangci-lint run --fix ./... -v
Previewing the rendered command
magus describe target renders the fully charm-applied command statically (no execution):
$ magus describe target lint:rw,debug
project: . target: lint
charms: [debug rw]
spell: go
command: go tool golangci-lint run --fix ./... -v
Add charms to the target ref and the command: line updates. Two caveats: a magusfile-function target computes its argv at runtime, so no static command is shown; and describe never executes or writes files even for :rw. (--dry-run reports at the target level; use describe to see the command itself.)
Seeing each charm's edit: --explain
--explain turns the single command: line into a step-by-step trace: the base
command, then the command after each active charm's patch, in the deterministic
sorted-name order magus applies them. It is the RFC 6902 patch made legible, so
you can see exactly which charm made which edit without reading the patch data.
$ magus describe target --explain lint:rw,debug
project: . target: lint
charms: [debug rw]
spell: go
command: go tool golangci-lint run --fix ./... -v
charm trace:
base go tool golangci-lint run ./...
+ debug go tool golangci-lint run ./... -v
+ rw go tool golangci-lint run --fix ./... -v
The flag comes before the target ref (--explain lint:rw), like every other magus
subcommand flag. A charm that is active but changes nothing for this target adds no
line, and a charm whose patch does not apply to the command is reported as
MGS6001 rather than dropped silently.
Declaring what a charm does
A charm only does something for a target that declares it. Declarations live in a spell's charms table, keyed by charm name. Two charm-construction modules exist (pick by where the spell runs), plus the raw-data escape hatch.
1. Built-in command spells (import "magus/charm")
A built-in command spell is self-contained: it imports only the pure-Buzz modules magus/spell and magus/charm, so it compiles to bare bytecode with no host bindings. magus/charm exports the core constructors as bare functions (the same flat-import idiom magus/spell uses for Target). Each resolves a value anchor to an index so you never count positions:
import "magus/spell";
import "magus/charm";
fun lint(target: Target) > Command {
final args = ["tool", "golangci-lint", "run", "./..."];
return Command{bin = "go", args = args, charms = {
"rw": after(args, "run", ["--fix"]), // insert after "run" - index-proof
"debug": append(["-v"]), // append
}};
}
magus/charm ships the core constructors: append, prepend, after, before, set, drop. For the advanced set (move/copy/test, the predicate *Func variants, path) reach the host charm module from a non-built-in spell (below). The constructors return the same {ops: [...]} record the raw form below produces, so the two interoperate.
2. Workspace spells & magusfiles (import "charm")
A workspace spell (imported by path) or a magusfile runs with host bindings, so it imports the host charm module, which carries the full constructor set as namespaced methods:
import "charm";
final args = ["tool", "golangci-lint", "run", "./..."];
charms = {
"rw": charm\after(args, "run", ["--fix"]), // insert after "run"
"debug": charm\append(["-v"]), // append
"stamp": charm\move(args, "run", charm\path(args, "./...")), // advanced: see below
};
The argument-removing constructor is named
drop(charm\drop), notremove: a charm module is a Buzz map, and the built-in map.remove()method would shadowremove. This is a constructor name only.charm\dropemits the standard RFC 6902{"op": "remove", ...}op. The patch vocabulary does not change; magus never deviates from RFC 6902.
3. Raw RFC 6902 data (the lowest level)
The constructors are pure convenience: they only build the {ops: [...]} record, a plain RFC 6902 JSON Patch. That record is the underlying type a charm declares (Charm{Ops: []PatchOp}, see the patch model). A constructor resolves the anchor to an index and returns the same record you could type by hand. The two forms are interchangeable; the helper used by the bundled spells and the literal it returns are the same value:
final args = ["tool", "golangci-lint", "run", "./..."];
"rw": after(args, "run", ["--fix"]), // helper: anchors on "run"
"rw": {"ops": [{"op": "add", "path": "/3", "value": "--fix"}]}, // ≡ the record it returns
You can declare the patch notation explicitly for an op no constructor covers (several edits in one charm), for full control, or by preference. The raw form is first-class, not a fallback. What you give up by hand is the anchoring: "/3" is a counted index that silently breaks if an earlier arg moves, whereas after(args, "run", ...) recomputes it. That index-proofing is why the bundled spells prefer the helper.
The six ops are exactly RFC 6902's (add/remove/replace/move/copy/test); see the patch model. magus adds no ops and renames none; the constructors are sugar over this vocabulary.
4. Function targets & ops (branch in code)
When the argv needs to be computed, branch in code. A magusfile function target receives the forwarded CLI args:
export fun lint(ctx: magus\Context, args: [str]) > void {
var fix = false;
for (a in args) { if (a == "--write") { fix = true; } }
os\exec("golangci-lint", if (fix) ["run", "--fix"] else ["run"]);
}
A function target reads the active charm set directly with ctx.has_charm(name), including the built-in read→write toggle, has_charm("rw"). This is how a function target selects which command to run, the one thing a charm itself cannot do (see the boundary). For example, a build target can compile the host binary by default and switch to the container image under a container charm:
export fun build(ctx: magus\Context, args: [str]) > void {
if (ctx.has_charm("container")) { ctx.needs(image_build); }
else { ctx.needs(go_build); }
}
Because the toggled targets are reached by nested dispatch (not the top-level selection), build:container trips the soft typo-guard warning. That is expected here, since a function target legitimately reads a charm no spell declares (see Discovery).
Spell op methods receive the active charm set as opts.charms (a lookup table: if opts.charms.rw then). Charms a spell does not declare or test for are ignored.
The charm constructor reference
Both charm modules build a charm's patch; every constructor returns { ops = [...] }. The argv-taking constructors resolve a value anchor (or predicate for the *Func variants) to a numeric JSON Pointer at author time, so the stored patch is pure positional RFC 6902.
The table is the full set, available on the host charm module (import "charm", called charm\after(...)). The pure-Buzz magus/charm module (import "magus/charm", called bare as after(...)) exports the core rows (append, prepend, after, before, set, drop) for self-contained built-in spells.
| Constructor | Builds |
|---|---|
append(vals) |
add each of vals at the end (/-) |
prepend(vals) |
insert vals at the front, in order |
after(argv, anchor, vals) |
insert vals just after the first element equal to anchor |
before(argv, anchor, vals) |
insert vals just before anchor |
set(argv, anchor, val) |
replace the element equal to anchor with val |
drop(argv, anchor) |
remove the element equal to anchor |
afterFunc(argv, fn, vals) |
after, but match by predicate |
beforeFunc(argv, fn, vals) |
before, by predicate |
setFunc(argv, fn, val) |
set, by predicate |
dropFunc(argv, fn) |
drop, by predicate |
move(argv, anchor, to) |
move the anchor element to pointer to ("/-" end, "/0" front, or path(...)) |
copy(argv, anchor, to) |
copy the anchor element to pointer to |
test(argv, anchor) |
guard: assert anchor is still at its position when the patch applies (else the run errors) |
moveFunc / copyFunc / testFunc |
the above, matching by predicate |
path(argv, anchor) |
the JSON Pointer ("/N") of anchor, for use as a to destination or in a hand-written op |
pathFunc(argv, fn) |
path, by predicate |
Method names are camelCase (charm\afterFunc, charm\pathFunc), following Buzz's convention.
A missing anchor is a load-time error, not a silently wrong index.
Use-case cookbook
The examples use the host module (charm.*); inside a self-contained built-in spell call the bare magus/charm form (append(...), after(...), and so on) instead.
Append a flag (e.g. a debug charm adding -v):
debug = charm\append(["-v"]);
// {"ops":[{"op":"add","path":"/-","value":"-v"}]}
Insert after a subcommand (anchor by value, index-proof):
// base ["test", "./..."]: add -race right after "test"
race = charm\after(["test", "./..."], "test", ["-race"]);
// {"ops":[{"op":"add","path":"/1","value":"-race"}]}
Swap a flag (e.g. gofmt -l . → -w .):
rw = charm\set(["-l", "."], "-l", "-w");
// {"ops":[{"op":"replace","path":"/0","value":"-w"}]}
Drop a flag (e.g. ruff format --check . → ruff format .):
rw = charm\drop(base, "--check"); // host; bare drop(base, "--check") in magus/charm
// {"ops":[{"op":"remove","path":"/3"}]}
Several edits in one charm (remove higher indices first to avoid reshuffling). Drop to the raw form, since each constructor yields a single op:
// cargo fmt -- --check → cargo fmt
rw = { "ops": [
{"op": "remove", "path": "/2"}, // "--check"
{"op": "remove", "path": "/1"}, // "--"
]};
Move/copy/test (advanced, host module only). Reposition an existing arg, or guard that one is where you think:
// move the matched flag to the end; charm\path resolves an anchor to its pointer
reorder = charm\move(base, "--config", "/-");
// assert "run" is still at its index when the patch applies, else the run errors
guard = charm\test(base, "run");
Match by predicate (the *Func variants, host module only):
cap = charm\setFunc(base, fun(s: str) > bool { return s.startsWith("-j"); }, "-j16");
Conditional or per-invocation logic belongs in a function target, not a charm. Charms are static data resolved at author time.
Charm vs Target: the command boundary
A charm rewrites a target's arguments. It can never change the base command (cmd) or replace the whole argv. ValidatePatch rejects the root pointer (""), so every charm op edits an element of the argv, never the array as a whole.
ValidatePatch enforces this boundary deliberately.
Decision guide
| You want to... | Use a... | Why |
|---|---|---|
run the same command with different flags, as a reusable named intent (rw, debug, race) |
charm | shared vocabulary; composes; CI can strip it |
| run a different command, or compute the argv, or branch on runtime state | target or spell op | only a target/op defines a command |
| pass a flag for one invocation only | -- passthrough (magus run test -- -run TestX) |
not a reusable intent |
| pick which project / spell runs | positional project arg / spell:: qualifier |
identity, not a modifier |
Why the boundary exists
- Composability. Argument edits layer cleanly across charms. Two charms each replacing the whole command is an unresolvable conflict; element-level edits have a well-defined merge.
- The one-intent contract. A charm name is supposed to mean the same thing everywhere. A charm that redefines a target's command is a bespoke per-target redefinition wearing a charm name:
rwwould meangofmt -where,terraform applythere, and the name no longer carries a single meaning. - Transparency. With the boundary, the running command is the target's declared
cmd(visible in the spell,magus describe, logs). Without it, the real command would depend on an invisible matrix of active charms. - Abuse prevention. Without the boundary, charms become a general-purpose override mechanism, and the shared vocabulary rots into hidden behavior per project.
Charms modify args; targets define commands. When you want a different command, write a target.
When you've left the charm layer
Function target (most common): write an exported function and call the tool via os\exec:
// magusfile.buzz
import "os";
export fun lint(ctx: magus\Context, args: [str]) > void {
var fix = false;
for (a in args) { if (a == "--write") { fix = true; } }
os\exec("golangci-lint", if (fix) ["run", "--fix", "./..."] else ["run", "./..."]);
}
Workspace spell: author a spells/<name>.buzz spell (imported by path) with an ops entry and wire per-project charms there. The spell owns the command; charms tune its args.
:: hatch: magus run go::go-vet api reaches a single spell op directly. It is an escape hatch, not the everyday surface.
Dynamic values: no interpolation, use the language
Charm args are literal: there is no ${VAR} interpolation, by design. The host language is the interpolation engine:
-
Known at load time: build the string in code and pass it to a constructor:
import "charm"; import "env"; charms = { "rw": charm\after(base, "run", ["--config={env\get("LINT_CONFIG")}"]) }; -
Per-invocation: use a function target. Charms are static data; they cannot read the env at run time.
How a charm reaches a spell
magus run lint:rw,debug
│
▼
ParseTarget → Target{Name:"lint", Charms:["rw","debug"]}
│
▼
WithCharms(ctx) → ctx carries {"rw","debug"} types/charm.go
│
▼
spell.Cast(ctx) → HasCharm(ctx,"rw") / HasCharm(ctx,"debug")
│
▼
resolveCharmArgs → concatenate active charms' patches (sorted name),
apply once over the base argv fork.go
HasCharm is a set-membership test; a spell reacts to charms it knows and ignores the rest. Charms are never part of a Target's durable identity (which is Path + Name; see targets).
Discovery
magus describe <target> lists the charms a target declares and renders the resulting command (see Previewing the rendered command):
magus describe target lint:rw,debug
# → project: . target: lint
# charms: [debug rw]
# command: go tool golangci-lint run --fix ./... -v
An active charm that no selected target declares (and isn't a reserved built-in like rw) prints a soft warning as a typo guard. It is only a warning, not an error, because a function target may legitimately read a charm it never declares.
Naming
Charm names use the target-name charset: letters, digits, -, _ (types.ValidateCharmName). By convention they are lowercase and represent shared vocabulary across the workspace: define a charm's meaning once and honor it everywhere. A charm useful only with one target is a smell; that is a one-off tool flag (pass it after --).
Names are normalized the same way target names are (types.Normalize, kebab-case - one function for targets, charms and spell ops alike), so matching is case- and separator-insensitive on both sides: :Rw, :rw, and :RW are one charm, as are :no_cache and :no-cache. A spell that tests has_charm("noCache") is matched by a :no-cache suffix and vice versa; declaration and invocation can't drift on spelling.
What is not a charm
- A different command. Charms rewrite args, never
cmd; see the boundary. - A whole-argv rewrite. The root pointer is rejected; express the change as individual
replace/remove/addops. - Project selection (
api,/): positional arguments, not charms. - Spell qualifier (
go::): aRunOption(WithSpellFilter), stripped by the CLI before charms are parsed. - One-off tool flags: pass these after
--(magus run test -- -run TestX).
Reference: the patch model
type PatchOp struct {
Op string // add | remove | replace | move | copy | test
Path string // JSON Pointer: "/N" or "/-" (add/move/copy targets only)
Value string // for add | replace | test
From string // for move | copy
}
type Charm struct {
Ops []PatchOp // an RFC 6902 patch over the target's argv
}
ValidatePatch enforces: the op is one of six; path is non-empty and rooted at / (root path rejected, forbidding whole-argv replacement); move/copy carry a /-rooted from. ApplyPatch runs ops in order over a copy of the argv (the base is never mutated), with bounds-checked indices. The implementation is verified differentially against the reference RFC 6902 library (evanphx/json-patch).
Glossary
| Term | Definition |
|---|---|
| Charm | A named, shared execution modifier carried in context. Target.Charms / []string. |
rw |
A built-in charm: mutate in place (format/generate; lint autofix where supported). Read via has_charm("rw"). Stripped from ci. |
gha |
A built-in charm: opt into GitHub Actions output. Swap a tool to its GHA annotation format (ruff/buf/sqlfluff/vitest), or have a target emit GHA-shaped output (the ci-shard job matrix → $GITHUB_OUTPUT). Set via :gha. A no-op where unsupported; not stripped from ci. |
| JSON Patch | The RFC 6902 document a charm declares: an ordered list of element-level ops (add/remove/replace/move/copy/test) over the target's argv. |
| PatchOp | One operation: {op, path, value?, from?}. |
| Anchor | A value (or predicate) a charm.* constructor resolves to a numeric JSON Pointer at author time. |
| Stacking | Multiple charms apply together: patches concatenate in sorted-name order and apply as one sequential patch. |
| The boundary | Charms edit argv elements only (never cmd, never the whole argv). Enforced by ValidatePatch. |
HasCharm |
The set-membership query a spell uses to react to a charm; unknown charms are ignored. |
See also
- operations: the Operation whose argv a charm patches, and where charms sit in the hierarchy.
- targets: what a Target is, and the CLI grammar charms attach to.
- spells: what a Spell is, and Spells vs Targets.
- modules/charm: the generated
charmmodule API reference.