viz-shell 0.1.0

One shell for every repo
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
<p align="center">
  <img src="assets/vz-logo.png" alt="vz" width="420">
</p>

<h3 align="center">One shell for every repo.</h3>

<p align="center">
  Versatile dev environments, everywhere, exactly as you want them.<br>
  As yourself, in any image, secure by default, and made for coding agents.
</p>

<p align="center">
  <a href="https://github.com/jan-blomquist/viz-shell/actions/workflows/gate.yml"><img alt="gate" src="https://github.com/jan-blomquist/viz-shell/actions/workflows/gate.yml/badge.svg?branch=master"></a>
  <a href="#license"><img alt="License: MIT or Apache-2.0" src="https://img.shields.io/badge/license-MIT%20or%20Apache--2.0-blue"></a>
  <img alt="Rust 1.98+" src="https://img.shields.io/badge/rust-1.98%2B-orange">
  <img alt="Linux" src="https://img.shields.io/badge/platform-linux-lightgrey">
</p>

<p align="center">
  <a href="#quick-start">Quick start</a> ·
  <a href="#why-vz">Why vz</a> ·
  <a href="#guide">Guide</a> ·
  <a href="#examples">Examples</a>
</p>

---

```console
$ whoami
sally
$ vz new
sally@vz-0-app:~/repos/app$ whoami
sally
```

Describe a repository's environment in a few lines of YAML. `vz` starts it in a container and drops
you in: as yourself, in your repository, with only what you chose to share. On a laptop, a server or
a CI runner, for you and for the agents working beside you.

> **Status:** young and moving fast. It works day to day, but the configuration may still change
> before 1.0. Feedback and issues are welcome.

## Why vz

- **Your repo, mounted.** `cd` into any git repository and type `vz new`. The repository is there,
  read-write, at the same path as on your host.
- **You, inside.** Same user, uid, groups, home and paths. Files you create stay yours; git and ssh
  just work; paths in errors match your editor's.
- **Any image.** Official images work unchanged, or `vz` builds the repository's own Dockerfile.
  Switch branches, switch environments; nothing rebuilds needlessly.
- **Configurations.** Yours in a library, the project's in the repository, chained by `extends`:
  `vz new -c trusted` for sudo and docker, `ci` for the pipeline.
- **Secure by default, made for agents.** No Linux capabilities, no sudo, no docker, no host network,
  no secrets, unless you grant them. Let an agent loose: of your machine, it reaches the repository
  and nothing else.
- **State that stays.** Caches, shell history and agent sessions survive the container, per
  repository, and never clutter your home.
- **Secrets out of sight.** Env files are read on the host and pass by name: never on a command line,
  never in a log.
- **Sessions.** Name a container, keep it, attach from any terminal: `vz new api`, `vz at api`.
- **One static binary.** No daemon, no runtime, no editor plugin. Just Docker.

## Quick start

From the latest release: the static binary and its alias `vz`, into `~/.local/bin`:

```sh
url=https://github.com/jan-blomquist/viz-shell/releases/latest/download/viz-shell-x86_64-unknown-linux-musl.tar.gz
cd "$(mktemp -d)" && curl -fsSLO "$url" && curl -fsSLO "$url.sha256"
sha256sum -c viz-shell-x86_64-unknown-linux-musl.tar.gz.sha256
mkdir -p ~/.local/bin && tar -xzf viz-shell-x86_64-unknown-linux-musl.tar.gz -C ~/.local/bin viz-shell vz
```

Or from crates.io, as the static musl build `vz` needs, since it mounts itself into every container:

```sh
rustup target add x86_64-unknown-linux-musl
cargo install viz-shell --locked --target x86_64-unknown-linux-musl
ln -sfn viz-shell ~/.cargo/bin/vz
```

