alba 0.2.1

Alba, the Beamfile task runner: parallel beams, caching, watch mode, and an interactive interface
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
# Alba

Alba is a task runner. Build files are described by a **Beamfile**, and a
**beam** is the unit of work it executes: a named task with a shell command,
optional dependencies on other beams, and a few small conveniences (string
templates, parameters, environment variables) for keeping a build script
readable.

## Install

Prebuilt binaries for Linux, macOS, and Windows come with every
[release](https://github.com/albahq/alba/releases). The installer scripts
download the right one and put `alba` into Cargo's bin directory
(`~/.cargo/bin` by default), no Rust toolchain required:

```sh
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/albahq/alba/releases/latest/download/alba-installer.sh | sh
```

```powershell
powershell -ExecutionPolicy Bypass -c "irm https://github.com/albahq/alba/releases/latest/download/alba-installer.ps1 | iex"
```

Once installed that way, `alba update` fetches the latest release and
replaces the binary in place.

With a Rust toolchain, `cargo install alba` builds the same binary from
crates.io, and `cargo install --path crates/alba` builds it from a checkout;
an alba installed like this updates with `cargo install alba` again, not
with `alba update`. Alba has no other runtime dependencies.

## A Beamfile

Save this as `Beamfile` in a project's root:

```
let profile = env("PROFILE", "debug")

default check

beam build {
  description "Compile the project"
  run "echo building in {profile} mode"
}
beam test {
  needs [build]
  run "echo tests passed"
}
beam lint {
  allow_failure true
  run "false"
}
beam greet(name) {
  description "Greet someone by name"
  run "echo hello, {name}!"
}
beam check {
  description "Everything that must pass before shipping"
  needs [test, lint]
  run "echo all checks passed"
}
```

A beam's `run` command is a string template: `{profile}` interpolates the
`let` binding above, and `{name}` interpolates `greet`'s own parameter.
`needs` lists the beams that must succeed first; `allow_failure` lets a beam
fail (`lint` here) without failing the beams that depend on it. `default`
names the beam a bare `alba` runs when no subcommand is given.

### Interpolation

Anything between `{` and `}` in a string is an expression. Inside those
braces, write string literals with **single** quotes: the body is read
straight from the source, so `"{env(\"VAR\")}"` is a syntax error while
`"{env('VAR')}"` is what you want. Escapes work normally everywhere else in
the string, and `{{` / `}}` produce literal braces.

### Reading the environment

`env("NAME", "fallback")` reads an environment variable, using the fallback
when it is unset. Written without a fallback, `env("NAME")` is resolved only
when a beam actually runs, so it may appear in `run` commands and in `env`
values but not in a `let` binding, `description`, `inputs`, `outputs`, `cwd`,
or an executor option — those are resolved while the Beamfile loads, and one
unset variable there would make the whole project fail to load, including for
beams nobody asked to run. Alba rejects that at the call site rather than
letting `alba check` answer differently depending on the machine it runs on.

### Git variables

The `git` object exposes the repository holding the Beamfile:

| Expression | Value |
| --- | --- |
| `git.branch` | Current branch name; `HEAD` when detached. |
| `git.sha` | Full SHA of `HEAD`. |
| `git.short_sha` | Abbreviated SHA (`git rev-parse --short HEAD`). |
| `git.dirty` | `true` when the working tree or the index differs from `HEAD`, untracked files included. |

It is usable wherever an expression is: `let`, `run`, `description`, `env`,
`inputs`, `outputs`, `cwd`, an executor option. `branch`, `sha`, and
`short_sha` are strings; `dirty` is a boolean, so
`{if git.dirty then '-dirty' else ''}` works with the existing expression
forms.

Each field is evaluated on first access and cached for the rest of the load,
so a single `git` call answers all four: a Beamfile that never reads `git`
never runs it. Outside a git repository, in a repository with no commit yet,
or with `git` missing from the `PATH`, reading any field is a load error
pointing at the expression that accessed it.

`git` is a reserved name: `let git = ...`, and a parameter named `git`, are
both rejected at parse time.

## Usage

```sh
alba                # runs the declared `default` beam, or lists beams if none is declared
alba check          # loads and validates the Beamfile without running anything
alba run <beam>     # runs a beam and everything it needs
alba affected <ref> # lists the beams a git diff affects, runs nothing
alba update         # replaces this binary with the latest release
```

On a terminal, running any of these opens the interactive interface
described in [Interactive interface](#interactive-interface) instead of
printing lines like the ones below; what follows is what stdout carries
when it is not a terminal (redirected, piped, or under `--no-ui`).

Running the example above:

```sh
$ alba check
✓ Beamfile: 5 beams

$ alba run greet World
── greet (0.0s, ok) ──
hello, World!
✓ 1 succeeded · 0.0s

$ alba
── lint (0.0s, failed (allowed)) ──
── build (0.0s, ok) ──
building in debug mode
── test (0.0s, ok) ──
tests passed
── check (0.0s, ok) ──
all checks passed
✓ 3 succeeded · ⚠ 1 failed (allowed) · 0.0s
```

`build` and `lint` have no `needs` of their own, so they run concurrently and
race to finish first — running this yourself, don't be surprised if their
lines come out in the opposite order. The rest (`test` waiting on `build`,
`check` waiting on both) is always ordered the same way.

### Flags

Common to every subcommand:

| Flag | Description |
| --- | --- |
| `--file <PATH>` | Path to the Beamfile to load. Defaults to `./Beamfile` in the current directory when omitted. |
| `-h`, `--help` | Print help. |
| `-V`, `--version` | Print version. |

`alba run <beam> [PARAM]...` additionally accepts:

| Flag | Description |
| --- | --- |
| `[PARAM]...` | Positional arguments bound, in order, to the parameters the beam declares (`beam deploy(target)` takes one). |
| `--jobs <N>` | How many beams may run at once. At least 1; defaults to the machine's available parallelism. |
| `--keep-going` | Keep going after a beam fails, instead of cancelling the beams that have not started. |
| `--output <STYLE>` | How text output is laid out: `interleaved` (every line as it happens, prefixed with the beam it came from) or `grouped` (each beam's output held back and printed as one block when it ends). Defaults to `interleaved` on a terminal and `grouped` otherwise. Ignored with `--log-format json`. |
| `--log-format <FORMAT>` | What stdout carries: `text` (human-readable, laid out by `--output`) or `json` (one JSON object per event, one per line). Defaults to `text`. See [The JSON stream]#the-json-stream. |
| `--watch` | Keep running: re-run the beam whenever the files its subgraph declares as `inputs` change, or a loaded Beamfile changes. Ctrl-C ends the session. See [Watch mode]#watch-mode. |
| `--affected <REF>` | Run only the beams affected by what changed since `<REF>`, and their dependents. With a beam, that beam runs if it is affected and nothing runs otherwise; without one, every affected beam is a target. A beam with no `inputs` is never affected on its own. Composes with `--watch`: the targets are recomputed on every run. See [Affected runs]#affected-runs. |
| `--ui` | Ask for the interactive interface even though stdout is not a terminal. Since it cannot actually draw there, the run is refused with an error instead of falling back to headless. Has no effect otherwise: on a terminal the interface is already the default, and `--log-format json`, `--output`, or `--no-ui` still choose the text renderers over it. See [Interactive interface]#interactive-interface. |
| `--no-ui` | Force the plain text renderers on, even on a terminal. |

### The JSON stream

`--log-format json` puts one JSON object per line on stdout. Every line
carries an `event` field naming its kind, so a consumer dispatches on that
before reading anything else. An ordinary run emits six kinds:

| `event` | Fields |
| --- | --- |
| `run_started` | `targets` (the beams asked for; empty when an `--affected` run found nothing), `affected_by` (the git reference an `--affected` run targeted; omitted otherwise), `beams` (every beam in the run's subgraph, the targets included), `edges` (one `[beam, dependency]` pair per edge between them). Always the first line of the stream, before any beam's own events, so a consumer knows the shape of the run before watching it happen. |
| `beam_started` | `beam`. |
| `beam_cached` | `beam`, for a beam the cache answered instead of running. |
| `beam_output` | `beam`, `stream` (`stdout` or `stderr`), `text`, `replayed` (`true` for a line replayed from the cache rather than produced now). |
| `beam_finished` | `beam`, `status`, `exit_code` (`null` unless the beam ran and exited non-zero), `duration_ms`. |
| `run_finished` | `succeeded`, `cached`, `failed`, `failed_allowed`, `cancelled` (each a list of beam ids), `duration_ms`, and `exit_code`, what the run's beams earned. |

A watch session's stream carries three more kinds; see
[Watch mode](#watch-mode). No run summary is printed to stderr in this
format: `run_finished` already carries every count the text renderers
would have restated there.

### Exit codes

| Code | Meaning |
| --- | --- |
| `0` | Success. |
| `1` | A beam failed (and was not `allow_failure`). |
| `2` | Alba itself failed: a missing Beamfile, a parse or validation error, a bad invocation, an unschedulable run. |
| `130` | The user interrupted the run (Ctrl-C). |

`alba run --watch` reports differently, because a session outlives any single
run inside it: every run already reported its own outcome as it happened, so
there is nothing left to score at the process level once the session ends.
An orderly Ctrl-C ends the session with `0`. `2` covers whatever keeps the
session from being a session at all: a Beamfile that fails to load at
startup, a file watcher that cannot start, or one that dies partway through
and leaves nothing left to watch.

On a terminal, where the interactive interface is the front end by default
(see [Interactive interface](#interactive-interface)), `q` exits with the
last run's own code, but only when that run reached its own end without
being abandoned; quitting mid-run, or before any run has finished, reports
`130` instead, since neither one vouches for the sources. Ctrl-C cancels a
run in flight without ending the session; with nothing running, it quits
the same way, `130`, even overriding a run that had just finished green.
Whatever keeps the interface from working at all, an unschedulable run or a
file watcher that fails to start, one that dies partway through, or the
session ending some other way than the user closing it, reports `2`, the
same as a headless session's own startup failures; so does `--ui` refused
because stdout is not a terminal.

### Executors

A beam runs its command on Alba's embedded shell by default (see
[Embedded shell](#embedded-shell) below); `executor system_shell` opts out
to the host shell instead, and `executor docker { image "..." }` runs it
inside a container (see [Docker executor](#docker-executor)). Any other
name, `executor <name> { ... }`, refers to an external plugin (see
[Plugins](#plugins)).

## Caching

A beam that declares `inputs` is cached. Before running it, Alba checks
whether the files matched by `inputs`, the rendered command, the beam's
`cwd`, its `env`, its arguments, its executor (including a docker beam's
image, volumes, and workdir, or a plugin beam's options), and every
dependency's own fingerprint are all unchanged since the last successful
run. If so, the beam is skipped: it is reported as `cached` in the
output, its stored logs are replayed in its place, and the run's summary
counts it under `cached` rather than `succeeded`. Change any of those
inputs, even a single character in one matched file, and the beam runs
again.

The executor's fingerprint is only as precise as what it hashes, though:
for `executor docker`, it is the `image` field's own text, not the image
digest it currently resolves to, so repointing a mutable tag (`docker
pull alpine:3` fetching a new build of the same `alpine:3`) does *not*
invalidate the cache — the tag text is unchanged, even though what it
points at is not. The same applies to a plugin: an updated
`alba-executor-<name>` binary reachable under the same name and options
is invisible to the fingerprint. Pin an image by digest, or bump a
version somewhere the fingerprint does see (the image tag itself, a
plugin option), to force a rebuild when only the thing behind a mutable
reference changed.

A beam without declared `inputs` is never cacheable and always runs. This
is deliberate for beams whose own work is cheaper than hashing their
inputs would be, such as an umbrella beam that only reports on the beams
it needs. If a beam declares `outputs` and one of them is missing from
disk, the beam also reruns even if its inputs are otherwise unchanged: a
cache entry only stands in for work whose result is actually still there.
Both `inputs` and `outputs` patterns resolve relative to the directory of
the Beamfile that declares the beam, never to the beam's `cwd`, so a beam
with `cwd "sub"` writing `out.txt` must declare it as `outputs
["sub/out.txt"]`. An `inputs` pattern may climb out of that directory
through leading `..` components: a beam imported from `contexts/shared/`
declares `inputs ["../../Dockerfile"]` to depend on the project root's
`Dockerfile`, and the cache, affected runs, and watch mode all follow it.
A `.` or `..` anywhere past the start of an `inputs` pattern is rejected
when the Beamfile loads.

`alba run <beam> --force` ignores the cache on the way in: the beam runs
regardless of what changed. If it succeeds, its result is written back to
the cache for the next invocation to read; a forced run that fails leaves
the previous entry untouched. Use it to force a rebuild without clearing
history for every other beam.

The cache itself lives on disk under `<beamfile directory>/.alba/cache`.
`alba cache clean` removes it entirely; the next run of any beam starts
from scratch and repopulates it. Alba never edits your `.gitignore`, so
add `.alba/` to it yourself in any project that turns caching on.

## Affected runs

`alba run --affected <ref> [beam]` runs only the beams affected by what
changed since `<ref>`: every path `git diff --name-only <ref>` reports,
plus every untracked file `.gitignore` does not exclude.

A beam is affected when:

- one of its `inputs` globs matches a changed path (pattern matching against
  the path, exactly as watch mode does, so a deleted file still counts), or
  the Beamfile that declares it changed;
- it transitively `needs` a beam that is affected: the set is closed over
  dependents, so an aggregating beam becomes affected the moment anything it
  needs does;
- it declares no `inputs` at all: such a beam is never affected by the first
  rule alone, only by needing one that is.

With a beam, the targets are the affected beams inside that beam's subgraph;
when the beam itself is not affected, nothing in its subgraph is either
(closure over dependents cuts both ways), and the run is empty. Without one,
every affected beam in the project is a target, except a beam that declares
parameters, which is skipped, since nothing can bind its arguments; the
others still run. Either way, an empty run prints `✓ nothing affected by
<ref>` and exits `0` rather than reporting an ordinary summary with every
bucket at zero.

`alba affected <ref>` lists the affected beams without running anything, one
per line, or `{"beams": [...]}` under `--log-format json`. It exits `0` even
when the list is empty: the dry run of `--affected`, meant for CI. A
parameterized beam is listed too, marked `deploy (takes parameters)`, since
nothing here needs to bind its arguments.

`--affected <ref> --watch` composes: the reference stays fixed while the
working tree moves. The watched set is the beam's whole subgraph, not only
what is affected at startup, so touching a file of a not-yet-affected beam
still triggers a run; every run, initial or triggered, recomputes the
targets against `<ref>`, so reverting a change drops its beam back out.

Affected selection is driven through the `git` CLI: `git` must be on the
`PATH` for `--affected` and `alba affected`.

## Watch mode

`alba run --watch [beam]` runs the beam, then keeps the session open and
re-runs it whenever a relevant file changes. Like a plain `alba run`, an
omitted beam falls back to the declared `default`, so a bare
`alba run --watch` works too.

On a terminal, this session starts inside the interactive interface (see
[Interactive interface](#interactive-interface)) rather than printing the
text this section describes: the interface already lets `w` turn watching
on or off mid-session, so `--watch` there only chooses the state it starts
in. Everything below still describes the session itself: what counts as a
change, what a broken Beamfile does to it, how `--force` and debouncing
behave. It holds regardless of which front end is showing it; only the
paragraphs about clearing the screen and the JSON stream are specific to
the plain text renderers.

What counts as relevant is exactly what the cache already tracks: the
files the target's subgraph declares as `inputs`, the same declarations
the cache fingerprints, so watch mode and the cache can never disagree
about what a change is. Every Beamfile the project loaded is
watched as well, so editing the Beamfile itself also triggers a run. A file
that `.gitignore` excludes never triggers one, for the same reason it never
enters the cache's fingerprint, and `.alba/` and `.git/` are excluded
outright: the cache writing its own state must never wake the session that
owns it. If no beam in the target's subgraph declares any `inputs`, Alba
prints a warning once at startup, since such a session can still only react
to a Beamfile edit.

A change that lands while a run is still in progress cancels that run and
starts a new one right away rather than waiting for it to finish. Every
change that lands during a run is folded into the next one, so a save the
cancellation raced with is never dropped.

Editing a loaded Beamfile reloads the project before the next run, so the
session always schedules the beams as they are currently written. If the
edit leaves the Beamfile unparsable, Alba renders the same diagnostic
`alba check` would report for it, runs nothing, and sits idle until a later
save produces a Beamfile that parses again, at which point the session
resumes on its own. While it sits there, the diagnostic is printed once
rather than once per file change: it is reprinted only when a save actually
changes the answer.

`--force` only applies to the run the session starts with. Every run the
watcher triggers afterward reads the cache normally; forcing those too
would mean rerunning the whole subgraph on every keystroke, which defeats
the point of having a cache at all.

Changes are debounced for a built-in 200 ms before they trigger a run
(not a flag), so a save that touches several files at once, or an editor
that writes a file more than once, produces a single run rather than
several.

Ctrl-C ends the session in an orderly way: every run inside it already
reported its own outcome, so the process exits `0` rather than the `130`
a single interrupted `alba run` reports. See [Exit codes](#exit-codes).

When the plain text renderers are the ones showing the session (off a
terminal, or forced on one by `--no-ui` or another flag that falls back to
them), they clear the screen before printing a triggered run whenever
stdout is a terminal, so each run starts on a clean page. This never
happens with `--log-format json`, and never happens when stdout is not a
terminal (a log file, a pipe): there is no screen to clear for a reader
parsing the stream, and off a terminal the output accumulates as a record
that clearing would destroy.

Piped through `--log-format json`, a session's stream carries three event
kinds beyond [an ordinary run's](#the-json-stream): `project_broken` (`diagnostic`, the same
rendered text also printed to stderr) whenever the project fails to load,
or a run cannot be scheduled once it has, `watch_waiting` (`files`, how many
resolved input files the session is watching) while it sits idle between
runs, and `watch_triggered` (`paths`, the changed paths that caused the next
run) the moment one starts. `paths` are relative to the project root, and
the array is empty when the trigger cannot be pinned to specific files,
such as a Beamfile that was broken and has just started parsing again. A
session parked on a project that will not load reports `project_broken`
once, then `watch_waiting` with `files` at `0` for as long as it stays
parked: it is stopped, not running, and nothing is resolved while nothing
loads.

Two things are worth knowing before leaning on watch mode. A beam that
writes to a git-tracked file matched by its own `inputs` triggers itself on
every run and loops forever, so keep generated output git-ignored. And
watch roots are computed once at startup, from the project root, the
directory of any Beamfile loaded from outside it, and the directory any
input climbing above the root matches under: an import added mid-session
whose directory lies outside those roots, or such an input directory
created mid-session, is not watched until the session is restarted.

## Interactive interface

On a terminal, `alba run` opens an interactive interface instead of
printing text: a live view of the run that takes over the whole screen for
as long as it lasts, and hands the terminal back exactly as it found it
once you quit (`q`).

Which front end a given invocation gets depends on stdout, not on typing
anything extra:

- It is the default whenever stdout is a terminal, unless `--log-format
  json`, an explicit `--output`, or `--no-ui` chooses the plain text
  renderers instead; all three win even together with `--ui`, since each
  already asks for a specific, script-readable shape of output the
  interface has nothing to add to.
- Off a terminal (piped, redirected, or under a test harness), with none
  of those three set, the text renderers run by default too, with no flag
  needed.
- `--ui` changes only that last case: instead of falling back quietly, it
  refuses with an error, since the interface still needs a real terminal
  to draw on, and drawing it down a pipe would just fill the reader's
  stream with escape codes.

The interface subsumes [watch mode](#watch-mode): `--watch` only chooses
the state the session starts in, and `w` toggles watching at any point
afterward. A Beamfile edit still reloads the project, a broken one still
parks the session until a later save fixes it, and a change still cancels
a run in flight and starts a fresh one, exactly as that section describes.

### Colour

- The theme: `✔` green, `⚡` cyan, `▶` yellow, `✖` red, `○` dimmed; the
  progress bar green while running; the outcome green or red; the header
  yellow while parked; keys in the bottom bar bold.
- `NO_COLOR` (or stdout not being a terminal) turns all of it off and the
  interface renders monochrome.
- Commands run under the interface receive `FORCE_COLOR=1` and
  `CLICOLOR_FORCE=1` (a beam's own `env` wins on those names), so `cargo`,
  `eslint`, and friends print the colours they would in a terminal, which
  the log pane renders. Both variables are inherited by anything the
  beam's command spawns in turn, not only that command itself, so a
  script that shells out further still gets colour several processes
  down. The cache does not see those two variables, so a beam hits the
  same entry whether the interface or a pipe ran it; that is right for
  rebuild avoidance, but it also means a beam whose output depends on
  colour (a build log written to a file, a generated report) can be
  produced under the interface and then served from cache to a headless
  run, or the other way around, with the fingerprint never noticing. A
  beam that redirects tool output into a file or a parser should pin
  `env { FORCE_COLOR = "0" }` in its own `env`, which wins. The headless
  renderers set nothing and print the command's bytes as they are.
- stderr lines that carry no colour of their own are dimmed; ones that do
  keep it.
- The exit replay of failed beams keeps their colours when the interface
  had colour, and prints plain text otherwise.

### Layout

```text
┌─ alba · build ──────────────────────────────────────────── watching 3 files ─┐
│BEAMS                         │LOGS                               ● following │
│ ✔ codegen                1.2s│Compiling proc-macro2 v1.0.86                  │
│ ⚡ api:codegen           0.8s│Compiling serde v1.0.210                       │
│ ▶ api:build             3.4s…│Compiling api v0.1.0 (/repo/api)               │
│ ○ build                      │warning: unused import: `std::fmt`             │
│ ○ test                       │  --> src/lib.rs:4:5                           │
│                              │                                               │
├──────────────────────────────┴───────────────────────────────────────────────┤
│ ✔ 1  ⚡ 1  ✖ 0  ○ 2                ▰▰▰▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱ 2/5 · 4.2s │
└─ q quit · r rerun · c cancel · w watch · / search · ? help ──────────────────┘
```

The header and the bottom bar are not panes of their own: they sit inside
the top and bottom edge of a single outer frame. Inside it, one vertical
divider separates the tree from the log pane, and a junction line closes
both columns above the footer.

- **Header**: `alba · {target}` on the left; on the right, between runs in
  a watch session, how many files it is watching, or `parked` when the
  project cannot load.
- **Tree** (left, 30 columns): one row per beam, `✔` succeeded, `⚡`
  cached, `▶` running (its own duration ticking), `✖` failed (an allowed
  failure included), `○` pending or cancelled. A name longer than its
  column is cut with a trailing `…`.
- **Logs** (right): the selected beam's output, following the tail by
  default; the title row shows `● following` or `↑ paused`. Scrolling up
  (the wheel, or the keys below) pauses following, so you can read in
  peace while the run continues; `G`, or scrolling back down to the
  bottom, resumes it. Long lines wrap by character; scrolling moves by
  whole lines. When a beam fails and you have not moved the selection
  since the run started, the selection jumps to it (the first failure
  only).
- **Footer**: the run. Left, the beams counted by status (`▶` left out of
  the tally, since it has not settled yet). Right, while a run is going,
  its progress bar, done/total, and elapsed time, ticking live; once it
  ends, `ok` or `failed` and the duration. The bar grows with the
  terminal, up to 30 cells, and gives way to the count alone when fewer
  than 14 cells are left for it.
- **Bottom bar**: the actions available in whatever mode is active, the
  keymap below condensed to what fits.

Below roughly 40 columns by 12 rows, the interface shows "terminal too
small" rather than a layout with nothing left to draw.

### Keymap

`Ctrl-C` behaves the same in every mode: it cancels a run in flight, or
quits if nothing is running.

**Normal**

| Key | Action |
| --- | --- |
| `q` | Quit. |
| `Ctrl-C` | Cancel the run in flight, or quit if idle. |
| `r` | Rerun the selected beam. |
| `f` | Rerun the selected beam, bypassing the cache. |
| `c` | Cancel the run in flight. |
| `w` | Toggle watch on or off. |
| `j`/`k`, `↓`/`↑` | Move the selection. Moving the selection by hand also stops a failure from moving it for the rest of the run. |
| `t` | Run the target the session was started for. `r` and `f` retarget the session onto the beam they rerun, so this is the way back to the whole graph. |
| `PgUp`/`PgDn`, `Ctrl-u`/`Ctrl-d` | Scroll the log pane by half its height. |
| `G` | Jump the log pane to the tail and resume following. |
| Mouse wheel | Scroll the log pane. |
| `n` / `N` | Step the committed search forward or backward, wrapping; re-runs the query against the newly selected beam if the selection moved since it was committed. |
| `/` | Enter search. |
| `v` | Enter copy. |
| `g` | Enter graph. |
| `?` | Open help. |

The bottom bar shows `q`, `r`, `c`, `w`, `/`, and `?`; the rest of this
table lives in the help overlay.

**Search** (`/`)

| Key | Action |
| --- | --- |
| Any printable character | Add to the query (composing only, `n`/`N` included; stepping is the Normal-mode binding above). |
| `Backspace` | Erase the last character. |
| `Enter` | Commit the query, return to Normal, and keep the highlights. Committing is what gives `n`/`N` something to step. |
| `Esc` | Cancel and return to Normal: nothing is committed, the highlights go away, `n`/`N` have nothing to step, and the log pane resumes following the tail. |

**Copy** (`v`)

| Key | Action |
| --- | --- |
| `hjkl` / arrows | Move the cursor. |
| `v` | Re-anchor the selection at the cursor. |
| `y` | Copy the selection (OSC 52, falling back to the system clipboard when the terminal does not support it) and return to Normal; the bottom bar confirms for about two seconds. |
| `Esc` | Leave without copying. |
| Click and drag, then release | Select by dragging in the log pane; releasing copies. |

Copy is entered with `v`; a click-drag alone, without `v` first, does
nothing. Once inside, the selection addresses only the log pane's own
text; the tree beside it is never part of it.

**Graph** (`g`)

| Key | Action |
| --- | --- |
| `↑↓←→` | Move focus between nodes. |
| `Enter` | Select the focused beam and return to Normal. |
| `Esc` | Return to Normal without changing the selection. |

Graph mode draws the run's dependency graph in topological layers, colored
by status, and replaces the whole body while it is open: there is no tree
or log pane behind it.

**Help** (`?`)

| Key | Action |
| --- | --- |
| `?` or `Esc` | Close the overlay. |

Help draws the same keymap as this section, over the tree and log panes
dimmed rather than hidden.

What exit code the process reports once you quit is not a keymap
question; see [Exit codes](#exit-codes).

## Embedded shell

A beam's `run` command executes on Alba's own embedded shell by default: a
home-grown, cross-platform, POSIX-like interpreter, not `sh` or
PowerShell. The point is that a single Beamfile behaves identically on
macOS, Linux, and Windows, instead of quietly depending on whichever shell
happens to be installed on the machine that runs it.

The supported syntax covers what most beam commands actually need:
sequencing (`cmd1 ; cmd2`, `cmd1 && cmd2`, `cmd1 || cmd2`, negation
`! cmd`), pipelines (`cmd1 | cmd2`), redirections (`>`, `>>`, `<`, `2>`,
`2>>`, `2>&1`), POSIX quoting (single quotes are literal, double quotes
allow expansions, backslash escapes a single character), variables
(`$VAR`, `${VAR}`, and `$?` for the exit code of the last completed
command; assignment with `FOO=bar`, which persists for the rest of that
`run` string, an environment prefix like `FOO=bar cmd`, which applies to
that one command only, and the `export`/`unset` builtins), command
substitution
(`$(...)`, which shares the shell's state rather than running in an
isolated subshell, so `$(cd sub)` really does leave the shell in `sub`),
tilde expansion (`~` at the start of a word), and globbing (`*`, `?`,
`[...]`).

Sixteen builtins ship with the shell: the pure shell builtins `cd`, `pwd`,
`exit`, `true`, `false`, `export`, and `unset`; `echo`; the file builtins
`cat`, `cp`, `mv`, `rm`, `mkdir`, and `touch`; and the utilities `sleep`
and `test` (also spelled `[`). A builtin always wins over a PATH binary of
the same name, so `rm -rf dist` behaves the same on every platform even
where a system `rm` also exists; an explicit path such as `/bin/rm`
bypasses the builtin and reaches the system binary directly.

A few behaviors are deliberately frozen rather than left
implementation-defined: an unset variable expands to the empty string
(there is no `set -u`); a pipeline's exit code is always its last
command's (there is no `pipefail`); a glob that matches nothing is left
literal instead of disappearing or erroring; a wildcard never matches a
leading dot, so `*` skips hidden entries and a hidden entry is reached
only by writing its dot out (`.env`, `.h*`); `echo` recognizes only the
`-n` flag and never interprets backslash escape sequences; and a newline
inside a `run` string behaves exactly like `;`.

Shell control flow (`if`, `for`, `while`, `case`), functions, heredocs,
background jobs (`&`, `wait`), subshells (`(...)`), advanced expansions
such as `${VAR:-default}`, arithmetic `$((...))`, or brace expansion
`{a,b}`, and every special parameter but `$?` (`$$`, `$!`, `$#`, `$*`,
`$@`, `$1` to `$9`) are out of subset. None of them fail silently or fall
back to a system shell: each is a parse error with a span pointing at the
offending construct and a suggestion. For example:

```
$ alba check
beam `deploy`: invalid embedded shell command
error: `for` loops are not supported by the embedded shell
  │ for f in *.rs; do echo $f; done
  │ ^^^
  = help: move the logic into a script invoked by `run`, or declare `executor system_shell` on this beam
```

A beam whose command needs more than this subset can opt out with
`executor system_shell`, which runs its command through the host shell
(`sh` on Unix, PowerShell on Windows) instead, exactly as Alba did before
the embedded shell existed.

`alba check` parses the command of every beam that uses the embedded
shell and takes no parameters, catching unsupported syntax before a beam
ever runs rather than partway through a build. Parameterized beams (their
`run` template cannot be rendered until their arguments arrive),
`executor system_shell` beams, and `executor docker` beams are not
statically checked this way.

### Upgrading a Beamfile written for the host shell

The embedded shell is the default for every beam that does not say
otherwise, so a Beamfile written when `run` meant `sh -c` may need a
look. The breaks to expect, in rough order of how often they bite:

- **Control flow and functions.** `if`, `for`, `while`, `case`, and
  function definitions are out of subset. Move the logic into a script
  the beam invokes, or declare `executor system_shell` on that beam.
- **Special parameters.** Everything but `$?` is rejected: `$$`, `$!`,
  `$#`, `$*`, `$@`, and `$1` to `$9`. A command that built a temporary
  name out of `$$` needs another way to spell it.
- **Builtin flags.** The sixteen builtins have deliberately narrow flag
  surfaces (`rm -r -f`, `cp -r`, `mkdir -p`, `echo -n`, and nothing
  else), and a builtin always wins over the PATH binary of the same
  name. A flag outside that surface is a usage error rather than a
  silently different behavior. Reach the system binary with an explicit
  path (`/bin/rm`) when you really need one of its own options.
- **Hidden files.** `*` never matches an entry whose name starts with a
  dot, exactly as `sh` behaves. A beam that ran under PowerShell on
  Windows and counted on different wildcard rules has to name those
  entries.
- **Other out-of-subset syntax.** Heredocs, background jobs (`&`,
  `wait`), subshells (`(...)`), `${VAR:-default}`, arithmetic
  `$((...))`, and brace expansion `{a,b}`.

`alba check` catches the grammar half of this list statically, before
anything runs: unsupported syntax and rejected parameters are parse
errors. It cannot catch the runtime half, because an unsupported builtin
flag is a perfectly valid parse. `rm --one-file-system dist` parses
cleanly and fails only when the beam runs, so run the beam once to find
those.

Whenever the rewrite is not worth it, `executor system_shell` on that one
beam restores exactly the previous behavior, and the rest of the Beamfile
keeps the cross-platform guarantee.

## Git hooks

A Beamfile declares git hooks and points each one at the beam that runs it:

```
hook pre-commit { beam check }
hook commit-msg { beam check_message }
```

`<name>` must be one of git's own hook names (`pre-commit`, `commit-msg`,
`pre-push`, `post-checkout`, and so on); an unknown one is a load error.
`beam` is required and names a local or namespaced beam; an unknown beam is
a load error, reported the same way an unknown `needs` is. Git passes each
hook a fixed number of arguments (`pre-commit` none, `commit-msg` one,
`pre-push` two, and so on); the target beam may declare at most that many
parameters: more is a load error, fewer is fine, git's extra arguments are
dropped. The same hook cannot be declared twice, and only the root
Beamfile's `hook` declarations count: an imported Beamfile's are ignored,
exactly like its `default`.

A hook that needs several beams points at an aggregating one, which is
exactly what running it by hand looks like:

```
beam check_message(path) {
  needs [fmt, lint]
  run "commitlint --edit {path}"
}
```

### Installing hooks

`alba hooks install` writes one identical script under `.alba/hooks/` for
every hook git knows, declared or not, and points the repository's
`core.hooksPath` at that directory. Writing a script for every hook rather
than only the declared ones is what lets a `hook` declaration added later
take effect without reinstalling: only the first install per clone matters.
Running it again rewrites the scripts without error. If `core.hooksPath`
already points somewhere else, it refuses instead of overwriting it:

```sh
$ alba hooks install
core.hooksPath already points to `.husky`, remove it or uninstall that tool first
```

`.alba/` should be in `.gitignore`; Alba never edits it for you.

`alba hooks uninstall` removes the scripts and unsets `core.hooksPath`, but
only when Alba is the one that set it, otherwise it refuses the same way.

### Running a hook

`alba hook <name> [args]` is what the installed scripts call, and doubles as
a way to run a hook by hand while debugging it. With no Beamfile, or no
`hook <name>` declared, it does nothing and exits `0`: this is what makes
installing scripts for every hook harmless. A Beamfile that fails to load
prints the diagnostic and exits `2` instead, blocking the git operation
rather than letting it through silently. Otherwise it is the strict
equivalent of `alba run <beam> [args]` with the text renderers and grouped
output: git's own arguments become the target beam's positional parameters,
truncated to what it declares, and the exit code is the run's, so git blocks
the operation on any non-zero exit, not only `1`.

`alba check` counts declared hooks in its summary and warns when they are
not yet installed:

```sh
$ alba check
⚠ 2 hooks declared, run 'alba hooks install'
✓ Beamfile: 5 beams, 2 hooks

$ alba hooks install
✓ hooks installed: core.hooksPath = .alba/hooks

$ alba check
✓ Beamfile: 5 beams, 2 hooks
```

`pre-push` receives the list of refs being pushed on standard input; Alba
does not forward it to the hook beam, so a hook that needs that list has to
read it another way.

## Docker executor

`executor docker { image "..." }` runs a beam's commands inside a
container instead of on the host, via the `docker` CLI:

```
beam ship {
  executor docker {
    image "alpine:3"
    volumes ["/host/cache:/cache"]
    workdir "/srv"
  }
  run "./deploy.sh"
}
```

`image` is required; `volumes` (a list of `host:container` entries) and
`workdir` (an absolute path inside the container) are optional. Alba
starts one container per beam and keeps it alive, dormant, for every
command that beam's `run` list runs, so state a command leaves behind
(files written, anything a previous command set up) is visible to the
next one in the same beam; the container is removed once the beam ends.
The image must provide `/bin/sh`, since every command runs through
`docker exec ... sh -c "<command>"`.

The project directory is always bind-mounted into the container. On
unix it is mounted at its own host path, so a beam's working directory
needs no translation; on windows it is mounted at `/workspace` instead,
with the working directory rewritten to the matching path under
`/workspace`. A declared `workdir` always overrides that computed path.

`volumes` are bind-mounted the same way, in addition to the project
directory, but each entry is validated when the Beamfile loads, before
any container exists: it must split on its last colon into a non-empty
host part and a container part starting with `/` (a windows host path
such as `C:\cache:/cache` keeps its own drive colon and still splits
correctly, since only the last colon counts). A docker-style suffix
appended after the container path, such as `:ro` for a read-only mount,
does not fit that shape and is rejected at load time rather than reaching
`docker` at all.

Running a docker beam requires `docker` on the `PATH`; it reaches
whichever daemon that `docker` CLI is itself configured to talk to
(Docker Desktop, a remote context, an API-compatible lookalike).

Every container Alba starts carries an `alba.beam=<name>` label. Alba
itself removes the container once the beam ends, in every outcome
(success, failure, cancellation); the label exists for the one case that
is not one of those — an Alba process that is killed outright (`kill -9`,
a crash) has no chance to run that removal. Find and clean up anything
left behind that way with:

```sh
docker rm -f $(docker ps -aq --filter label=alba.beam)
```

## Plugins

`executor <name> { ... }` for any `name` that is not `shell`,
`system_shell`, or `docker` refers to an external plugin: a separate
`alba-executor-<name>` binary on the `PATH` that Alba spawns and speaks
a line-oriented JSON protocol to, one process per beam, kept alive for
every command in that beam the same way the docker executor keeps its
container alive.

Alba resolves every plugin a run needs before any beam starts: a beam
whose plugin cannot be found on the `PATH` fails the whole run
immediately, with a suggestion when the name looks like a typo of a
built-in executor:

```sh
$ alba run ghost
beam `ghost` uses executor `nosuchthing`: `nosuchthing` is neither a
built-in executor nor `alba-executor-nosuchthing` on the PATH
```

The full wire protocol (every message, its exact JSON shape, timeouts, and
cancellation) is specified in
[`docs/plugin-protocol.md`](docs/plugin-protocol.md). `crates/alba-executor-example`
is a complete reference implementation to read alongside it.

`alba plugin check <BINARY>` drives a plugin binary through that whole
protocol and reports whether it conforms, without needing a Beamfile:

```sh
$ alba plugin check target/debug/alba-executor-example
✓ handshake
✓ execute: exit code 0, 1 output line
✓ cancel (answered in 2.165583ms)
✓ close
conformant: protocol v1
```

## Claude Code plugin

Alba ships a [Claude Code](https://claude.ai/code) plugin, unrelated to the
executor plugins above, so the assistant understands Alba and can read,
write, and run `Beamfile`s in your project. Run both commands inside Claude
Code:

```text
/plugin marketplace add albahq/alba
/plugin install alba
```

The first registers this repository's marketplace
(`.claude-plugin/marketplace.json`) as a source; the second installs the
`alba` plugin from it. See
[`claude-code-plugin/README.md`](claude-code-plugin/README.md) for what it
provides.

## Contributing

See [CONTRIBUTING.md](CONTRIBUTING.md) for the setup, the checks every
change must pass, and the commit conventions.

## License

[MIT](LICENSE)