README
¶
ironstate
ironstate is a declarative, Ansible-style task runner driven by YAML: it reconciles each leaf action's desired state against what's currently installed, printing what it would do by default (dry-run) and only making changes when --apply is passed. It ships as a single cross-platform binary (Windows/Linux/macOS - see .goreleaser.yaml); cmd/ironstate is the entrypoint, with the engine/handlers/filters/template code living under internal/ (see Architecture).
Installation
Install script (recommended)
Downloads the release archive matching your OS/architecture from GitHub Releases, verifies its checksum, and installs the ironstate binary to ~/.local/bin by default. Every OS/architecture combination this project ships is built (Windows/Linux/macOS, each on amd64/x86_64 and arm64) - macOS covers both Intel (amd64) and Apple Silicon (arm64).
Linux/macOS:
curl -fsSL https://raw.githubusercontent.com/TacoContent/ironstate/develop/install/install.sh | bash
Windows (PowerShell):
irm https://raw.githubusercontent.com/TacoContent/ironstate/develop/install/install.ps1 | iex
Both scripts accept an install directory and a specific version to pin instead of latest:
./install.sh --dir /usr/local/bin --version v0.1.0
./install.ps1 -InstallDir C:\tools\bin -Version v0.1.0
If no release exists for your OS/architecture, the script exits with an error, a link to open an issue on this repo, and a block of diagnostic text (OS, architecture, script name, the release tag/asset it tried) to paste directly into the issue - see install/install.sh/install/install.ps1.
Manual download
- Download the archive for your OS/architecture from the latest release -
ironstate_<version>_<os>_<arch>.zip(Windows) orironstate_<version>_<os>_<arch>.tar.gz(Linux/macOS).<arch>is Go's naming:amd64(x86_64/Intel) orarm64(Apple Silicon/ARM64) - e.g. an Intel Mac (uname -mprintsx86_64) wantsironstate_<version>_darwin_amd64.tar.gz. - (optional) Verify it against that release's
checksums.txt(e.g.sha256sum -c checksums.txtorGet-FileHash -Algorithm SHA256).checksums.txtis itself keyless-signed with cosign:cosign verify-blob --bundle checksums.txt.sigstore.json checksums.txt --certificate-identity-regexp "https://github.com/TacoContent/ironstate/.github/workflows/release.yml@.*" --certificate-oidc-issuer https://token.actions.githubusercontent.com. - Extract the archive and put the
ironstate/ironstate.exebinary somewhere on yourPATH.
go install
ironstate is a public Go module, so the Go toolchain (1.26+, see go.mod) can build and install it directly from source, no release archive needed:
go install github.com/TacoContent/ironstate/cmd/ironstate@latest
This installs to $(go env GOPATH)/bin (make sure it's on PATH). Since this skips the release build's -ldflags, ironstate version reports dev/none/unknown instead of a real version/commit/date - functionally identical otherwise.
Using zyedidia/eget
eget is a small, cross-platform Go binary that downloads and installs a release from GitHub Releases. It can be used to install ironstate without a shell script:
linux/macOS
eget TacoContent/ironstate --to=~/.local/bin
Windows (PowerShell)
eget TacoContent/ironstate --to=$env:USERPROFILE\.local\bin
Build from source
git clone https://github.com/TacoContent/ironstate.git
cd ironstate
go build -o bin/ironstate ./cmd/ironstate # bin/ironstate.exe on Windows
Getting started
# Scaffold a new playbook (see Playbooks below)
./bin/ironstate init my-playbook
# Dry-run / apply / tags - --playbook takes a specific file, a playbook
# directory (searches for site.yml/main.yml inside it), or a bare name
# with a sibling '<name>.yml'; omit it to use the current directory.
./bin/ironstate --playbook path/to/your/playbook
./bin/ironstate --playbook path/to/your/site.yml --apply
./bin/ironstate --playbook path/to/your/site.yml --tags cli,security --apply
# Verbose (also prints a line for every already-satisfied/skipped leaf)
./bin/ironstate --playbook path/to/your/site.yml --verbose
# Merge in an extra vars document, and/or override individual vars by
# dotted key path (repeatable, highest precedence of all)
./bin/ironstate --playbook path/to/your/site.yml --vars-file ./ci-vars.yml
./bin/ironstate --playbook path/to/your/site.yml --var editor=nvim --var ssh.port=2222
# Introspection
./bin/ironstate filters list
./bin/ironstate doctor
./bin/ironstate version
Exit codes: 0 on a clean run; 1 when the run stopped on a task's failed_when/an unhandled dispatch error; 2 when the site file failed to load or parse (see internal/cli/errors.go).
Cross-platform: ironstate itself builds and runs on Windows/Linux/macOS. Windows-only modules (winget, chocolatey, registry, scheduled_task, path) error clearly when dispatched on a non-Windows host; a shell.host: pwsh task still needs pwsh on PATH wherever it's actually dispatched, same as any other shell host preset needing its own interpreter present. See Facts for the platform/arch/os_family facts a site.yml can branch on.
Output: on a real terminal, results print as a colored table with a per-module emoji, a host-facts panel up front, and a final summary block with elapsed time - a "changed" leaf (installed/removed) reads brighter than an already-satisfied skip, and a failure reads in a danger red. --output json switches to a plain JSON array on stdout instead (informational/progress lines go to stderr, so this stream stays clean and pipeable, e.g. ... --output json | jq); colors auto-disable when not attached to a terminal, honor NO_COLOR/IRONSTATE_NO_COLOR, or can be forced off with --no-color.
Playbooks
ironstate doesn't hard-code a single site file location - like an Ansible playbook, you create your own directory with a site.yml plus whatever hosts/, variables/, packages//roles/ overlays it needs, then point --playbook at that directory (or its site.yml directly). playbooks/camalot/ in this repo is one such playbook, kept here as a real-world worked example (the repo owner's own machine setup) - copy its shape for your own playbook, or start from an empty site.yml. Nothing about the playbooks/ directory name, or camalot itself, is special or required by ironstate - it's just a sample.
--playbook doesn't require the exact file path: point it at a directory and it searches for site.yml, site.yaml, main.yml, then main.yaml inside it; point it at a bare name (e.g. playbooks/camalot) and it also tries a sibling playbooks/camalot.yml/.yaml file; omit --playbook entirely to search the current directory. An error names every path tried if none exist.
ironstate init
ironstate init [playbook-name] scaffolds that starter shape for you - creates <playbook-name>/ (or initializes the current directory if no name is given) with:
<playbook-name>/
├── site.yml
├── roles/
├── tasks/
├── packages/
├── hosts/<machine-name>.yml
└── variables/<user-name>.yml
Every generated YAML file is intentionally minimal (vars: {} / tasks: []) - just enough to be a valid document to start editing; roles/, tasks/, packages/ are created empty (with a .gitkeep placeholder so they survive a fresh git clone). <machine-name>/<user-name> come from this machine's own computer_name/user_name facts (see Facts), lowercased. Re-running init is safe - an already-existing file or directory is left untouched, never overwritten.
./bin/ironstate init my-playbook
cd my-playbook
../bin/ironstate --playbook site.yml
File hierarchy
Files are loaded and merged in this order. Each subsequent file's tasks list is appended to the base file's list; vars deep-merges key-by-key (an overlay can add or override individual vars without wiping out the base set).
<playbook>/
├── site.yml ← base (always loaded)
├── hosts/
│ ├── main.yml ← default, merged if it exists
│ └── <chained-name>.yml ← chained overlays, merged least-to-most specific
└── variables/
├── main.yml ← default, merged if it exists
├── <chained-name>.yml ← chained overlays, merged least-to-most specific
└── <USERNAME>.yml ← legacy per-user overlay, merged last
The chained overlay filenames are built from this machine's own facts (see Facts): hostname, os_family, platform, arch. Use any N of them, joined by ., in any order you like - so a bare single-characteristic name like windows.yml/ubuntu.yml/archlinux.yml (matching os_family) or amd64.yml (matching arch) works, and so does amd64.krayt.yml written arch-before-hostname instead of krayt.amd64.yml:
windows.yml # any host where os_family == "windows"
ubuntu.yml # any Linux host whose distro ID is "ubuntu"
amd64.yml # any host where arch == "amd64"
amd64.windows.yml # arch == "amd64" AND os_family == "windows"
krayt.yml # this specific host, any arch/os
krayt.amd64.yml # same as amd64.krayt.yml - order doesn't matter
krayt.amd64.windows.yml
krayt.amd64.windows.linux.yml # the fully-qualified chain (os_family and platform both listed)
ironstate merges every one of these that exists, broadest first, most specific last. Precedence is a weighted priority - hostname (8) > os_family (4) > platform (2) > arch (1) - so a name's "specificity" is the sum of the characteristics it names: any hostname-containing name always outranks any name without one (8 alone beats 4+2+1=7 combined), and within that, adding more/higher-priority characteristics always ranks higher still. So hosts/windows.yml/hosts/ubuntu.yml applies to any matching machine, hosts/krayt.yml layers a krayt-only override on top of it, and hosts/krayt.amd64.yml layers on top of that again. This same main.yml + chained-overlay mechanism also applies to every include: (roles/, packages/, hosts/<name>/, etc. - see Architecture): a package can ship e.g. roles/foo/windows.yml or roles/foo/krayt.yml right next to its roles/foo/main.yml to override/extend it for a class of hosts or one specific host.
--vars-file <path> (repeatable) merges one or more additional documents on top of everything above, in the order given (a later --vars-file wins over an earlier one on overlapping keys) - highest precedence short of --var - handy for a CI-only or one-off vars file that isn't part of the playbook's own hierarchy:
./bin/ironstate --playbook site.yml --vars-file ./ci-vars.yml
./bin/ironstate --playbook site.yml --vars-file ./ci-vars.yml --vars-file ./local-overrides.yml
--var key=value (repeatable) overrides a single var by dotted key path, after everything else has merged - the final, most explicit word on a var's value:
./bin/ironstate --playbook site.yml --var editor=nvim --var ssh.port=2222
Overlay files use the explicit tasks:/vars: mapping form (not the bare-list form) so they have somewhere to merge into.
.env / .secrets
Before anything else runs, ironstate loads KEY=VALUE lines from a .env file, then a .secrets file, out of the current working directory (not the playbook's own directory, and not relative to the binary) into the process environment - so lookup('env', 'KEY'), a scheduled_task's password_env, or anything else reading $env:KEY/os.Getenv sees them. Either file is optional; a missing one is silently skipped. Each non-blank, non-#-comment line is KEY=VALUE; a value wrapped in matching '...'/"..." has those quotes stripped. Loaded in that order (.env then .secrets), so run ironstate from wherever these files live - typically your playbook's own root, alongside site.yml, if you keep it there.
Secrets and sensitive values
ironstate treats sensitive values as secret once they are known, then masks them in any later output it prints while leaving the value usable for actual evaluation.
This is done in two ways:
.secretsis automatically sensitive. Anything loaded from.secretsis registered as a secret value immediately, before tasks start running. This makes.secretsthe authenticated/stored-secret channel, while.envremains ordinary config and is not treated as automatically secret.- A fact/var/task result can be marked as secret by prefixing the authored name with
$. The$is stripped for runtime access, but the value is still tracked as secret and redacted from logs, tables, and JSON output.
vars:
$github_token: "ghp_..."
tasks:
- fact:
name: $api_token
value: ${{ lookup('env', 'MY_TOKEN') }}
- id: $task_secret
shell:
command: Write-Output "secret message"
Accessible names remain normal after the prefix is stripped:
vars.github_tokenfacts.api_tokentask_secret.stdout
The secret marker is display-only. Conditions and template expressions still resolve the real value; redaction happens when ironstate prints it back out, replacing the sensitive value with *** instead of emitting the raw string. This applies to per-task logging, task result tables, and --output json output alike.
A few safeguards are deliberate:
- very short values are ignored by the registry to avoid over-redacting common values like
trueor1 - transformed outputs such as
${{ vars.token | upper }}are only redacted when the underlying secret value is the exact registered string, matching the usual CI-style secret masking model - a secret task still preserves the real value internally for evaluation, but its verbose log lines and result labels are suppressed/redacted to avoid leaking the script text, task name, or stdout/stderr content
Machine-specific overrides — hosts/
Create a file named after the machine's hostname to add or change tasks for that machine only (the same computer_name fact - see Facts - falls back to the OS hostname on Linux/macOS). You can also chain on .arch/.os_family/.platform for a narrower override (e.g. KRAYT.amd64.windows.yml, or KRAYT.amd64.ubuntu.yml on Linux), use a bare characteristic name with no hostname at all for a broader one (e.g. windows.yml for every Windows machine, or ubuntu.yml for every Ubuntu machine), or drop in a hosts/main.yml as a default applied to every machine before any of these - see File hierarchy.
# Find your hostname
$env:COMPUTERNAME # e.g. KRAYT - or `hostname` on Linux/macOS
# hosts/KRAYT.yml
tasks:
- tags: [gaming]
winget:
package: Valve.Steam
source: winget
state: present
# override: '/DIR="path"' # passes override arguments to the installer
- tags: [cloud, cli]
eget:
package: rclone/rclone
state: present
args:
- --to=~/.local/bin/rclone.exe
- --upgrade-only
User-specific overrides — variables/
Create a file named after the username to add tasks or vars for a specific user account on any machine. This same directory also accepts the chained hostname/arch/os_family/platform overlays (including bare characteristic names like windows.yml or ubuntu.yml) and a variables/main.yml default described in File hierarchy - the username file always merges last (highest precedence of the auto-detected files, before --vars-file/--var).
# Find your username
$env:USERNAME # e.g. ryan - or `$USER`/`whoami` on Linux/macOS
# variables/ryan.yml
vars:
editor: nvim
tasks:
- tags: [http, cli]
pipx:
package: posting
state: present
Architecture
ironstate loads YAML, merges overlays, flattens the task/action tree (accumulating tags and when - see below - and loading any included packages inline as it goes), applies --tags filtering, then dispatches the surviving leaves to their handlers sequentially, in the order the tasks are written (Ansible-style; not grouped by module). "Sequentially" matters: each leaf's when and any remaining ${{ }} references are resolved immediately before that leaf runs, against facts/vars plus a registry that grows as earlier leaves' id/fact results come in - not all evaluated up front - so a task can act on an earlier task's result (see Registering results). The actual per-module logic (how to test/install/uninstall/describe a leaf) lives in its own handler under internal/handlers.
Fact gathering runs first: before any of that, every fact leaf in the whole tag-filtered tree runs as its own pass, in document order, ahead of every other leaf - matching Ansible's "gather facts" phase. This means a fact's own value/when can only see gathered facts, vars, and facts registered earlier in this same pass; it can never reference another task's id-registered result, since no non-fact task has run yet at that point - see fact for how to compute a fact's value from a live command instead.
cmd/ironstate/ ← main package (thin - flag parsing lives in internal/cli)
internal/
├── cli/ ← cobra command tree (root/apply, 'filters', 'doctor', 'version')
├── config/ ← flags/env/config-file loading (viper)
├── packages/ ← YAML loading, hierarchy merge, 'include' package loading
├── model/ ← generic YAML shape + helpers shared across packages
├── tasks/ ← task/action tree flattening (loops, tags/when cascade, 'include')
├── facts/ ← gathered host facts (Windows-real / other-OS build split)
├── expr/ ← shared expression tokenizer/parser/evaluator (variable paths,
│ comparisons, 'is' tests, '|' filter pipeline) - used by both
│ conditions/ and template/
├── conditions/ ← 'when'/'failed_when' expression evaluation
├── template/ ← '${{ }}' resolution passes (soft/strict/self-referential)
├── templateengines/ ← 'jinja' (native) and 'gotemplate' (Go stdlib) render engines
├── filters/ ← built-in '|' filters, plus the external script-filter adapter
│ └── embed/ ← the generic PowerShell shim script filters run through
├── engine/ ← dispatch loop (fact-first two-phase run, registry/facts/
│ command-availability threading, table/JSON/summary output)
├── handlers/ ← one file per module: winget, chocolatey, pipx, npm, cargo, go,
│ gem, eget, zip, symlinks, file, copy, shell, blockinfile, lineinfile,
│ ssh_host_block, log, fail, path, fact, assert, registry,
│ scheduled_task, template
├── ui/ ← terminal color/emoji output styling
└── exec/ ← external-process Runner abstraction handlers shell out through
Each internal/handlers/*.go file implements the shared Handler interface (Test/Describe/Install/Uninstall - see internal/engine/engine.go). To add a new module: register a handler in internal/handlers/handlers.go's All(); add its name to handlers.AllModuleNames (task-tree flattening won't recognize the module key in a YAML task at all otherwise - see internal/tasks.Options.ModuleNames/firstModuleKey); add it to engine.DefaultNoCommandCheckModules if it isn't backed by an external CLI; and, for parity, add it to internal/tasks/realfixture_test.go's realModuleNames. Optionally add a matching $defs entry to ironstate.schema.json for editor validation/autocomplete on the new module's fields.
Custom filters
Two ways to add a | filter (see when conditions for the pipeline syntax):
-
Built-in (compiled in): add a function to
internal/filters/builtins.goand register it there - requires rebuilding the binary, but has no external process to spawn per call. -
Script filter (no rebuild): drop a script implementing the shim's contract into the directory
ironstatescans for filters -filters/by default, resolved relative to the site file's own directory, configurable via afilters.dirconfig value (ironstate doctor --filters-dir <dir>/ironstate filters list --dir <dir>to inspect what's discovered). It's registered automatically at startup under its file's base name (upper.ps1→ theupperfilter,leet.sh→ theleetfilter) - only if no built-in already claims that name (a built-in always wins). If a directory has more than one script implementing the same filter name (e.g. bothleet.ps1andleet.sh), only one wins, by this fixed priority order: PowerShell → bash → zsh → fish → nushell. Five extensions ship a runner today, each via a generic, embedded shim speaking a small JSON-over-stdio protocol:.ps1- PowerShell,param($Value, [object[]] $ArgValues)(internal/filters/embed/shim.ps1), returning any JSON-serializable value..sh/.zsh/.fish- bash/zsh/fish, plain positional args ($1/$argv[1]is the value, the rest are the call's args, always coerced to strings) and stdout as the string result (internal/filters/embed/shim.sh/shim.zsh/shim.fish) - each requiresjqonPATHfor the shim itself to decode/encode requests (these three shells have no native JSON support). On Windows, a barebashonPATHvery commonly resolves to Windows' own WSL launcher stub (System32\bash.exe), which is incompatible (it mangles Windows-style paths) - ironstate skips that launcher and prefers a real Windows-path-aware bash (Git for Windows, MSYS2, Cygwin) instead..nu- nushell,def main [value, ...args] { ... }printing its result (internal/filters/embed/shim.nu) - like the other shells, always a plain string result. Unlike every other extension, a.nufilter is not kept warm as a persistent worker process across calls - nushell has no reliable way to read one request, respond, then read a later request on the same still-open stdin pipe, so each call spawns a freshnuprocess instead (slower per call, but correct).
A
filters.interpretersconfig value maps other extensions to their own interpreter argv for whenever a new script language gets a shim. Each discovered script's interpreter process is started once, lazily, and kept warm for the run's lifetime rather than spawned per call (except.nu, see above).
Run ironstate filters list (or ironstate doctor) to see every filter currently available, built-in or script, by name.
ironstate filters list
Lists every filter available to ${{ }}/when right now - every built-in from the table above, plus whatever script filters (see Custom filters) are discovered under a directory - one <name> <built-in|script> line per filter, sorted by name:
| Flag | Default | Description |
|---|---|---|
--dir |
filters/ |
Directory to scan for external script filters |
$ ironstate filters list --dir path/to/your/playbook/filters
basename built-in
concat built-in
default built-in
...
upper built-in
my_custom_filter script
A name shown as built-in is always resolved from internal/filters/builtins.go, even if a same-named script also exists under --dir - a built-in always wins (see Custom filters).
ironstate doctor
A single at-a-glance health check: confirms every package-manager CLI a site.yml might dispatch to is actually on PATH, plus reports discovered script filters (the same listing filters list gives, folded into one command since both are "is my environment set up correctly" checks).
| Flag | Default | Description |
|---|---|---|
--filters-dir |
filters |
Directory to scan for external script filters |
$ ironstate doctor --filters-dir path/to/your/playbook/filters
[ok] winget C:\Users\you\AppData\Local\Microsoft\WindowsApps\winget.exe
[missing] choco not found on PATH
[ok] pipx C:\Users\you\.local\bin\pipx.exe
[missing] npm not found on PATH
[ok] cargo C:\Users\you\.cargo\bin\cargo.exe
[ok] go C:\Program Files\Go\bin\go.exe
[missing] gem not found on PATH
[ok] eget C:\Users\you\.local\bin\eget.exe
[ok] pwsh C:\Program Files\PowerShell\7\pwsh.exe
script filters discovered under path/to/your/playbook/filters:
my_custom_filter
A [missing] line isn't necessarily a problem - it only matters for the specific package-manager modules (winget/chocolatey/pipx/npm/cargo/go/gem/eget) or shell.host: pwsh tasks your own site.yml actually uses; doctor checks a fixed list of every module this build knows about; bin availability is otherwise re-checked per-module at dispatch time regardless (see Architecture).
Task/action model
A document is either the explicit form:
tasks:
- name: install age via winget
tags: [security, cli]
when: condition == true
actions:
- name: Log a message
log:
message: "Hello, world!"
level: info
- name: foo
winget:
package: FiloSottile.age
source: winget
state: present
or, when you don't need vars in that file, the document root can just be the list directly:
- name: install age via winget
tags: [security, cli]
actions:
- name: Log a message
log: { message: "Hello, world!" }
- name: foo
winget: { package: FiloSottile.age, source: winget, state: present }
Every item in a task list is classified independently:
- Has an
actions:key → it's a grouping task. Itsname/tags/whenscope the whole subtree (nested to any depth, like Ansible blocks). - Has an
include:key → same asactions:, except the subtree is loaded frompackages/<name>/main.ymlinstead of written inline. See Includes. - Neither → it is a leaf action itself, directly. Its one recognized module key (
winget,copy,log, ...) is what runs. This is why a bare list of standalone actions and a list of tasks are the same mechanism - a "task" with noactions:and a "bare action" are identical in shape:
- name: Log a message
log:
message: "Hello, world!"
level: info
- name: foo
winget:
package: FiloSottile.age
source: winget
state: present
tags cascade down: a leaf's effective tags are its own tags plus every ancestor task's tags, unioned. In the first example above, neither action declares its own tags, so both inherit [security, cli] from the enclosing task.
when cascades via AND: a leaf only runs if every ancestor task's when and its own when all evaluate true.
Looping (with/items)
A task carrying with or items is materialized multiple times before anything else about it is looked at - name, tags, when, id, its module fields, or a nested actions/include (looping a whole block, like Ansible). Each copy gets ${{ item }}/${{ item.<key> }} resolved against one value:
items- a list; one materialized copy per entry (Ansibleloop-style).with- a single value; exactly one copy, without iterating ("use this as a reference" rather than a loop). Ignored ifitemsis also set.
tasks:
- name: install ${{ item.name }}
winget:
package: ${{ item.package }}
source: ${{ item.source }}
state: ${{ item.state }}
with:
name: WinToys
package: 9P8LTPGCBZXD
source: msstore
state: present
- name: install ${{ item.package }}
eget:
package: ${{ item.package }}
state: ${{ item.state }}
args: ${{ item.args }}
items:
- package: sharkdp/fd
state: present
args:
- --to=~/.local/bin/fd.exe
- package: another/tool
state: absent
The second example's another/tool entry has no args - resolving ${{ item.args }} for that iteration warns and omits the field entirely (falls back to eget's own default), rather than substituting an empty string of the wrong type. This omit-on-unresolved behavior applies to any field whose entire value is a single unresolved ${{ }} reference, not just item.* - see Template expressions.
Note that with never iterates, even if the value you give it happens to be a list - it's always exactly one copy, with item bound to that whole value. If you want one copy per list entry, that's what items is for.
If a looped task also has an id, every iteration shares that same name - see id on a looped task.
Nested loops (parent)
An actions: entry inside a loop can itself carry with/items, looping again per outer iteration. Doing so rebinds item to the inner value - the outer value would otherwise become unreachable - so it's exposed instead as ${{ parent.item }} / ${{ parent.item.<key> }}. A third level of nesting chains one more parent: ${{ parent.parent.item }}, and so on for however many loops deep you go.
tasks:
- name: for each user
items: "${{ vars.users }}" # e.g. [{ name: alice, repos: [a, b] }, { name: bob, repos: [c] }]
actions:
- name: for each of this user's repos
items: "${{ item.repos }}"
log:
message: "${{ parent.item.name }} owns ${{ item }}"
This prints alice owns a, alice owns b, bob owns c.
Gotcha: a loop task's own items/with expression is resolved before that loop exists - in the enclosing scope, where item still refers to the outer value (there's no parent to reach yet at that point, since the inner loop hasn't started). So the inner loop's own items: ${{ item.repos }} above is correct; writing items: ${{ parent.item.repos }} there would be wrong (and, with no third loop enclosing it, parent wouldn't even be defined). Everything else on that same task - when, tags, its module fields, or a further-nested actions/include - resolves afterward, inside the loop's own per-iteration pass, where item has already rebound and parent is available. In other words: a loop task's items/with line looks "outward" (current scope), everything else about that task looks "inward" (its own new scope, one level past parent).
when conditions
when accepts a single condition string, or a list of strings (list = implicit AND, matching Ansible). Conditions are bare expressions - no ${{ }} wrapper - evaluated against a flat context of gathered facts, user-defined vars (see below; vars win on name collision), and any id-registered results/fact values from earlier tasks (see Registering results).
Grammar (lowest to highest precedence):
expr := or_expr
or_expr := and_expr ("or" and_expr)*
and_expr := not_expr ("and" not_expr)*
not_expr := "not" not_expr | comparison
comparison := membership (("==" | "!=" | "<" | "<=" | ">" | ">=") membership)?
membership := pipeline (("in" | "not in") primary | ("is" | "is not") IDENT)?
pipeline := primary ("|" IDENT ("(" (expr ("," expr)*)? ")")?)* # filter chain, e.g. value | default("x") | upper
primary := STRING | NUMBER | "true" | "false" | "null"
| "[" (expr ("," expr)*)? "]"
| IDENT (("." IDENT) | ("[" NUMBER "]"))* # dotted/indexed path, e.g. nested.value, results[0].rc
| "(" expr ")"
This grammar lives in internal/expr and is shared with ${{ }} template expressions (see Template expressions) - anything documented here, including filters, also works inside ${{ }}, and vice versa.
-
String comparisons (
==,!=, ordering) are case-sensitive, matching Ansible/Jinja. -
in/not in: right-hand side a list → membership check; a string → substring containment. -
A bare variable with no operator is truthy-checked directly (Ansible-style
when: some_var). -
is/is nottype-tests:mapping(aliasmap),boolean(aliasbool),string,number,list,defined,none(aliasnull). Use these instead of==/truthy checks when you need to know a value's actual type rather than whether it casts totrue- notably,some_map == trueis also true for any non-empty mapping, since both go through the same truthy cast, sois mapping/is booleanare the only reliable way to tell "a booleantrue" apart from "a map" (seepackages/languages/main.yml, where a var can be either a blankettrueor a per-key map). -
value | filter(args)pipes a value through a named filter, Jinja-style, and chains left-to-right (value | trim | upper). Filters bind tighter than comparisons, sojava_version | default("25") == "25"filters first, then compares. Every built-in filter below lives ininternal/filters/builtins.go; a playbook can also add its own without recompiling via a script filter (see Custom filters). Unless noted otherwise, every filter is null-in/null-out: anullpiped value passes straight through rather than erroring.Filter Arguments Result defaultfallbackThe piped value if it's non-null, else fallback.togglefallbackThe piped value if it's a string, else fallback- for a var that's normally an on/off boolean flag but may instead be set to a string to name a specific override, e.g.jdk: truevs.jdk: 'Eclipse.Temurin.21'(a baretrue/falseboth fall back, same asnull- see thepackages/languages/javaexample below).ternarywhenTrue, whenFalsewhenTrueif the piped value is truthy, elsewhenFalse(does not passnullthrough - always returns one of its two arguments).enabled...keys(each a plain string, optional)Collapses the repeated (X is mapping and X.Y) or (X is boolean and X)on/off-toggle pattern into one call, at any depth:productivity | enabled('browsers', 'chrome')walksproductivity.browsers.chromekey by key. At each step, a boolean stops the walk immediately and is the answer -trueenables everything below it,falsedisables everything below it regardless of a deeper key's own value (an explicit disable always wins); a mapping keeps descending. Once every key is consumed (or none were given - a bareproductivity | enabledchecksproductivityitself), the value reached counts as "on" if it'strueor still a mapping - present/configured in some structured way, even with no specific leaf checked yet. That last part matters for a wrapping/gating task deciding whether to even look at a section at all, e.g.productivity | enabled('browsers')istrueas soon asproductivity.browsersexists as a mapping of individual toggles, without needing one specific browser's key. Anything else (a missing key, a non-mapping in the way, or a plain string/number at the end) isfalse. Can't be written asproductivity.browsers.chrome | enabled()with no arguments - a filter never sees the surrounding variable context, only the value piped into it, so by the time that whole path resolves to one value, a boolean ancestor has already collapsed everything below it tonull, losing whether it wastrueorfalse.upper/lowernone Uppercase/lowercase the string, invariant culture. trimnone Trims leading/trailing whitespace. quotequoteChar(optional, default")Wraps the value in quoteCharon both sides. A blank/whitespace-only value becomesnullinstead of quoting an empty string.lengthnone .Lengthof a string, or element count of an array;0if the value isnull.concatdelimiter, ...extraItemsJoins the piped value with delimiter: an array value has its elements joined; a scalar value is treated as one element. AnyextraItemsafterdelimiterare appended before joining - e.g."hello" | concat(" ", "world")→"hello world". Use this for joining a list into one delimited string - seejoinbelow for building a filesystem path instead.join...partsCombines the piped value and every argument as filesystem path segments via [System.IO.Path]::Combine(like Python'sos.path.join), e.g."~/base" | join("sub", "file.txt"). Not a delimiter-join - useconcatfor joining a list into a string.splitdelimiterSplits a string into an array on a literal delimiter - the inverse of concat. Drops one trailing empty element, so a valueconcatproduced round-trips.prefixtextPrepends "text "(one space) to the value, or to every element if the value is an array - e.g.item.key | split("\n") | prefix(item.hostnames | concat(" "))builds"hostname key-line"pairs from a multi-line key blob.dirname/basenamenone Parent directory / file name of a path string ( [System.IO.Path]::GetDirectoryName/GetFileName).resolvenone Expands the value through Resolve-UserPath(e.g.~expansion).existsexpected(optional bool, defaulttrue)Tests whether the value - a path string, FileInfo, orDirectoryInfo- exists on disk. Returnsexpectedwhen it does,not expectedotherwise (including fornull/blank).sha1none Lowercase hex SHA-1 hash of a string value; throws on a non-string, non-null value. Handy for a deterministic blockinfilemarker_namederived from content that varies, e.g.${{ item.public_key | sha1 }}.lookupaction, ...piecesFetches external content: lookup('url', ...)does aGETand returns the body;lookup('file', ...)reads a local file (~expanded). Every argument afteractionis concatenated together into the URL/path - if any piece isnull/empty, the whole call returnsnullinstead of composing a wrong target (e.g. a missing per-item value silently skips the lookup rather than requesting a broken URL).from_jsonnone Parses a JSON string into a PowerShell object ( ConvertFrom-Json) - typically piped intojson_querynext.json_queryqueryQueries a PowerShell object (usually piped from from_json) for a nested value. Usesjqsyntax (e.g..ssh_keys) ifjqis onPATH; otherwise falls back toSelect-Object -ExpandProperty <query>, which only supports a single bare property name, not fulljqfilter syntax.
tasks:
- name: only on this machine
when: computer_name == "KRAYT"
winget: { package: Valve.Steam, state: present }
- name: only for admins, unless disabled
when:
- is_admin == true
- not (vars_disabled == true)
shell: { command: "..." }
- name: any of several tags
when: "'gaming' in tags or 'media' in tags"
log: { message: "a gaming or media var is set" }
- name: languages.rust - either a blanket "true" or a per-key map
when:
- (languages is mapping and languages.rust) or (languages is boolean and languages)
include: { name: languages/rust, state: present }
Facts
Gathered fresh every run; a deliberately small, easy-to-extend starter set (see internal/facts/facts.go):
| Fact | Description |
|---|---|
computer_name |
The machine's hostname ($env:COMPUTERNAME on Windows, OS hostname elsewhere) |
user_name |
The current user's name ($env:USERNAME on Windows, the OS user record/$USER elsewhere) |
home |
The current user's home directory |
os_version |
OS version, reported as-is (not reformatted/reparsed): major.minor.build on Windows (its real OS version), macOS's product version verbatim (sw_vers -productVersion, e.g. 14.5), or on Linux the distro's /etc/os-release VERSION_ID verbatim (e.g. Ubuntu 22.04, Debian 13), falling back to the kernel release verbatim (uname -r, e.g. 6.8.0-31-generic) for rolling releases with no VERSION_ID (e.g. Arch Linux) |
os_build |
OS build number (Windows only - always 0 on Linux/macOS, where there's no equivalent single number) |
is_admin |
Whether the current process is running elevated |
shell_pwsh |
Whether pwsh is on PATH |
pwsh_version |
Output of pwsh --version if pwsh is on PATH, else null |
shell_bash |
Whether bash is on PATH |
bash_version |
Output of bash --version (first line only) if bash is on PATH, else null |
shell_zsh |
Whether zsh is on PATH |
zsh_version |
Output of zsh --version if zsh is on PATH, else null |
shell_fish |
Whether fish is on PATH |
fish_version |
Output of fish --version if fish is on PATH, else null |
shell_nu |
Whether nu is on PATH |
nu_version |
Output of nu --version if nu is on PATH, else null |
platform |
Go's GOOS - windows, linux, or darwin |
arch |
Go's GOARCH - amd64, arm64, ... |
os_family |
windows/darwin as-is; on Linux, the distribution ID from /etc/os-release (e.g. ubuntu, debian, archlinux, alpine, redhat, fedora) if detectable, else linux |
Vars
User-defined, under a top-level vars: mapping (merges across site.yml/hosts//variables/ - see File hierarchy):
vars:
enable_extra_tools: true
editor: nvim
Facts and vars share one flat namespace in when (bare facts.<key>/vars.<key>, vars win on collision at the top level). They're also available to ${{ }} string templating with the same prefix: ${{ facts.computer_name }}, ${{ vars.editor }} (alongside the existing ${{ package.* }}/${{ inputs.* }} used by modular packages - see Template expressions).
id-registered results join this same flat namespace, without a prefix, in either when or ${{ }} - see the next section. fact values are the one exception: they're registered under facts.<name> (see fact), not bare, since a user-defined fact is a counterpart to gathered facts, sharing that same namespace - ${{ facts.pwsh_system_profile }}, not ${{ pwsh_system_profile }}.
Registering results (id)
Give any leaf action an id: and, once it runs, its result is registered under that name - matching Ansible's register, just spelled id:
tasks:
- name: check for a file
id: foo_stat
shell:
command: Get-Item -Path "C:\foo\file.txt"
- name: install foo parser
when: foo_stat.rc != 0
winget:
package: foo.parser
state: present
The registered value is shaped like an Ansible registered variable:
| Field | Description |
|---|---|
changed |
Whether this leaf resolved to Install/Uninstall (not Skip) |
failed |
Whether this leaf counted as failed - see Failing a task |
rc |
Exit code. Real for every CLI-backed module (winget/chocolatey/pipx/npm/cargo/go/eget) and shell; every pure-PowerShell module defaults to 0 unless it throws (then 1) |
stdout / stdout_lines |
Captured stdout (joined / split on newlines). Real for the same modules as rc; ''/[] otherwise |
stderr / stderr_lines |
Captured stderr, same as above (a thrown exception's message lands here) |
Reference it with a bare dotted path in when (foo_stat.rc != 0, no ${{ }} wrapper - same grammar as facts/vars) or with ${{ }} in a template (${{ foo_stat.stdout }}, or ${{ foo_stat }} for the whole object).
id on a looped task
An id on a task that also has items (see Looping) is shared by every iteration - naively overwriting the same registry entry each time would silently keep only the last iteration's result. Instead, the flat fields (changed/rc/stdout/...) reflect the last iteration, and every iteration's result is additionally available as .results[N] (0-indexed, in iteration order) - both when and ${{ }} support [N] indexing into a list, interspersed with dotted access:
tasks:
- name: run for each message
id: example_task
shell:
command: "echo ${{ item }}"
items:
- "Hello, world!"
- "Goodbye, world!"
- name: check the first iteration specifically
when: example_task.results[0].rc == 0
log:
message: "first result was ${{ example_task.results[0].stdout }}, last was ${{ example_task.rc }}"
A task with id but no loop never gets a .results key - it stays exactly as it was before this existed.
Ordering matters: this only works because tasks are dispatched sequentially and each one's when/${{ }} are resolved immediately before it runs (not all up front) - see Architecture. Referencing an id that hasn't executed yet (written later in the file, skipped by an earlier when/--tags, or never run because it's an --apply-gated real command evaluated during a dry-run) resolves to $null/the zero-value defaults, not an error - write comparisons defensively (e.g. foo_stat != null and foo_stat.rc != 0) if that distinction matters. In dry-run, changed is still accurately predicted for every module (it doesn't require actually running anything), but rc/stdout/stderr stay at their zero-value defaults for every module since nothing actually runs without --apply.
fact (see below) is the write-your-own-value counterpart to id: it always actually applies (dry-run included), since it's pure bookkeeping with no real system side effect - useful when you want a value available for later when/${{ }} previews without gating it behind an id'd real action. Unlike id, a fact registers under the facts.<name> namespace (see Facts/Vars), not bare.
Native pwsh results
An id'd shell task under the default host: pwsh doesn't just capture text - if the command's only real output (ignoring Write-Host/Write-Information) is a single object, that object's own properties are merged directly into the registered result, right alongside rc/stdout/etc. (a real property is never overwritten by one of those reserved names). So a task that runs a plain PowerShell expression returning a rich object can have its fields referenced directly, no extra indirection:
tasks:
- name: get program files directory
id: pf
shell:
command: (Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion")
- name: log it
log:
message: ${{ pf.ProgramFilesDir }}
This only applies to host: pwsh (the only host with a real "object" concept - an external process like cmd/node/npx tsx only ever produces text) and only when exactly one such object comes out; a command producing plain text, zero objects, or more than one object just gets the usual rc/stdout/stdout_lines/stderr/stderr_lines shape.
A fact can't read this off another task's id (see fact - facts run in their own gather-facts pass, before any id'd task). To turn a native result like this into a fact, give the fact its own embedded shell instead - value then self-references this same merged result, the same convention failed_when uses:
tasks:
- name: program files directory fact
fact:
name: program_files_dir
shell:
command: (Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion")
value: ${{ ProgramFilesDir }} # -> referenced later as ${{ facts.program_files_dir }}
Failing a task (failed_when, continue_on_error)
By default, a leaf counts as failed exactly when its handler reports a non-zero rc (a thrown error also counts, as rc: 1 with the exception's message in stderr). failed_when overrides that: same grammar as when (a single expression, or a list, implicit AND), evaluated against this leaf's own rc/stdout/stdout_lines/stderr/stderr_lines/changed as bare names - no ${{ }} wrapper, no id needed:
tasks:
- name: run a health check
failed_when: rc != 0 or 'ERROR' in stdout
shell:
command: C:\tools\healthcheck.exe
What happens next depends on continue_on_error:
- Unset (default): a failed task stops the whole run immediately - remaining tasks never dispatch, and
ironstateexits with a non-zero code. Matches Ansible's default (noignore_errors). continue_on_error: true: the failure is logged and the run moves on to the next task - matches Ansible'signore_errors: true. The task's result (and itsid-registered value, if any) still reportsfailed: true, so a later task can react to it.
tasks:
- name: best-effort cleanup
continue_on_error: true
shell:
command: Remove-Item C:\maybe\missing.tmp -ErrorAction Stop
Supported modules
| Module | Manager / mechanism |
|---|---|
winget |
Windows Package Manager (winget) |
chocolatey |
Chocolatey (choco) |
pipx |
Python isolated tools (pipx) |
npm |
Node global packages (npm -g) |
cargo |
Rust crates (cargo install) |
go |
Go binaries (go install) |
eget |
GitHub release binaries (eget) |
zip |
Download + extract ZIP (no external tool) |
symlinks |
Symbolic links (no external tool) |
copy |
Copy a local file into place (no external tool) |
shell |
Run an inline PowerShell command or script file (no external tool) |
blockinfile |
Insert/update/remove a marker-delimited block of text in a file (no external tool, modeled on Ansible's blockinfile) |
lineinfile |
Ensure/replace/remove line(s) in a text file (no external tool, modeled on Ansible's lineinfile) |
log |
Print a message at a chosen level (no external tool) |
path |
Add/remove directories on the current user's PATH (no external tool) |
fact |
Set an arbitrary named value for later tasks to reference (no external tool) |
registry |
Write one or more named values under a registry key (no external tool) |
scheduled_task |
Register/update/remove a Windows Task Scheduler task (ScheduledTasks module) |
include |
Pull in another document's tasks from packages/<name>/main.yml (no external tool) |
Every leaf shares these envelope fields, which sit beside its module key, not inside it:
| Field | Values | Description |
|---|---|---|
name |
string | Label used in output |
tags |
list of strings | Used with --tags; cascades down from ancestor tasks |
when |
string or list of strings | Gate(s); AND'd with ancestor tasks' when |
id |
string | Registers this leaf's result for later tasks to reference - leaf actions only. See Registering results |
items / with |
list / any value | Materializes this task once per entry (items) or exactly once (with), templating ${{ item.* }} first. See Looping |
failed_when |
string or list of strings | Overrides whether this leaf counts as failed. See Failing a task |
continue_on_error |
boolean, default false |
Keep running past a failed leaf instead of stopping the run. See Failing a task |
Each module's own fields (documented inline in site.yml) still include state (present/absent/latest, default present).
zip
| Field | Required | Description |
|---|---|---|
src |
yes | URL of the ZIP archive |
dest |
yes | Directory to extract into (~ expansion supported) |
creates |
yes | Glob patterns whose presence signals "already installed" |
include |
no | Filename patterns to extract (whitelist) |
exclude |
no | Filename patterns to skip (blacklist) |
sha256.cache |
no | Path to cache the downloaded archive's SHA256 hash; used by state: latest to skip unchanged archives |
symlinks
A thin wrapper over file (type: link) - see below. Kept as its own module for the simpler src/dest shape.
| Field | Required | Description |
|---|---|---|
src |
yes | Link target (~ expansion supported) |
dest |
yes | Link path (~ expansion supported) |
force |
no | Replace whatever already exists at dest if it isn't already the right symlink. Default true - unlike file's own force (default false), this preserves the original always-replace behavior. Set false to warn and skip instead |
file
Modeled on Ansible's file: manages a path as a plain file, directory, symlink, or hard link.
| Field | Required | Description |
|---|---|---|
path |
yes | Path to manage (~ expansion supported) |
type |
no | file (default, creates an empty file if missing, no-op otherwise), directory (creates it and any missing parents), link (symlink to src), hard (hard link to src), touch (always updates the timestamp, creating an empty file first if missing - like Unix touch) |
src |
one of type: link/type: hard |
Existing path the link points to |
force |
no | When path already exists as something other than type, replace it. Default false - warns and skips instead |
Note: state here is this codebase's usual present/absent/latest (see Task/action model) - unlike Ansible's own file module, which overloads state to also mean the target kind (file/directory/link/hard/touch) plus absent. That's what type is for instead, so this module's dispatch stays consistent with every other handler. absent removes whatever is at path - recursively for a real directory, but a link/hard link is only ever unlinked, never recursed into, so removing a directory symlink can't delete the target's contents.
tasks:
- name: custom PowerShell scripts directory
file:
path: ~/.config/powershell/custom
type: directory
- name: pin a specific config as the active one
file:
path: ~/.config/app/active.json
type: link
src: ~/.config/app/profiles/work.json
copy
| Field | Required | Description |
|---|---|---|
src |
yes | Source file or directory. Resolved relative to the playbook's own root directory (the directory containing its site.yml), or the owning package's own directory — see Includes — unless it's absolute or ~-prefixed |
dest |
yes | Destination path (~ expansion supported) - a directory when src is a directory |
present/latest copy src over dest whenever their SHA256 hashes differ; absent removes dest.
If src is a directory, every file under it is copied recursively, preserving subdirectory structure - rsync/Ansible style: a trailing / on src (e.g. files/custom/) copies its contents directly into dest, while no trailing slash (e.g. files/custom) nests it as dest/custom/... instead. dest is created as a directory either way. present/latest compare each file's SHA256 hash individually (extra files already in dest that aren't under src are left alone - this isn't a mirror); absent removes only the files this task copied, then prunes any subdirectories left empty as a result - never dest itself.
tasks:
- name: custom PowerShell source files
copy:
src: "files/home/.config/powershell/custom/" # trailing slash: contents only
dest: "~/.config/powershell/custom"
state: present
template
| Field | Required | Description |
|---|---|---|
src |
yes | Template source file. Resolved the same way as copy.src |
dest |
yes | Destination path for the rendered output (~ expansion supported) |
engine |
yes | Which template engine renders src: jinja or gotemplate (see below) |
vars |
no | Extra key/value pairs layered on top of facts/vars/registry for this render only (last-write-wins, shallow merge) |
present/latest render src and write it to dest whenever the freshly-rendered content differs from what's already there (a plain string compare - the template equivalent of copy's SHA256 hash compare); absent removes dest. The render context is the same facts/vars/id-registry context when/${{ }} already resolve against, with this task's own vars layered on top.
Two engines:
jinja- a sandboxed, block-capable engine built on the same expression grammar aswhen/${{ }}(see Template expressions):{{ expr }}output,{% if %}/{% elif %}/{% else %}/{% endif %},{% for x in iterable %}/{% endfor %}(nesting supported), and{% set x = expr %}. No cmdlet/command invocation exists in the grammar at all, so a template can only read/compare/filter context values - never cause a side effect.gotemplate- Go's stdlibtext/templatedirectly, unmodified: real Go template syntax ({{ .facts.computer_name }}-style leading-dot field/map-key access,{{ if }}/{{ range }}/{{ with }}, pipelines) against the same render context, rather than${{ }}'s own expression grammar. A missing key renders as a zero value (missingkey=zero) instead of erroring.
tasks:
- name: Render global gitconfig
template:
src: templates/gitconfig.j2
dest: ~/.gitconfig
engine: jinja
- name: Render per-profile gitconfig
items: ${{ vars.git.profiles | default([]) }}
template:
src: templates/gitconfig.profile.j2
dest: ~/.gitconfig-${{ item.name }}
engine: jinja
vars:
profile: ${{ item }}
shell
| Field | Required | Description |
|---|---|---|
command |
one of command/script |
Inline script content, written to a temp file and run. Use a YAML block scalar (|) for multiline scripts |
script |
one of command/script |
Path to an existing file to run instead, resolved the same way as copy.src |
host |
no | What runs command/script, like a shebang line. Default pwsh runs it directly, in-process. Presets powershell, cmd, bash, sh, node, python expand to their executable. Anything else is split on whitespace and used as exe + leading args - e.g. npx tsx - so any script runner on PATH works without code changes |
extension |
no | Overrides the temp file's extension for inline command under a non-pwsh host (defaults to a sensible one per preset, .txt otherwise) - e.g. .ts so npx tsx sees real TypeScript |
args |
no | List of arguments passed to the command/script |
creates |
no | Glob patterns whose presence means "already run". Without it, present/latest always re-run the command. For absent, these paths are removed instead of the command running again |
Per-state command/script (scripted install/uninstall): command/script/args/host/extension can instead be nested one level deeper, under present/absent/latest keys, to run a different command per state:
tasks:
- name: scripted tool
shell:
present: { command: ./install.ps1 }
absent: { command: ./uninstall.ps1 }
creates: [~/.tools/thing/installed.marker]
The block for the item's own state (default present) is picked, and falls back field-by-field to the flat top-level fields for anything that block doesn't set - so plain top-level command/script (no per-state blocks at all) keeps working exactly as before. absent is the one exception with no top-level fallback: the legacy behavior for state: absent has always been "remove creates entries, run nothing", so reusing the top-level (present-oriented) command by default would be a surprising, silent behavior change. Write a dedicated absent block to run something on uninstall; without one, absent still only removes creates entries, same as today. creates itself is always read from the top level regardless of any per-state block, since it's the one signal that decides which state to reconcile to in the first place.
Output is captured, not streamed live: stdout (Write-Host/Write-Output/real native stdout, all merged together) and stderr are captured, then echoed after the command finishes (stdout via Write-Host, stderr via Write-Warning) - so it still ends up on the console, just after completion rather than in real time. This is what backs an id'd shell task's rc/stdout/stdout_lines/stderr/stderr_lines (see Registering results).
Under the default pwsh host specifically, that capture preserves real object identity rather than flattening everything to text: if the command's only non-Write-Host output is a single object (e.g. Get-ItemProperty's result), that object's own properties are merged directly into the registered result - see Native pwsh results.
tasks:
- name: example
shell:
command: |
Write-Host "hello"
Write-Host "world"
state: present # absent removes the `creates` entries instead of re-running
args: [a, b=1]
creates: [~/.foo/bar]
# Non-default host: run an existing TypeScript file through npx tsx.
- name: ts-example
shell:
host: npx tsx
script: files/my-script.ts # an existing file, resolved like copy.src
- name: ts-inline-example
shell:
host: npx tsx
extension: .ts # so the temp file is real TypeScript, not .txt
command: |
console.log("hello from tsx")
blockinfile
Modeled on Ansible's blockinfile: manages a marker-delimited block of text inside a file, leaving everything else in the file untouched.
| Field | Required | Description |
|---|---|---|
dest |
yes | File to manage (~ expansion supported) |
block |
one of block/template, unless state: absent |
Content to place between the markers. Use a YAML block scalar (|) for multiline content |
template |
one of block/template |
Renders a template and uses its output as the block's content instead of a literal block string - same src/engine/vars fields as the template module (minus dest/state, which this task's own dest/state already cover). Wins if both block and template are given |
marker |
no | Template for both marker lines; {mark} is replaced with marker_begin/marker_end, {name} with the identifier from marker_name (see below). Default # {mark} IRONSTATE MANAGED - {name} |
marker_name |
no | Identifier substituted for {name} in marker, so multiple blockinfile tasks can target the same dest without one overwriting another's block. Defaults to this task's own name, then to dest's file name, if not set |
marker_begin |
no | Substituted for {mark} in the opening marker line. Default BEGIN |
marker_end |
no | Substituted for {mark} in the closing marker line. Default END |
insertafter |
no | Where to insert the block when the markers aren't already present: EOF (default), BOF, or a regex - inserted after the last matching line (falls back to EOF if nothing matches). Ignored if insertbefore is set |
insertbefore |
no | Like insertafter, but inserts before the first matching line (BOF for beginning of file). Takes precedence over insertafter when both are set |
create |
no | Create dest (and its parent directory) if missing. Default false - if dest doesn't exist and this is false, the item is skipped with a warning |
backup |
no | Write a timestamped copy of dest (dest.<yyyyMMddHHmmss>.bak) before changing it. Default false |
If the marker lines are already present, the block between them is replaced in place. present/latest write the block whenever its current content doesn't already match; absent removes the marker lines and everything between them.
Note: the default
markertemplate gained a{name}token (seemarker_nameabove). A block written before this change (no name in its marker) won't match the new default and will be treated as not-installed - the nextpresent/latestrun inserts a second, newly-marked block below the old one rather than replacing it in place. Either delete the old marker block by hand once, or pinmarkerto the old# {mark} IRONSTATE MANAGEDtemplate for that task to keep matching it.
tasks:
# Two blockinfile tasks targeting the same dest - each gets its own
# marker pair (from its own 'name') so neither overwrites the other.
- name: dev/ws aliases
blockinfile:
dest: ~/.config/powershell/profile.ps1
create: true
block: |
function dev { Set-Location ~/Development }
- name: shell environment
blockinfile:
dest: ~/.config/powershell/profile.ps1
marker_name: "Override 123" # overrides the identifier instead of using the task's own name
block: |
$env:VIRTUAL_ENV_DISABLE_PROMPT = 1
- name: gitconfig core section
blockinfile:
dest: ~/.gitconfig
marker_begin: "managed-start"
marker_end: "managed-end"
insertafter: BOF
- name: dev/ws aliases, rendered from a template instead of a literal block
blockinfile:
dest: ~/.config/powershell/profile.ps1
create: true
template:
src: templates/dev-aliases.ps1.j2
engine: jinja
vars:
dev_path: ~/Development
block: |
[core]
autocrlf = false
lineinfile
Modeled on Ansible's lineinfile: ensures a single line is present (or absent), optionally replacing by regex or literal search.
| Field | Required | Description |
|---|---|---|
path |
one of path/dest/destfile/name |
File to manage (~ expansion supported). dest, destfile, and name are aliases |
line |
yes for state: present/latest |
Desired line content |
regexp |
no | Regex to match lines. For present/latest, the last match is replaced. For absent, all matches are removed |
search_string |
no | Literal substring match alternative to regexp (mutually exclusive with regexp) |
backrefs |
no | true lets line use backrefs (\1, \g<name>) from regexp matches. If no regex match is found, no insertion occurs |
with |
no | Optional replacement-template context. Keys are available as top-level names and under with/input for ${{ }} (for example ${{ input.Version }}) |
insertafter |
no | Insertion point (when no match is found): EOF (default), BOF, or regex. Regex mode inserts after the last match unless firstmatch: true |
insertbefore |
no | Insertion point alternative to insertafter: BOF or regex. Regex mode inserts before the last match unless firstmatch: true |
firstmatch |
no | With regex insertbefore/insertafter, use the first match instead of the last |
create |
no | Create the file (and parent directory) when missing for present/latest. Default false |
backup |
no | Write a timestamped backup (<path>.<yyyyMMddHHmmss>.bak) before changing. Default false |
present/latest behavior:
- If
regexporsearch_stringmatches, the last matching line is replaced. - If no match is found,
lineis inserted atinsertafter/insertbefore(default end-of-file). - With
backrefs: true,regexpis required and no insertion is done when there is no match.
Replacement string syntax in line:
- Regex capture groups (with
backrefs: true): supports$1,\1, and\g<name>. ${{ ... }}syntax (ironstate expression engine), includingwith/inputcontext aliases.- Go template syntax (
{{ .Version }}style). - Jinja-style syntax (
{{ version }}style).
When both regex captures and templates are used, templates render first, then regex capture replacement is applied.
absent behavior:
- Removes all lines matching
regexp(or containingsearch_string, or exactly equal tolinewhen neither matcher is provided).
tasks:
- name: ensure bell is disabled in bash exports
lineinfile:
path: ~/.bash/custom/exports/default.bash
regexp: '^bind .set bell-style none'
line: "bind 'set bell-style none' 2>/dev/null"
insertafter: EOF
- name: rewrite listen directive with regex backrefs
lineinfile:
path: ~/.config/myapp.conf
regexp: '^(listen=).*$'
line: '\1localhost:9090'
backrefs: true
- name: rewrite version using $1 capture syntax
lineinfile:
path: ~/.config/myapp.conf
regexp: '^([Vv]ersion):\s+.*$'
line: '$1: 1.2.5'
backrefs: true
- name: replacement using go template + with context
lineinfile:
path: ~/.config/myapp.conf
regexp: '^Version:'
line: 'Version: {{ .Version }}'
with:
Version: '1.2.5'
- name: replacement using ${{ }} + input alias
lineinfile:
path: ~/.config/myapp.conf
regexp: '^Version:'
line: 'Version: ${{ input.Version }}'
with:
Version: '1.2.5'
- name: remove legacy directive lines
lineinfile:
path: ~/.config/myapp.conf
state: absent
regexp: '^legacy_'
log
Reuses the present/absent state machine instead of being a special "always run" module kind: state: present (default) or latest always print the install message; state: absent always prints the uninstall message. Log has no real idempotent "already applied" concept - it always fires when reached; state only selects which phase's message prints.
| Field | Description |
|---|---|
message |
Shorthand for install.message - the common "just print something" case |
level |
debug, verbose, info (default), warning, or error |
install.message / install.level |
Nested form: message/level printed when this resolves to Install |
uninstall.message / uninstall.level |
Nested form: message/level printed when this resolves to Uninstall (state: absent) |
tasks:
- name: simple
log:
message: "Hello, world!"
level: info
- name: phase-aware
log:
install:
message: Installing the package
uninstall:
message: The package is being uninstalled.
level: warning
state: absent # -> prints the 'uninstall' message
fail
Unconditionally fails this leaf (non-zero rc) with a message - guard it with when to fail only under a specific condition, like Ansible's fail module. Reuses the present/absent state machine like log: state: present (default) or latest fails with the install message; state: absent fails with the uninstall message.
| Field | Description |
|---|---|
message |
Shorthand for install.message |
exit_code |
The rc this leaf reports (default 1) - applies to whichever phase actually runs |
install.message / uninstall.message |
Nested form: message used when this resolves to Install/Uninstall (state: absent) |
tasks:
- name: stop the run if a required var is missing
when: required_setting is not defined
fail:
message: "required_setting must be set"
exit_code: 2
path
Adds/removes directories on the current user's persistent PATH (User scope - no admin required). Also patches the current process's PATH for entries it actually adds/removes, so later tasks in the same run (e.g. a binary an eget task just installed) are immediately on PATH without a new shell.
| Field | Required | Description |
|---|---|---|
paths |
yes | Directories to ensure on PATH (~ expansion supported) |
tasks:
- name: register local bins
path:
paths:
- ~/.local/bin
- ~/.cargo/bin
state: present
fact
Sets an arbitrary named value - the write-your-own-data counterpart to gathered facts, registered under the same facts.<name> namespace (see Facts/Vars), distinct from id-registered results (see Registering results). Reuses the present/absent state machine like log: state: present (default) or latest (re)sets the fact every time it's reached; state: absent unsets it. No real idempotency - a fact always fires when reached, and always actually applies (dry-run included, since it's pure bookkeeping with no real system side effect).
Every fact leaf runs before every other task (see Architecture) - so a fact's value/when can only reference gathered facts, vars, and facts registered earlier in this same gather-facts pass. It cannot reference another task's id-registered result (no non-fact task has run yet); a fact needing a live command's output must compute it itself via its own embedded shell instead (see below).
| Field | Required | Description |
|---|---|---|
name |
yes | Name this fact is registered under, as facts.<name> (not to be confused with the registry module below - this is the in-memory fact namespace, not the Windows registry) |
shell |
no | Runs this command (same shape as shell) to compute the fact's value, instead of a literal value. See below |
value |
yes, unless state: absent or shell is given |
Any YAML value - scalar, list, or nested mapping. May reference ${{ facts.* }}/${{ vars.* }} (or, with an embedded shell, this same task's own rc/stdout/... - see below), resolved before being stored |
tasks:
- name: define a simple fact
fact:
name: bar
value: foo
- name: define a complex fact
fact:
name: bar
value:
items: [a, b]
another-property:
sub-item: foo
fact with an embedded shell
A fact can compute its own value by running a command directly - the only way to derive a fact from a live command's output, since a fact can't reference a separately-id'd task's result (see above). This command always actually runs, even without --apply - the same dry-run exception the fact registration itself already gets, since a fact has no real system side effect and a dry-run preview of a later when/${{ }} reference needs a real value to check, not a zero-result stand-in:
tasks:
- name: PowerShell profile directory fact
fact:
name: pwsh_profile_dir
shell:
command: Write-Output $PROFILE
value: "${{ stdout | dirname }}"
If value is omitted, the fact is set directly to the command's trimmed stdout. If value is given, it's resolved after the command runs, against this same task's own bare rc/stdout/stdout_lines/stderr/stderr_lines (the same self-reference convention failed_when uses - see Failing a task) alongside the usual facts/vars/id-registry context, so it can also fall back to a var or combine with something else already registered. failed_when on the same task sees the same fields:
- name: PowerShell profile directory fact
fact:
name: pwsh_profile_dir
shell:
command: Write-Output $PROFILE
value: "${{ stdout | dirname }}"
failed_when: rc != 0 or (stdout | length == 0)
assert
Fails this task unless every that condition holds. that uses the same bare-expression grammar as when conditions - dotted/indexed identifiers, == != < <= > >=, and/or/not, in/not in, is/is not, and | filters - evaluated against facts/vars/id-registered results. There's no real idempotent "already applied" state (like log/fact): the check always fires when reached, and - unlike log/fact - it's always actually evaluated even without --apply, since the check is the point and has no system side effect to skip in a dry run.
A failing that becomes this leaf's rc: 1 (message in stderr); a passing one becomes rc: 0 (message in stdout). From there it's an ordinary leaf: default failed_when/continue_on_error behavior applies exactly as it would to any other module (see Failing a task) - a failed assert stops the run unless continue_on_error: true.
| Field | Required | Description |
|---|---|---|
that |
yes | Condition(s) that must all be true. A single expression string, or a list of expressions (implicit AND) - matches when |
fail_msg |
no | Error message used verbatim when one or more that conditions are false. Without it, a message is built from the task's name and every failing condition |
success_msg |
no | Message used verbatim when every that condition is true. Without it, a message is built from the task's name and the number of conditions checked |
tasks:
- name: Verify local bin path fact
assert:
that:
- facts.local_bin_path is defined
- facts.local_bin_path | length > 0
- facts.local_bin_path | exists
fail_msg: "Local bin path fact is not defined or empty. Please define 'facts.local_bin_path' in your inventory or host vars."
success_msg: "Local bin path fact is defined and valid."
registry
Writes one or more named values under a single registry key.
| Field | Required | Description |
|---|---|---|
path |
yes | Registry key path. Supports hive shortcuts HKLM/HKCU/HKCR/HKU/HKCC and their HKEY_* full names (e.g. HKEY_LOCAL_MACHINE\Software\...), with or without a trailing : or forward slashes |
values |
yes | One or more { name, type, value } entries to write under path |
Each entry in values:
| Field | Required | Description |
|---|---|---|
name |
yes | Value name under the key |
type |
yes | One of String, ExpandString, Binary, DWord, MultiString, QWord (matched case-insensitively - DWORD/QWORD work too) |
value |
yes, unless state: absent |
Matches type: a scalar for String/ExpandString/DWord/QWord, a list for MultiString (strings) or Binary (byte values 0-255) |
present/latest create the key if needed and set every listed value, correcting any that exist with the wrong type or data (not just skipping a mismatch). absent removes only the listed value names, never the key itself - a value that exists with the wrong type still gets removed.
tasks:
- name: configure MyApp
registry:
path: HKCU:\Software\MyApp
state: present
values:
- name: InstallPath
type: String
value: C:\Program Files\MyApp
- name: Version
type: DWORD
value: 3
- name: AllowedUsers
type: MultiString
value: [alice, bob]
scheduled_task
Registers/updates/removes a Windows Task Scheduler task by generating a Task Scheduler XML definition and shelling out to the built-in schtasks.exe (part of Windows itself, not PowerShell). Unlike a rich Get-ScheduledTask object model, schtasks.exe has no equivalent to diff field-by-field against, so idempotency is intentionally reduced here: present/latest only check that a task with this name/path exists (plus enabled) - a drifted action/trigger/principal/settings is not detected and re-applied automatically. Use state: latest (or delete and let it re-register) to force a fresh registration after changing one of those fields.
| Field | Required | Description |
|---|---|---|
name |
yes | Task name |
path |
no | Task folder, e.g. \MyApps\. Default \ (root). Normalized to always start/end with \ |
description |
no | |
enabled |
no | Default true. Applied via Enable-ScheduledTask/Disable-ScheduledTask after every (re)registration |
actions |
yes, unless state: absent |
One or more { execute, arguments, working_directory } - programs to run, in order |
triggers |
no | Omit for a manual/on-demand-only task. See below |
principal |
no | Which account the task runs as - omit to run as the current user with standard rights |
settings |
no | Task-level settings - partial-declare like registry's values (only listed keys are managed/compared) |
Each entry in actions:
| Field | Required | Description |
|---|---|---|
execute |
yes | Path to the executable/script |
arguments |
no | Arguments passed to execute |
working_directory |
no |
Each entry in triggers has a type, which selects which other fields apply:
type |
Fields |
|---|---|
logon |
user_id? (omit for any user), delay?, random_delay? |
startup |
delay?, random_delay? |
once |
at (full date+time), repetition_interval?, repetition_duration?, random_delay? |
daily |
at (time-of-day), days_interval? (default 1), repetition_interval?, repetition_duration?, random_delay? |
weekly |
at (time-of-day), days_of_week (required list, e.g. [Monday, Wednesday]), weeks_interval? (default 1), repetition_interval?, repetition_duration?, random_delay? |
delay/random_delay/repetition_interval/repetition_duration accept either an ISO 8601 duration (PT30S, P1D) or a plain .NET TimeSpan string (00:00:30, 1.00:00:00).
Registering a task with a logon or startup trigger requires an elevated (Run as Administrator) session - verified against both Register-ScheduledTask and raw schtasks.exe, so it's a genuine Task Scheduler privilege requirement, not a gap either tool can route around. once/daily/weekly triggers, and a task with no triggers at all, register fine as a standard user.
principal:
| Field | Description |
|---|---|
user_id |
Account to run as (a username, SYSTEM, NT AUTHORITY\SYSTEM, ...). Mutually exclusive with group_id |
group_id |
Group to run as instead of a single user |
logon_type |
None, Password, S4U, Interactive, Group, ServiceAccount, or InteractiveOrPassword |
run_level |
Limited (default) or Highest ("Run with highest privileges") |
password_env |
Name of an environment variable (populated via this repo's own .env/.secrets loading - see File hierarchy) holding the account's password. Only used when logon_type is Password - never write a plaintext password into YAML |
logon_type: Password additionally requires user_id and password_env. A stored task password can't be read back, so idempotency can't detect a drifted password - only state: latest is guaranteed to re-apply one.
settings (only declared keys are managed/compared):
| Field | Description |
|---|---|
disallow_start_if_on_batteries |
|
start_when_available |
Run as soon as possible after a scheduled start is missed |
hidden |
|
wake_to_run |
Wake the machine from sleep to run this task |
allow_hard_terminate |
|
run_only_if_network_available |
|
run_only_if_idle |
|
multiple_instances |
IgnoreNew, Parallel, Queue, or StopExisting |
restart_count |
Retries on failure - Task Scheduler silently ignores this unless restart_interval is also set; declare both together |
execution_time_limit |
Kill the task if it runs longer than this. PT0S means no limit |
restart_interval |
Delay between retries when restart_count is set |
delete_expired_task_after |
Auto-delete the task this long after its last trigger expires |
Test only checks existence (by name/path) plus enabled - it does not diff actions/triggers/principal/settings field-by-field the way registry compares its values, since schtasks.exe has no equivalent object model to diff against. present/latest (re)register the task via schtasks.exe /Create /XML <generated-definition> /F; absent removes it via schtasks.exe /Delete /TN <name> /F.
tasks:
- name: kanata autostart
scheduled_task:
name: kanata
description: Starts kanata at logon
actions:
- execute: ~/.local/bin/kanata-run.bat
triggers:
- type: logon
principal:
run_level: Highest # kanata needs elevation for keyboard remapping
state: present # registering this needs an elevated session - see above
Tags filtering
--tags is a flat set of tags; a leaf runs if --tags is omitted, or if any of its effective tags (its own plus every ancestor task's) match:
# Anything tagged "cli", anywhere in the tree
./bin/ironstate --tags cli
# Anything tagged "cli" OR "security"
./bin/ironstate --tags cli,security
There's no <group>.<tag> scheme - --tags doesn't know about module names at all, only tags.
A leaf with no effective tags at all (neither its own nor any ancestor task's) always runs, regardless of --tags - it can't be deliberately targeted or excluded by a tag it doesn't have, so --tags simply doesn't apply to it; when remains the only gate. This matters for untagged prerequisite tasks a when elsewhere depends on - e.g. a fact that gathers facts.wsl_installed with no tags: of its own must still run under --tags editors for a later, differently-tagged task's when: facts.wsl_installed to see it set at all. Tag an always-relevant task explicitly if you want that documented in the YAML, but it isn't required for it to keep running.
Includes
A related set of items (an eget binary, a symlink to it, a config file to copy, a setup script...) can be defined once as a package under packages/<name>/main.yml, using the same tasks: shape as site.yml (the explicit form, so it has a tasks: key to merge on). Pull it into a run with an include: action, wherever you'd write any other action:
# site.yml
tasks:
- name: install lolcat
tags: [lolcat, cli]
include:
name: lolcat
state: present # optional, default 'present' - see Template expressions below
tags: [keyboard] # optional - see Template expressions below (distinct from the envelope 'tags:' above, which cascades to every included task regardless)
with: # optional - arbitrary inputs, see Template expressions below
layout: colemak
include is handled like actions: during tree flattening (see Task/action model): it loads packages/lolcat/main.yml and splices its tasks: list in at this position, so the included tasks inherit this task's tags/when exactly like a nested actions: list would. Any copy.src or shell.script path inside a package's main.yml is resolved relative to that package's own directory (e.g. files/kanata.kbd → packages/kanata/files/kanata.kbd), not the playbook's own root directory.
# packages/kanata/main.yml
tasks:
- tags: [keyboard, kanata]
eget:
package: jtroo/kanata
state: present
args: [--to=~/.local/bin/kanata, --upgrade-only, --asset=".zip"]
- tags: [keyboard, kanata]
copy:
src: files/kanata.kbd # -> packages/kanata/files/kanata.kbd
dest: ~/.kanata/kanata.kbd
state: present
- tags: [keyboard, kanata]
symlinks:
src: ~/.local/bin/kanata/kanata_windows_gui_winIOv2_cmd_allowed_x64
dest: ~/.local/bin/kanata.exe
state: present
Template expressions
state, tags, and with on an include: action are not applied to the included tasks automatically (that's what the envelope tags:/when: next to include: are for - see the install lolcat example above). They're only made available inside that package's main.yml as GitHub-Actions-style ${{ ... }} expressions, which a package opts into by writing the expression on whichever of its own fields should receive it:
| Expression | Resolves to |
|---|---|
${{ package.name }} |
The package's directory name |
${{ package.state }} |
The state on the include: action (default present if omitted) |
${{ package.tags }} |
The tags array on the include: action (default []) |
${{ inputs.<key> }} |
with.<key> on the include: action (dotted paths like ${{ inputs.<key>.<nested> }} work for nested values) |
${{ facts.<key> }} |
A gathered host fact, or an earlier task's fact (see Facts and fact) |
${{ vars.<key> }} |
A user-defined var (see Vars) |
${{ item }} / ${{ item.<key> }} |
The current loop value, inside a task with with/items (see Looping) |
${{ parent.item }} / ${{ parent.item.<key> }} |
The enclosing loop's value, inside a loop nested within another loop's actions: (chain parent.parent.item etc. for deeper nesting - see Nested loops) |
${{ <id> }} / ${{ <id>.<field> }} |
An earlier task's id-registered result (see Registering results) |
Any of these paths can index into a list with [N] (0-indexed), interspersed with dotted access - e.g. ${{ example_task.results[0].rc }} for a looped task's first iteration. when supports the same [N] indexing in its bare (non-${{ }}) grammar.
The text inside ${{ ... }} is a full expression - not just a bare path - built on the same grammar as when (see when conditions), including its value | filter(args) pipeline and is/is not type tests. The most common use is a value-or-default fallback:
# packages/languages/java/main.yml
vars:
defaults:
languages:
java:
jdk: Microsoft.OpenJDK.25
jre: false
tasks:
- name: Install Java JDK
winget:
package: "${{ languages.java.jdk | toggle(defaults.languages.java.jdk) }}"
state: present
when: languages.java.jdk != false
A vars: block at the top of a package's own main.yml declares package-local defaults: bare top-level names (here defaults, but a package author can name it anything - it's not a reserved word) distinct from site-level vars.*, so a package can express "the user's override, else my own built-in default" as two separate paths rather than one merged value. Package-local vars are available inside that package's own expressions only (not to its caller, nor to a package it in turn includes); if a package-local name ever collides with a site-level var/fact/id-registered name, the site-level one wins.
Here, a user's site.yml sets languages.java.jdk to a plain boolean (true/false, matching every other languages.* toggle) to enable/disable Java without specifying a package - toggle(...) (unlike default, which only replaces null) treats any boolean the same as unset, and falls back to the package's own built-in default; a string value instead names a specific override package ID (e.g. jdk: Eclipse.Temurin.21). The separate when: languages.java.jdk != false is what actually skips the task when the user explicitly disabled it - toggle(...)'s job is only to pick the right package ID once the task is known to be enabled. A filter's argument can just as well be a literal (default('Oracle.JDK.25')) or a site-level path (default(vars.editor)) instead - whichever suits the package. Filters also chain left-to-right (${{ inputs.name | trim | upper }}).
If an expression is the entire value of a field (e.g. state: ${{ package.state }} or tags: ${{ package.tags }}), that field is replaced with the referenced value's native type — a string stays a string, an array stays an array. If that whole-value expression can't be resolved, the field is omitted instead (a warning is logged either way) - the consuming code's own default applies, rather than injecting a wrongly-typed empty string (see Looping for the common case: an optional per-item field like args: ${{ item.args }}). If the expression is embedded inside a larger string instead (e.g. a shell.command or a copy.dest path), it's substituted as text, and an unresolved one just blanks that portion of the string (there's no "omit part of a string" equivalent).
# site.yml
tasks:
- name: kanata
include:
name: kanata
state: absent # toggle the whole package off
with:
layout: colemak
# packages/kanata/main.yml
tasks:
- eget:
package: jtroo/kanata
state: present # this dependency stays installed even if the package is 'absent'
args: [--to=~/.local/bin/kanata, --upgrade-only, --asset=".zip"]
- copy:
src: files/${{ inputs.layout }}.kbd
dest: ~/.kanata/kanata.kbd
state: ${{ package.state }} # follows the package's toggle
Note that ${{ package.tags }} replaces a field wholesale rather than appending — to combine caller-supplied and package-defined tags, reference individual with values instead (e.g. tags: [keyboard, ${{ inputs.extra_tag }}]).
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
ironstate
command
Command ironstate is the single-binary replacement for ironstate.ps1.
|
Command ironstate is the single-binary replacement for ironstate.ps1. |
|
internal
|
|
|
cli
Package cli wires the cobra command tree.
|
Package cli wires the cobra command tree. |
|
conditions
Package conditions implements 'when'/'that' condition evaluation — a thin consumer of internal/expr's shared tokenizer/parser/evaluator, a port of modules/Conditions.psm1.
|
Package conditions implements 'when'/'that' condition evaluation — a thin consumer of internal/expr's shared tokenizer/parser/evaluator, a port of modules/Conditions.psm1. |
|
config
Package config layers CLI flags, IRONSTATE_* env vars, and an optional ironstate.yaml/.ironstate.yaml config file (via viper) into one typed Config struct — see docs/plans/go-rewrite.md §4.1.
|
Package config layers CLI flags, IRONSTATE_* env vars, and an optional ironstate.yaml/.ironstate.yaml config file (via viper) into one typed Config struct — see docs/plans/go-rewrite.md §4.1. |
|
expr
Package expr implements the small Ansible/Jinja-flavored expression language shared by 'when' conditions and '${{ ...
|
Package expr implements the small Ansible/Jinja-flavored expression language shared by 'when' conditions and '${{ ... |
|
facts
Package facts gathers host facts made available to 'when' conditions and '${{ facts.* }}' templating — a port of modules/Facts.psm1's small, deliberately fixed starter set, plus 'platform'/'arch'/'os_family' (docs/plans/go-rewrite.md §1's cross-platform goal): ironstate.ps1 was always Windows-only, but the Go binary itself is genuinely cross-platform (windows/linux/darwin builds, see .goreleaser.yaml), so a site.yml can now usefully branch on 'when: facts.platform == "linux"' - a deliberate addition beyond parity, not a compatibility requirement.
|
Package facts gathers host facts made available to 'when' conditions and '${{ facts.* }}' templating — a port of modules/Facts.psm1's small, deliberately fixed starter set, plus 'platform'/'arch'/'os_family' (docs/plans/go-rewrite.md §1's cross-platform goal): ironstate.ps1 was always Windows-only, but the Go binary itself is genuinely cross-platform (windows/linux/darwin builds, see .goreleaser.yaml), so a site.yml can now usefully branch on 'when: facts.platform == "linux"' - a deliberate addition beyond parity, not a compatibility requirement. |
|
filters
Package filters implements the built-in '|' pipeline filters (a direct port of filters/*.ps1) plus the external script-filter adapter described in docs/plans/go-rewrite.md §4.5 (see script.go/pool.go).
|
Package filters implements the built-in '|' pipeline filters (a direct port of filters/*.ps1) plus the external script-filter adapter described in docs/plans/go-rewrite.md §4.5 (see script.go/pool.go). |
|
model
Package model holds the generic, dynamically-typed document shape every other package works with — YAML decoded into map[string]any / []any / scalars, mirroring how the PowerShell implementation treats ConvertFrom-Yaml's [ordered] hashtables + IList duck-typing.
|
Package model holds the generic, dynamically-typed document shape every other package works with — YAML decoded into map[string]any / []any / scalars, mirroring how the PowerShell implementation treats ConvertFrom-Yaml's [ordered] hashtables + IList duck-typing. |
|
packages
Package packages implements YAML document loading, the site/host/user file-hierarchy merge, and 'include:' resolution — a port of modules/Packages.psm1.
|
Package packages implements YAML document loading, the site/host/user file-hierarchy merge, and 'include:' resolution — a port of modules/Packages.psm1. |
|
pathutil
Package pathutil holds small filesystem-path helpers shared across internal/filters and (in later phases) internal/handlers — ports of Common.psm1's Resolve-UserPath / ConvertTo-NormalizedPathString and the 'join' filter's .NET Path.Combine semantics.
|
Package pathutil holds small filesystem-path helpers shared across internal/filters and (in later phases) internal/handlers — ports of Common.psm1's Resolve-UserPath / ConvertTo-NormalizedPathString and the 'join' filter's .NET Path.Combine semantics. |
|
tasks
Package tasks implements task/action tree normalization and flattening — a port of modules/Tasks.psm1's Expand-TaskTree.
|
Package tasks implements task/action tree normalization and flattening — a port of modules/Tasks.psm1's Expand-TaskTree. |
|
template
Package template implements '${{ ...
|
Package template implements '${{ ... |