Or from a checkout (needs [Rust](https://rustup.rs) and [just](https://just.systems)):

```sh
git clone https://github.com/jan-blomquist/viz-shell && cd viz-shell
just build && just install         # ~/.local/bin/viz-shell, and its alias vz
```

This repository is the worked example: three files.

```yaml
# vz.yml: the toolchain to build viz-shell, on the library's Debian
name: default
extends: vz-debian-trixie              # the library's base: sudo, the docker CLI, locales
image: { dockerfile: Dockerfile }      # ARG BASE: built on vz-debian-trixie's image
state:
  - /usr/local/cargo/registry          # survives the container, kept in .vz_state/
env:
  defaults: { RUST_LOG: info, CARGO_TERM_COLOR: always }
  files: [.env]                        # read on the host, never mounted
```

```yaml
# dev.vz.yml: the developer shell, on the toolchain
name: dev
extends: default
image: { dockerfile: dev.Dockerfile }  # fish, Node, opencode, Codex, Claude Code
shell: fish
state: [~/.config/fish, ~/.local/share/fish, ~/.codex, ~/.claude]
env:
  passthrough: [CLAUDE_CODE_OAUTH_TOKEN]
```

```sh
vz new            # the toolchain
vz new -c dev     # the developer shell
```

The first run writes your library, `~/.config/viz-shell/`: one configuration, `vz-debian-trixie`, and
the Dockerfile it builds. Your own additions are one more file, not checked in:

```yaml
# sally.vz.yml
name: sally
extends: dev
mounts:
  - ~/repos/notes:rw
env:
  passthrough: [GH_TOKEN]
```

## How vz differs

- **Not an editor plugin.** Dev Containers center on the editor; vz is shell-first. Use it with any
  editor, or none, over ssh, on a server, in CI.
- **Not a package manager.** Nix and Devbox assemble packages; vz runs whatever image you give it,
  and builds your Dockerfile when you give it one.
- **Not host integration.** Distrobox and Toolbox deliberately share your home and your host; vz
  isolates by default and shares only what the configuration declares.

## Guide

- [Use](#use)
- [Configurations](#configurations)
- [Sessions](#sessions)
- [Images](#images)
- [Your user in the image](#your-user-in-the-image)
- [State](#state)
- [Mounts](#mounts)
- [Share](#share)
- [Privileges](#privileges)
- [Environment](#environment)
- [Hooks](#hooks)
- [Examples](#examples)
- [Logging](#logging)
- [Development](#development)
- [License](#license)

## Use

```sh
vz new              # a shell: `shell`, else bash, else sh
vz -- cargo test    # one command instead
vz new -c ci        # the configuration ci, after what it extends; or VZ_CONFIG=ci
vz -f ci.yml        # also read this file, any YAML, as one of the repository's; repeatable
vz --show-effective-config   # the chain, its images, the result as YAML; runs nothing
vz configs          # the configurations of the repository and the library
vz new api          # a container named api; see Sessions
vz attach 0         # a shell in container 0 of this repository; or `vz at api`
vz ls               # this repository's containers; --all for every repository's
vz kill 0 api       # removes containers; --all for all of this repository's
```

`vz` alone lists the commands. `vz` (the alias of `viz-shell`) reads the [configurations]#configurations
of the repository and your library, and runs the chain of the one asked for, `default` without `-c`. It pulls or
builds the image if missing, and runs a container, removed on exit unless `persistent`, with its
[hooks]#hooks: `create` once, `attach` before every shell or command. `vz` exits with the shell's,
or the command's, exit code. Inside:

- the repository is mounted read-write at its host path; the working directory is yours;
- you are you: same user, uid, group and home, so `whoami`, `~` and ssh work as on the host;
- `TERM`, `COLORTERM`, `LANG` and `VZ_LOG` are copied in when set. A `TERM` the image has no
  description for, as slim images lack those of newer terminals like ghostty, kitty or wezterm,
  becomes `xterm-256color`, so tmux, less and htop still work;
- `VZ_CONTAINER` names the container, `VZ_CONTAINER_CONFIG` its configuration, when not `default`, and `VZ_REPO`
  the repository root: for prompts, scripts and agents that want to know where they run.

`banner:` prints a banner above an interactive shell (never above `vz -- command`), in the
manner of fastfetch: what the shell is about to be. `true` shows the built-in art, `false` no banner,
and a string, art of your own above the facts; an empty one, the facts alone. Write it as a `|2`
block when its first line starts with spaces, so YAML knows the indentation. Colored on a terminal,
unless `NO_COLOR` is set.
The environment shows as a count, never names or values.

```
       _              _          _ _
__   _(_)____     ___| |__   ___| | |
\ \ / / |_  /____/ __| '_ \ / _ \ | |
 \ V /| |/ /_____\__ \ | | |  __/ | |
  \_/ |_/___|    |___/_| |_|\___|_|_|

sally@vz-0-app
--------------
Version: 0.1.0
Session: new, ephemeral
Repo: ~/repos/app
Branch: main
Chain: vz-debian-trixie (library) → default → dev → sally
Config: sally
Image: vz-app:3f9c2a1b7d4e8f60 (on vz-viz-shell:9a1c0d2e5b7f3a41)
Shell: fish
Sudo: yes
Docker: /run/user/1000/docker.sock
Network: host
Mounts: 4 (2 vz-debian-trixie.vz.yml, 1 vz.yml, 1 sally.vz.yml)
State: 2 paths in ~/repos/app/.vz_state
Env: 3 variables
Hooks: 2 create, 1 attach
```

The library's `vz-debian-trixie` writes the built-in art out in full, so it is there to edit; a later
configuration replaces it, or turns it off with `banner: false`.

`shell` picks the interactive shell: a name on the image's `PATH`, or an absolute path. It is also
`$SHELL` and your login shell inside. An image without it gives a warning, then bash, else sh, so a
library `shell: fish` doesn't break images without fish. Keep fish's configuration and history per
repository, apart from the host's, as state:

```yaml
shell: fish
state:
  - ~/.config/fish
  - ~/.local/share/fish
```

Unknown keys in a configuration are refused, naming the line.

## Configurations

A configuration is one YAML document: an image, state, mounts, env, hooks, and what the shell may
share and do. `vz new` runs `default`; `vz new -c NAME` runs `NAME`, after the configurations it
extends. Two places hold them: the **repository**, at the git root, and the **library**,
`~/.config/viz-shell/` (or under `$XDG_CONFIG_HOME`), for configurations a repository extends by name.

1. **Files.** `vz.yml`, `*.vz.yml` and `*.vz.yaml`, in the repository root, the library, and the
   folders the library's files list under `scan:`; nothing else is read, and a filename means nothing
   more. `-f FILE` reads another file, any YAML, as one of the repository's. `scan:` belongs in a file
   of the library folder, and is refused anywhere else.
2. **Documents.** A file holds one or more documents, separated by `---`; each is a configuration.
3. **Names.** `name:` is required, but for one configuration in each scope (the repository, the
   library with its `scan:` folders): the one without a name is `default`. Two without, or two of one
   name, in one scope are refused, naming both files. A configuration named `default` is an ordinary
   one.
4. **Extends.** One name, explicit; nothing is implied. It resolves to the repository's configuration,
   else the library's, never to itself: a repository's `trusted` with `extends: trusted` extends the
   library's. An unknown name is refused, listing the folders scanned and the names found; a cycle,
   naming it.
5. **Chain.** The parent's chain, then the configuration; later wins: settings (`image`, `state_dir`,
   `banner`, `shell`, `persistent`, `attach`) are replaced, `share` and `privileges` per key,
   `env.defaults` per variable name; in every list, an entry with an earlier one's key (a path; a name
   for passthrough; the command for hooks) updates it in its place, a new one comes last, and
   `enabled: false` removes one. A key twice in one list is refused. Paths are made absolute as each
   file is read, relative to its own folder, so an entry is removed however either file writes it.
6. **Images.** `image:` follows the chain: a Dockerfile declaring `ARG BASE` builds on the image
   before it; one without it, or an image reference, replaces ([Images](#images)). `ARG BASE` without
   a default requires an image before it, and is refused without one; `docker build .` then needs
   `--build-arg BASE=…`; `# check=skip=InvalidDefaultArgInFrom` under `# syntax=` silences BuildKit's
   lint about the missing default. An image holds only what its chain extends.
7. **Default.** Without `-c`: the repository's `default`. The library holds none: without one, `vz new`
   says ``no default configuration: add vz.yml with `extends: vz-debian-trixie`, or run with -c NAME``.

A repository writes the configurations it needs, `trusted` too:

```yaml
# vz.yml
name: default
extends: vz-debian-trixie
image: { dockerfile: Dockerfile }
mounts:
  - ~/repos                         # read-only
---
name: trusted
extends: default                    # default's lineage, then this
privileges: { sudo: true }
share: { docker: true, host_network: true }
```

A configuration of your own, beside the repository's: whether it is checked in is yours.

```yaml
# sally.vz.yml
name: sally
extends: default
image: { dockerfile: sally.Dockerfile }   # stacks on default's image
mounts:
  - ~/repos/shared-lib:rw                 # default mounts ~/repos read-only; this one, writable
  - ~/.config/gh
```

```dockerfile
# sally.Dockerfile
ARG BASE            # the repository's image, passed by vz; this file does not build alone
FROM ${BASE}
RUN apt-get update && apt-get install -y --no-install-recommends ripgrep \
 && rm -rf /var/lib/apt/lists/*
```

Run it with `vz new -c sally`, or `VZ_CONFIG=sally`. `--show-effective-config` prints the chain, one
line per configuration with its file, each image with its configuration, file and verdict, then the
result as YAML:

```
# vz-debian-trixie (~/.config/viz-shell/vz-debian-trixie.vz.yml)
# default (vz.yml)
# sally (sally.vz.yml)
# image: ~/.config/viz-shell/vz-debian-trixie.Dockerfile (vz-debian-trixie, ~/.config/viz-shell/vz-debian-trixie.vz.yml)
# image: Dockerfile (default, vz.yml, ARG BASE: required, stacks)
# image: sally.Dockerfile (sally, sally.vz.yml, ARG BASE: required, stacks)
```

`vz configs` lists every configuration, the repository's first: its name, file, what it extends, what
it changes.

- The chain is recorded on the container, in the labels `vz.chain` (`<config>@<file>`, fold order)
  and `vz.image` (tags, bottom first), and shown on entry as `Chain:`, attached or not, a library
  configuration marked `(library)`; `docker inspect` has it for debugging.

**The library.** The first `vz` writes one template when the library has no configuration named
`vz-debian-trixie`, from [`templates/`](templates), embedded in the binary, and never overwrites a
file: `vz-debian-trixie.vz.yml`, Debian trixie with sudo, the docker CLI and locales, what vz's
features need, every option written at its default with what flipping it does; and
`vz-debian-trixie.Dockerfile`, which it builds. A repository extends it by name. Add configurations of
your own beside it, for repositories to extend.

The portability test for a repository's configurations: no host path outside the repository, except
`state`, which lives inside it. Someone cloning the repository gets its environment; what it extends
from their library is theirs.

Trust: `vz` runs the configuration it is given; it cannot tell a hostile one, which can name any host
file or share the docker daemon. Review a repository's configuration as you would its code. What
protects the host is what reaches the container: only the repository, and what the configuration
shares, mounts or grants; and, unless `privileges.sudo` is granted, the secure floor inside it.

## Sessions

Every container is named `vz-<index>-<repository>`, its hostname too, so your prompt says which one
you are in. The index is the lowest free one of the repository's containers; `vz new api` adds a
name: `vz-1-app-api`. Labels (`vz.repo`, `vz.index`, `vz.name`, `vz.config`, …) identify them:
`vz ls`, `vz attach` and `vz kill` look containers up by label, by index or name.

```yaml
persistent: true    # the container outlives the shell that created it; `vz kill` removes it
attach: true        # `vz new` without a name joins this repository's container of the same configuration
```

| `persistent` | `attach` | `vz new` | the creating shell exits | the next `vz new` |
|---|---|---|---|---|
| false | false | a new container | it is removed | another new one |
| true | false | a new container | it is kept | another new one |
| true | true | a new container, or joins the kept one | it is kept | joins it |
| false | true | a new container, or joins the running one | it is removed, with attached shells | joins it |

- `vz attach [INDEX|NAME] [-- COMMAND]` (or `vz at`) runs a shell, or the command, in a container
  of this repository, as you; without a target, in the only running one. A stopped persistent
  container is started. `attach: true` joins unnamed containers only; a named one is attached by name.
- A container is entered only with its own configuration: `vz attach 0` to a container started with
  `-c trusted` is refused, naming `vz -c trusted attach 0`. `vz new` never lands in
  a trusted container.
- A container keeps the configuration it was created with; attaching after a change warns, and
  `vz kill` then `vz new` applies it. `vz -- COMMAND` joins as `vz new` does.
- Attaching is `docker exec` of vz's own binary: it waits for the entrypoint to finish setting you up,
  then becomes you, as the entrypoint does.

## Images

```yaml
image: hello-world            # pull
```

```yaml
image:                        # build; paths relative to the configuration file's folder,
                              #   or ~/… under your home, or absolute; ${repo}, ${home} substituted
  dockerfile: Dockerfile
  context: .                  # optional, default: the configuration file's folder
  args: { GREETING: hello }   # optional
```

- A built image is tagged `vz-<Dockerfile's folder>:<hash of Dockerfile + args>` and builds only
  when missing. One Dockerfile used by many repositories, say from the library's `vz-debian-trixie`, is one image.
  Editing the Dockerfile or args rebuilds; editing a copied file does not — remove the image to force it.
- Builds run `docker build`, via [docker-wrapper]https://github.com/joshrotenberg/docker-wrapper,
  so `.dockerignore` applies.
- This repository's `Dockerfile`: the Rust toolchain, fish as its shell, and `vz` built from the
  checkout, stacked on the base (below); alone, on pinned Debian, with what the base adds.

**Stacking.** A Dockerfile that declares `ARG BASE` before its first `FROM`, then `FROM ${BASE}`, is
built on the image the configurations before it in the chain resolved to: vz pulls or builds that one
first, then passes `--build-arg BASE=<its tag>`. Images follow the [chain](#configurations), so a later
configuration's Dockerfile lands on top. The base's tag joins the hash: a new base rebuilds what stacks on it. Three
forms: `ARG BASE=<default>` stacks, and with no earlier image builds alone on its default;
`ARG BASE` stacks, and with no earlier image is refused, naming the file; no `ARG BASE` (or `BASE`
set in `args`, or an image reference) replaces. `--show-effective-config` marks each image
`ARG BASE: stacks`, `ARG BASE: required, stacks`, `ARG BASE: its default` or `replaces`; the banner
shows `Image: vz-app:3f9c2a1b (on vz-tools:9a1c0d2e, debian:stable-slim)`.

**What an image needs.** Official images like `debian`, `alpine`, `rust`, `node` or `python` already
meet the contract. For your own images:

- a shell: `bash`, else `sh`, or the one `shell` names;
- tools under `/usr/local` or `/opt`, not in a home, so the image serves every user and `state`
  mounts in your home cannot shadow them;
- no reliance on an `ENTRYPOINT` (vz runs its own) or on a baked user (vz adds you);
- `sudo`, if a configuration grants `privileges.sudo`.

**Base image.** The first run writes the library's template, `vz-debian-trixie`
([`templates/vz-debian-trixie.vz.yml`](templates/vz-debian-trixie.vz.yml) and
[`templates/vz-debian-trixie.Dockerfile`](templates/vz-debian-trixie.Dockerfile), embedded in the
binary); its `image:` builds the Dockerfile, locally, on first use, as `vz-viz-shell:<hash>`. It
holds what vz's features need: sudo, the docker CLI, locales, ca-certificates; nothing else. A
repository extends it by name; edit it, or add a configuration of your own beside it with another
image.

**How the image is built.** apt when Debian's version will do. Otherwise the vendor's release,
downloaded from its URL and verified by sha256, or, for a static binary whose vendor publishes an
image as the way to get it, `COPY --from` that image. Every `FROM` is pinned by digest and every
download checksummed.

A repository stacks on it and adds what it needs:

```dockerfile
# The image this one builds on, passed by vz; `docker build .` needs --build-arg BASE.
ARG BASE
FROM ${BASE}
RUN apt-get update \
 && apt-get install -y --no-install-recommends git fish \
 && rm -rf /var/lib/apt/lists/*
```

An opinionated everyday image, with agents, shells and tools, is a repository of your own, built the
same way: a configuration of your own in the library for repositories to extend, or beside a
repository's own, stacked on its image ([Configurations](#configurations)).

Pin a version, never a moving tag: a new base is then an edit to the `FROM`, which changes the
image's hash, so the repository rebuilds on that branch, and only there.

## Your user in the image

**Default — added at start.** The image needs no user. `vz` starts the container as root with
itself as entrypoint, which adds your `/etc/passwd` and `/etc/group` lines, creates your home,
then becomes you. One image serves everyone; your home starts empty.

**Opt-in — baked at build.** For tools installed into your home or files owned by you, declare
any of these build args; `vz` passes your values:

| Arg | Value | | Arg | Value |
|---|---|---|---|---|
| `VZ_USER` | user name | | `VZ_GROUP` | group name |
| `VZ_UID` | uid | | `VZ_HOME` | home path |
| `VZ_GID` | primary gid | | | |

```dockerfile
ARG VZ_USER
ARG VZ_UID
ARG VZ_GID
ARG VZ_GROUP
ARG VZ_HOME
RUN groupadd -g "$VZ_GID" "$VZ_GROUP" \
 && useradd -u "$VZ_UID" -g "$VZ_GID" -d "$VZ_HOME" -m -s /bin/bash "$VZ_USER"
# Later build steps run as you.
USER $VZ_USER
```

Alpine: `addgroup -g "$VZ_GID" "$VZ_GROUP" && adduser -D -u "$VZ_UID" -G "$VZ_GROUP" -h "$VZ_HOME" "$VZ_USER"`.

- Declared args join the image hash, so such an image is built per user.
- At start, a baked user must match you: name, uid and home; group name and gid.
  Anything else holding your name, uid or gid is refused, naming it.
- Always pass `-d "$VZ_HOME"`. On Ubuntu 23.04+, `userdel -r ubuntu` first: it holds uid 1000.
- `USER` affects only the build; the container always starts as root for the entrypoint.
- Don't set these in a configuration's `args`: they would override yours and fail the match.

## State

Container paths whose contents survive the container. Each is kept in the state folder, `.vz_state/`
at the git root by default, at its own container path and mounted back; the host's own files are untouched.

```yaml
state_dir: .vz_state                   # optional: the state folder, see below
state:
  - ~/.local/share/opencode            # a folder
  - /var/cache/apt                     # any absolute path
  - { path: ~/.config/opencode/opencode.json, type: file, init: "{}" }   # a file, "{}" the first time
```

| Entry | Inside | Kept at |
|---|---|---|
| `~/.local/share/opencode` | `/home/sally/.local/share/opencode` | `.vz_state/home/sally/.local/share/opencode` |
| `/var/cache/apt` | `/var/cache/apt` | `.vz_state/var/cache/apt` |

- Expanded: `{ path, type: dir | file, init, enabled }`. `init` is a file's content when `vz`
  creates it; never rewritten.
- `state_dir`: relative to the configuration file's folder, `~/…` or absolute. Without it every configuration,
  `-c` ones included, shares `.vz_state/` at the git root.
- `vz` creates missing entries as you. Delete the state folder to start over; ignoring it in git is up to you,
  but keep it out of Docker build contexts: `**/.vz_state/` in `.dockerignore`.
- A state folder hides what the image had at that path, and belongs to you.
- A `file` suits tools that update in place. One that saves by rename (`git config`) fails with
  "Device or resource busy": keep a folder and point the tool into it.
- Paths start with `~/` or `/`, without `.`, `..` or `//`, and may not hold or sit inside the repository.

## Mounts

Host paths shown inside, reusing the host's own files: at the same path, unless a target names
another. Written as docker's `-v`: `path[:target][:ro|rw]`. Mounts are read-only unless `:rw`.
The default is read-only, `vz`'s secure-by-default posture; a mount says `:rw` to be writable.

```yaml
mounts:
  - ~/.config/gh:rw                                     # read-write
  - ~/repos                                             # read-only
  - ~/repos/skills:~/.agents/skills                     # elsewhere inside
  - ~/repos/skills:~/.config/opencode/skills            # one source, several targets
```

The map form says the same, key by key, and adds `enabled: false`, which removes an entry an
earlier layer added:

```yaml
mounts:
  - { path: ~/.config/gh, mode: rw }
  - { path: ~/repos/skills, target: ~/.agents/skills, enabled: false }
```

- Each path starts with `~/` or `/`; anything else, a mode other than `ro` or `rw`, or an empty
  field is refused, quoting the entry.
- The repository `vz` runs for is always read-write, even inside a read-only mount like `~/repos`:
  deeper mounts land on top. A mount that lands on the repository itself is skipped, so one repository
  can be read-write for every session, its own included: `- ~/repos/notes:rw`.
  `VZ_LOG=viz_shell=debug` shows the skip.
- A mount must exist on the host; `vz` never creates one.
- A single file mounts too, with two catches: a read-write one breaks tools that save by renaming
  over it ("Device or resource busy"), and a running container keeps seeing the old version when
  the host replaces the file by renaming, as many editors and `git config` do. Folders have neither.
- `- ~/.ssh` gives ssh inside your keys, `config` and `known_hosts`, as on the host. The keys are
  then readable by everything in the container: mount it only where you trust what runs there.
- Mounts are keyed by their target, where they land, else their path: a later configuration updates or removes
  one by it, in either form, and one source may land in several places.
- A mount may land inside a state folder, such as a library inside a tool's persisted config: `vz`
  creates its mount point in the state folder, as you. It may not hold a state path, sit on one, or
  lie inside a state file.
- Paths follow the state rules.

## Share

What of the host the shell shares; nothing unless a layer turns it on.

```yaml
share:
  docker: true         # the host's docker daemon
  host_network: true   # the host's network stack
```

- `docker`: the socket behind the current docker endpoint (it follows `DOCKER_HOST` and
  `docker context use`) is mounted at its own path, `DOCKER_HOST` points at it, and you join its
  group: docker works inside as you, without sudo. The image needs the docker CLI.
- Sharing the daemon gives the shell root-equivalent control of the host: only for trusted repositories.
- `host_network`: `--network host`, the host's network stack, its `localhost` and its ports. Without
  it the shell still reaches the internet, through docker's own network, and the host answers to
  `host.docker.internal`, as in Docker Desktop, but not on its `localhost`.
  It gives no root, but the shell reaches every service the host does, and its ports can clash.
- `false` in a later configuration turns either off: `share: { docker: false }`.
- `vz` inside `vz` talks to the host's daemon, which mounts host paths: run the `vz` built in the
  repository (`target/…/release/viz-shell`); another is refused.

## Privileges

What the shell may do inside; nothing beyond the secure floor unless a layer grants it.

```yaml
privileges:
  sudo: true      # root through sudo; the image needs sudo
```

- The secure floor, by default: every Linux capability dropped, `no-new-privileges` set. The
  container's root processes keep `CHOWN`, `SETUID`, `SETGID` and `KILL`: the entrypoint to set you
  up, the init to pass signals on to your processes. Once the entrypoint becomes you, the shell holds
  no capabilities, and setuid programs such as `sudo` or `su` gain nothing. At most 512 processes
  run, so a runaway or a fork bomb stops there, not at the host's limit.
- `sudo: true`: docker's default capabilities, no `no-new-privileges`, no process limit of its own,
  and a password-less sudoers line for you. An image without sudo gets a warning, and the shell starts without it.
- The library's `trusted` grants it; `false` in a later configuration takes it back.

## Environment

Variables inside the container, from four sources; later wins:

```yaml
env:
  defaults:                     # 1. written here: the lowest level
    RUST_LOG: info
    REPO_ROOT: ${repo}          #    ${repo} and ${home} are substituted
  files:                        # 2. read on the host, in this order; never mounted
    - .env                      #    skipped when missing
    - { path: .env.required, required: true }   # must exist
  passthrough:                  # 3. copied from the host's environment
    - GH_TOKEN
    - "FMP_*"                   #    `*` and `?` globs
```

```sh
vz --env RUST_LOG=debug         # 4. over everything; `--env NAME` copies the host's
vz --show-env                   # every name and where it comes from, never a value
```

- Paths are relative to the configuration file's folder, `~/…` or absolute. Files use `.env` syntax: `KEY=value`,
  `#` comments, `export`, and quotes around values with spaces.
- `defaults` is a map, keyed by variable name: a later configuration overrides per name, `null`
  removes one.
  `files` and `passthrough` are lists: expanded forms `{ path, required, enabled }` and `{ name, enabled }`.
- An env file tracked by git is loaded with a warning: its values are in the repository's history.
- Values reach the container by name (`docker create --env NAME`, and `docker exec` when attaching),
  never on a command line or in a log;
  `--show-effective-config` and `--show-env` never print a value from a file or the host.
  `docker inspect` of the container still shows them, as for any container environment.
- `vz` sets `HOME`, `VZ_*`, `TERM`, `COLORTERM`, `LANG` and, when docker is shared, `DOCKER_HOST` itself;
  the environment cannot change those.

## Hooks

Commands run inside the container before you enter it:

```yaml
hooks:
  create:                      # once per container, on its first entry
    - npm ci
    - { run: "fish -c 'set -U fish_greeting'", enabled: false }
  attach:                      # before every entry: each shell, and `vz -- command`
    - git fetch --quiet
```

- Run as you, in the repository root (`$VZ_REPO`), through `sh -c`, with the session's environment;
  each list in order. Output goes to the terminal of the entry that runs them.
- `create` runs once per container, on its first entry; a stop and start doesn't run it again. An
  ephemeral container runs it on every `vz`: keep hooks idempotent and fast.
- `attach` runs before every entry: the first shell, each `vz attach`, and `vz -- command`.
- A failing hook fails the entry, naming it and its exit status. A failed `create` runs again on the
  next entry.
- Entries arriving while `create` runs wait for it.
- Keyed by the command: a later configuration adds one, replaces one by the same command, or removes one with
  `enabled: false`. An empty command is refused.
- Another shell's syntax goes through it: `fish -c '...'`.

To seed a config file once, a `state` file with `init:` needs no hook.

## Examples

Recipes in [`examples/`](examples): each folder holds its configuration files, `<name>/*.vz.yml`,
any Dockerfiles they build, and `<name>/test.bats`, a [bats](https://bats-core.readthedocs.io) file that
runs the recipe and checks the result, one `@test` per check; `examples/helpers.bash`, loaded by each,
sets up a throwaway home, library and repository and removes the containers a file started. Copy a
folder's configuration files and Dockerfiles to your repository root, or try one in place with `-f`.

`just examples` runs them all, `just examples mounts` one. They need docker: run them from inside vz,
in this repository's `default` configuration, which shares the host's daemon (`vz -- just examples`).

| Recipe | Shows |
|---|---|
| [`pull-image`]examples/pull-image | the smallest configuration: one document, no `name:`, the default |
| [`build-dockerfile`]examples/build-dockerfile | building from a Dockerfile, with args |
| [`image-stack`]examples/image-stack | `ARG BASE` stacking along the chain, on a built image and on a reference; replacing Dockerfiles; `deep` extending another document's `tools`; a configuration that extends nothing; images named by their folder |
| [`baked-user`]examples/baked-user | your user baked into the image, installing into your home |
| [`state`]examples/state | folders, a file with `init`, absolute paths |
| [`mounts`]examples/mounts | the `path[:target][:ro\|rw]` string form: read-only `~/repos`, a read-write config folder, a single file, one source at several targets, a mount inside state, a mount on the repository skipped |
| [`configs`]examples/configs | documents of one file, each extending `default`: overriding and removing entries; `debian12` in a file of its own; `trusted` with `extends: trusted` reaching the library's; `VZ_CONFIG` |
| [`docker`]examples/docker | the host's docker daemon inside, as you; off in another configuration |
| [`env`]examples/env | every environment source and their order, a later configuration's overrides, values kept out of sight, the `TERM` fallback |
| [`library`]examples/library | no default in the library; a library configuration run by name; a repository's nameless default `extends: vz-debian-trixie`; its `trusted` on the library's; `vz configs`; a library filename meaning nothing |
| [`local`]examples/local | a configuration of your own: `sally.vz.yml` extending the default, a value, a mount's mode, an added mount, `sally.Dockerfile` stacked on its image; `joe.vz.yml` with the banner, its `Chain:` and `Config:` |
| [`resolution`]examples/resolution | what reading and resolving refuse: an unknown name, a cycle, two names in `extends`, `profiles:`, two without `name:`, one name twice, a nameless one beside a `default`, `ARG BASE` with nothing before it, `scan:` outside the library, one library name in two folders, no default |
| [`privileges`]examples/privileges | the secure floor by default, its process limit; sudo in another configuration; an image without sudo |
| [`host-network`]examples/host-network | the host's network in another configuration, docker's own by default, `host.docker.internal` |
| [`shell`]examples/shell | fish as the shell, its configuration as state; a missing shell's fallback |
| [`sessions`]examples/sessions | named containers, `VZ_CONTAINER`, persistent ones, attach by index, name or `attach: true`, kill |
| [`hooks`]examples/hooks | `create` once per container, across a stop and start, and `attach` per entry, in order, in the repository root; one removed in another configuration; a failing hook; `attach: true`, the next vz running `attach` again; configurations in files of their own extending them |

## Logging

Stderr, filtered by `VZ_LOG` (default `warn,viz_shell=info,docker_wrapper=error`):

```sh
VZ_LOG=viz_shell=debug vz new   # each step, and every docker command in full
VZ_LOG=debug vz new      # plus docker-wrapper's spans: each CLI call, exit code, output size
```

## Development

```sh
just build                # → target/x86_64-unknown-linux-musl/release/viz-shell
just install              # → ~/.local/bin/viz-shell, and the alias ~/.local/bin/vz
```

Rust 1.98.1 and the musl target are pinned in `rust-toolchain.toml`. The binary is static,
because `vz` mounts itself into every container it starts: build it anywhere, run it on any Linux host.

Inside vz: `vz new -c dev`, the toolchain with fish and the coding agents, then `just build` there.

Build first (`just build`); the example tests run `target/.../release/viz-shell`, or `$VZ`.
Each file runs with a throwaway `HOME` (`target/vz-examples/<example>/home`), so `~` never touches
yours, and starts with an empty `.vz_state/`. `vz -- just examples` runs them inside, in `default`,
which shares the host's docker daemon; from `dev`, no: the agents' configuration does not reach it.

```sh
just test                  # unit tests, no engine needed
just examples              # every examples/*/test.bats, with bats, against the built vz; needs docker
just examples state        # one of them
```

The gate, `.github/workflows/gate.yml`, runs on every pull request and on master: it builds vz on the runner,
then runs the example tests through it, from this repository's `default` image. Every run proves the glory
path on a clean machine: scaffolding the library, building the base, stacking, the socket share.

Releases are release-plz's: `release-plz.toml`, `.github/workflows/release.yml`. Every push to master
updates one release PR, bumped from the conventional commit titles since the last release: `feat:` a minor,
anything else a patch, `!` or `BREAKING CHANGE:` a major from 1.0 (0.x to 1.0 is a hand edit of
`Cargo.toml`). Merging it tags `v<version>`, publishes to crates.io, creates the GitHub release with the
changelog and attaches the static tarball. Pull requests are squash-merged, so their titles must be
conventional commits: `Feat/ci gate` lands under Other. The maintainer creates two secrets:
`RELEASE_PLZ_TOKEN`, a PAT, so the release PR runs the gate, and `CARGO_REGISTRY_TOKEN`.

Issues and pull requests are welcome. Run `just test` and `just examples` before sending one.

## License

Licensed under either of [Apache License, Version 2.0](LICENSE-APACHE) or
[MIT license](LICENSE-MIT) at your option.

Unless you explicitly state otherwise, any contribution intentionally submitted
for inclusion in this project by you, as defined in the Apache-2.0 license,
shall be dual licensed as above, without any additional terms or conditions.