muscles — Specification¶
A CLI that explains container images: what each layer changed, where the wasted space is, and what changed between two versions. It works the same on your laptop and in CI.
| Status | v0.1, implemented. This spec records what was built and why; the docs are the user-facing reference. |
| Author | Ross Edman |
| Last updated | 2026-10-20 |
| Language | Go (1.26+) |
| Libraries | Cobra, Viper, go-containerregistry, Lip Gloss (color only), xxhash, zstd, doublestar |
| Style guide | CLI Style |
1. Problem¶
A container image is a stack of filesystem diffs, but the usual tools barely
show them. docker history lists commands and sizes. To see which files a
layer added, changed or deleted, you export the image and diff the tarballs by
hand. In CI it's worse: images grow release after release and nobody notices
until pulls get slow.
Questions that should take seconds to answer:
- Why is this image 1.4 GB? Which layer, which files?
- What did
RUN apt-get install ...actually put on disk? - Did my cleanup step actually free space, or does an earlier layer still hold those bytes?
- What did this pull request change in the image?
- Did this pull request make the image bigger than we allow?
2. Goals¶
- Explain each layer. Files added, modified, permission-only rewrites and deletions, with sizes, next to the Dockerfile step that created the layer, plus a one-line summary.
- Find waste. Bytes stored in the image that aren't visible in its final filesystem.
- Compare two images. Shared layers, where they split, and a file-level diff of the final filesystems.
- Gate CI. Fail a build when the image breaks a size, waste or growth budget, or contains a forbidden path.
- Same tool, both places. Readable tables in a terminal; stable JSON and Markdown for CI, scripts and pull request comments.
- Works without Docker. Reads from a registry, a tarball or an OCI directory as well as the local daemon, so CI runners don't need a Docker socket.
3. Non-goals¶
- An interactive TUI. dive covers interactive browsing (see design note D1).
- Building, pushing or modifying images. Generic registry tasks (copy, tag, inspect manifests) are left to crane and skopeo.
- Vulnerability scanning (Trivy, Grype).
- Windows container images.
4. Scenarios¶
| # | Where | Scenario | Command |
|---|---|---|---|
| S1 | Local | "Why is this image so big, and what do I change?" | muscles app:dev --dockerfile Dockerfile (images inspect), then muscles waste list app:dev |
| S2 | Local | "What did layer 4 install?" | muscles changes list app:dev --layer 4 |
| S3 | Local | "What changed since the last release?" | muscles images diff ghcr.io/acme/app:1.8.0 app:dev |
| S4 | CI | "Fail the PR if the image breaks our budget." | muscles images check app.tar --baseline ghcr.io/acme/app:main |
| S5 | CI | "Show reviewers what this PR changed in the image." | muscles images diff ghcr.io/acme/app:main app.tar -o markdown >> "$GITHUB_STEP_SUMMARY" |
| S6 | CI | "Never ship secrets or a .git directory." |
check.forbid in .muscles.yaml, enforced by images check |
5. Command grammar¶
Following CLI Style §1 and §5, commands are muscles <noun> <verb> <target> --modifier, with few verbs over few nouns. A bare noun prints its help.
| Command | Purpose |
|---|---|
images inspect IMAGE |
Size, pull size, waste; ranked fixes with savings, layer, step, Dockerfile line, problem and fix; files worth a look; biggest layers. --limit. muscles IMAGE runs it. |
layers list IMAGE |
One row per layer: size, + ~ ◌ - counts, step, note. Empty steps dimmed between rows. |
changes list IMAGE |
Paths changed, rolled up by directory: --layer, --type, --path, --min-size, --files, --limit. |
waste list IMAGE |
Hidden bytes with the storing and hiding layers: --files, --limit. |
images diff BASE TARGET |
A one-sentence verdict, shared layers, per-layer size changes, package changes, final filesystem diff. |
images check IMAGE |
Budgets and forbidden paths: --baseline, --max-size, --max-compressed, --max-waste, --max-growth, --max-layers. |
config list |
Every setting, value and origin. |
cache list, cache delete [--preview] |
The layer index cache. |
help [COMMAND] [--json], version [--json], completion SHELL |
Self-documentation (CLI Style §23 to §26). |
Global flags: -o/--output table|plain|json|markdown (and sarif for images check), --json (wins over --output), --source, --platform, --config, --cache-dir, --dockerfile, --fresh, --timeout, --no-color, -q/--quiet, -v/--verbose (wins over --quiet).
Shortcuts: muscles IMAGE is muscles images inspect IMAGE; muscles layers|changes|waste IMAGE runs that noun's list. A word that is or nearly is a command is never taken as an image. At a terminal, commands end with a Next: hint on stderr.
The CLI reference documents every flag and output shape.
5.1 Change types¶
| Marker | Name | Rule |
|---|---|---|
+ |
added | The path doesn't exist below. |
~ |
modified | The path exists below; contents, link target or type differ. |
◌ |
meta | The path exists below with identical contents; only permissions, owner or times differ. The whole file is stored again. |
- |
deleted | .wh.NAME, or .wh..wh..opq (removes lower layers' entries under a directory, keeping its own layer's). |
5.2 Exit codes¶
| Code | Meaning |
|---|---|
| 0 | Success, including requested help and empty results |
| 1 | Runtime error |
| 2 | Usage: bad command, flag, argument or config file |
| 3 | Registry authentication failed |
| 4 | Image not found |
| 5 | images check found a failing rule |
| 130 | Interrupted |
Exit 5 is only ever a failed check, so CI can tell a budget breach from a broken pipeline. Under --json, errors are {"error": {"code", "message", "run"}} on stderr.
6. Image sources¶
All sources go through go-containerregistry's v1.Image:
| Source | Reference | Notes |
|---|---|---|
| Docker / Podman daemon | app:dev, or docker:// |
Asked first in auto mode; the daemon exports the whole image. |
| Registry | ghcr.io/acme/app:main, or registry:// |
Layers streamed, never pulled. Docker and Podman keychains. |
| Tarball | app.tar, or tar:// |
docker save or buildx type=docker; exactly one image. |
| OCI layout | ./out, or oci://DIR[:tag] |
Tag by the org.opencontainers.image.ref.name annotation; nested indexes followed. |
Resolution: an explicit prefix, then --source, then an existing path (directory: OCI, file: tarball), then anything that can only be a path (starts with /, . or ~, or ends in .tar without a tag) is a missing tarball, else the daemon and then the registry. Multi-platform images pick --platform, or Linux on the host architecture; attestation manifests are skipped.
7. Analysis¶
7.1 Indexing¶
Each layer tar is read once, as a stream. One entry per header: clean absolute path, type, size, permission bits, uid, gid, link target, xxhash64 of the contents. Contents are never kept.
7.2 Replay¶
Layers are replayed over a merged filesystem tree (§5.1). Each wasted byte is attributed to the layer that stored it and the layer that hid it, with a reason (modified, meta, deleted). Re-declared directories are not changes.
7.3 Steps¶
History entries that made a layer are paired with layers aligned at the top (some builders drop base image history); entries without a layer become the empty steps between layers. CleanStep turns /bin/sh -c #(nop) ENV A=1 into ENV A=1 and BuildKit's RUN |2 A=1 B=2 /bin/sh -c make # buildkit into RUN make.
7.4 Summaries and notes¶
- Mostly: descend from
/while one directory holds at least 80% of a layer's changed bytes; if none dominates, the top three directories holding at least 10% each. - Notes:
package_cache(apt, apk, yum, dnf, pip, npm, yarn, Go build cache),hidden_delete,permission_rewrite(≥warn.permission_rewrite),overwrite(≥warn.overwrite),large_file(≥warn.large_file). Every note carries afix. - Packages: the dpkg status file, apk's installed database, rpm's database (SQLite, Berkeley DB or ndb) and pip's
.dist-info/METADATAare parsed as layers are indexed; each layer records the packages it installed, upgraded and removed, and its summary says so. - Fixes:
images inspectturns notes into fixes, each claiming the wasted bytes it would save (package caches first) so no byte is counted twice and the savings add up to the waste. - Dockerfile lines: with
--dockerfile, the final stage's instructions are matched to steps from the top down, ignoring flags; unmatched steps have no line.
7.5 Rollup¶
A directory collapses into one row only when every change below it has the same type and previous layer. Starting from the root, mixed directories are always opened, single-entry directories are always opened, and then the biggest directories are opened while the rows fit about --limit (30).
7.6 Cache¶
Layer indexes are stored as zstd-compressed gob in $XDG_CACHE_HOME/muscles/layers/v1/<diffID>.idx, written atomically, LRU-capped at cache.max_size (2 GB). For tarball and daemon sources the cache also records the compressed digest and size, which go-containerregistry otherwise computes by compressing the layer; registries are always asked.
8. Configuration¶
Precedence (CLI Style §11): flags > MUSCLES_* environment > project file (nearest .muscles.yaml/.muscles.yml up from the working directory, or --config) > user file ($XDG_CONFIG_HOME/muscles/config.yaml) > defaults. Unknown keys are errors with a did-you-mean suggestion.
source: auto # auto | docker | remote | tar | oci
platform: "" # default: linux on the host architecture
output:
units: si # si (MB) | iec (MiB)
cache:
dir: "" # default: $XDG_CACHE_HOME/muscles
max_size: 2GB
check:
max_size: 800MB
max_compressed: 300MB
max_waste: 50MB # or "10%" of the image
max_growth: 5% # vs --baseline; or bytes
max_layers: 40
forbid: ["**/.env", "**/.git/"]
allow: ["/etc/ssl/certs/*.pem"]
warn:
large_file: 50MB
permission_rewrite: 1MB
package_cache: true
9. Output¶
| Format | Default when | Notes |
|---|---|---|
table |
stdout is a terminal | Aligned, colored (decoration only), with totals. |
plain |
stdout is not a terminal | One tab-separated record per line, exact byte counts, no header (CLI Style §36). |
json |
--json |
"schema": "muscles/v1"; fields only ever added; [] never null. |
markdown |
-o markdown |
For pull requests and job summaries; long diffs folded in <details>. |
Progress is one live line on stderr, only when stderr is a terminal. With GITHUB_ACTIONS=true, images check adds ::error/::warning workflow commands to table and plain output (never JSON or Markdown).
10. Distribution¶
GoReleaser builds static binaries for linux/darwin/windows × amd64/arm64 with completions and a man page, checksums.txt, a multi-arch image ghcr.io/rossedman/muscles (distroless static), and a Homebrew cask in rossedman/homebrew-tap. A pushed v* tag runs the release workflow. See Releasing.
11. Architecture¶
cmd/muscles/ entry point
internal/cli/ Cobra commands, one per file
internal/config/ Viper resolution and validation
internal/source/ references → v1.Image
internal/index/ layer tars → entries; the cache
internal/analysis/ replay, waste, steps, notes, rollup (no I/O)
internal/compare/ two images (no I/O)
internal/check/ budgets and forbidden paths (no I/O)
internal/ui/ terminal, theme, banner, tables
internal/size/ sizes and limits
internal/testimg/ in-memory images for tests and docs
Data flows one way: source → index → analysis → compare / check → cli. See Design for the decisions (D1 to D9).
12. Quality¶
- Coverage: 90% total or more, enforced in CI (currently about 96%).
- Tests: in-memory images (
internal/testimg) cover every change type, opaque whiteouts, links, empty history and mismatched history; the registry tests use go-containerregistry's in-memory registry; the daemon is faked; command tests drivecli.Runwith buffers and a fake terminal, including prompts, interrupts, timeouts and a crash. - CI: gofmt, vet,
go test -race, coverage gate, golangci-lint,goreleaser check, a strict docs build, and a smoke job that builds muscles' own image and checks it against.muscles.yaml.
13. Later¶
- A JSON baseline report, so
images check --baseline report.jsonneeds no registry access. - Reading containerd's content store directly, avoiding the daemon's full export.
- A small read-only
exploreTUI, if browsing longchanges listoutput proves painful.
14. Open questions¶
- Name.
musclesis short and easy to type (CLI Style §4) but says nothing about images. Keep it? - Baseline artifacts. Is reading the baseline from a registry enough, or should a saved JSON report be the default in CI (§13)?