Problem
anvil shell <pkg> composes the package's env and spawns $SHELL, but the commands: declarations from the package definition do not reach the interactive subshell. So a package like
# substance.yaml
commands:
substance: '"/Applications/.../Adobe Substance 3D Painter"'
works through anvil run substance -- substance, but typing substance in a shell opened via anvil shell substance returns command not found.
The asymmetry is the bug: anvil run knows about the alias map, anvil shell drops it. A user who asks for "a shell with the package active" reasonably expects every command the package declares to be callable — the current behaviour breaks that mental model and sends them back to anvil run for trivial cases.
Current vs desired
| Surface |
Today |
Desired |
anvil run substance -- substance |
runs Painter |
unchanged |
anvil shell substance → substance |
command not found |
runs Painter |
anvil shell substance → which substance |
nothing |
path under $ANVIL_COMMAND_DIR |
anvil shell --env-only substance |
n/a |
env-only, no shims (escape hatch) |
Options considered
A. Materialize commands as wrapper scripts on a prepended PATH (recommended)
On anvil shell <pkg>:
- Create
$TMPDIR/anvil-shell-<uuid>/.
- For each
(name, command) in the merged commands: map, write
#!/usr/bin/env bash
exec <shell-words-tokenised command> "$@"
and chmod 755.
- Prepend the tempdir to
PATH, export ANVIL_COMMAND_DIR=<tempdir> for introspection.
- Spawn
$SHELL. Tempdir survives the session, garbage-collected on the next anvil shell invocation (sweep $TMPDIR/anvil-shell-* older than shell.orphan_ttl).
Pros
- Shell-agnostic out of the box — bash, zsh, fish, nu, and any Windows shell that respects PATH once we write
.cmd shims instead of shebang scripts.
which substance / type substance report a real path. Debuggable.
- Commands compose naturally (
xargs substance, watch substance -h, subshells, pipes).
- Mirrors established tools: rez, mise, direnv, plus anvil's own
wrap subcommand, all use PATH shims.
- Zero shell-specific code paths in anvil.
Cons
- One extra fork per command invocation. Sub-millisecond on local disk.
- Leftover tempdirs if a shell is SIGKILL'd. Mitigated by the lazy sweep.
- PATH ordering: a pre-existing user
substance binary gets shadowed. Usually what the user wants; command -p or absolute path still wins if not.
B. Emit shell-specific RC snippets (aliases / functions)
Detect $SHELL, generate alias substance='...' for bash/zsh, function substance; ...; end for fish, etc. Spawn with the appropriate --rcfile/-i incantation.
Pros
type substance reports "alias" correctly.
- No filesystem writes past startup.
Cons
- Per-shell codegen (bash, zsh, fish, nu, csh, pwsh…). Real maintenance surface.
- Aliases break non-interactive composition (
xargs substance, find -exec substance).
- RC file interaction is fiddly:
bash --rcfile X suppresses .bashrc so the user's prompt disappears; workarounds exist but they're shell-specific.
C. Status quo plus documentation
"shell is for env; use run for commands." Easy to ship, unsatisfying because the package definition makes a promise the shell silently breaks.
D. Hybrid — Option A as default plus --inject-aliases opt-in for B
Ship A, leave B as a flag for users who prefer native alias semantics. Twice the surface area; only worth it if there's demand.
Recommendation
Option A — PATH shims. Same model the rest of the tool ecosystem converged on for a reason: shell-agnostic, composable, debuggable, cleanly deallocated. Ship A first; revisit B only if the type substance == alias ergonomic comes up from real users.
Implementation outline
anvil/src/commands/shell.rs: add shim materialisation before the $SHELL spawn. Reuse the existing shell-words split already used by anvil run.
- Tempdir:
tempfile::TempDir with a stable prefix (anvil-shell-) so the sweeper can find orphans. Keep the handle alive through the exec; let OS temp cleanup + our sweeper handle the rest.
- Sweep: on
anvil shell entry, walk $TMPDIR/anvil-shell-* and unlink dirs older than config.shell.orphan_ttl (default 1 hour). Cheap, runs before we materialise the current session's shims.
- Config:
# ~/.anvil.yaml
shell:
orphan_ttl: 3600 # seconds
inject_commands: true # false replicates today's env-only behaviour
- CLI flags on
anvil shell:
--env-only — skip shim generation entirely.
--no-sweep — skip orphan cleanup (for debugging).
- Windows: emit
.cmd wrappers rather than shebang scripts. PATH semantics unchanged.
- No per-shim env prelude needed: the shim inherits
PATH, DYLD_LIBRARY_PATH, etc. from the shell's env, which anvil already set before spawning.
Escape hatches
anvil shell --env-only <pkg> — env composed, no shims. Preserves today's behaviour behind a flag for users who want to test the raw env.
ANVIL_DISABLE_COMMAND_SHIMS=1 — env-var equivalent, useful in CI.
- Inside the subshell:
unset PATH front-trim, command -p substance, or full path — users can always opt out.
Testing story
- Unit:
materialise_commands(command_map, tempdir) -> Vec<PathBuf> writes correct shim contents, permissions, returns the list.
- Integration (lightweight pty or
expect-style harness): spawn anvil shell substance, send which substance, assert the result points inside the tempdir. Send substance --version, assert exit 0 and expected output.
- Cleanup: create stale
anvil-shell-* dirs in $TMPDIR, run anvil shell, assert only fresh dirs remain and the stale ones are gone.
- Cross-shell smoke: CI matrix runs the integration test under bash, zsh, and fish (GitHub Actions supports all three).
Docs impact
README shell section: add "every command declared by the package is callable in the subshell" plus the --env-only escape hatch.
- No migration notes needed: users who expected commands to work today were already broken, this lifts the limitation.
Out of scope (for the first cut)
- PowerShell support. Ship POSIX first; Windows
.cmd shims cover cmd.exe and are enough for most anvil users today. Proper PowerShell sessions can follow once the POSIX path is solid.
- Auto-completion for shim commands (bash-completion, zsh fpath, fish completion). Nice follow-up; not blocking the basic
which substance → works win.
- Runtime shim regeneration inside the subshell. Current mental model: the shell is a snapshot of the resolved env at spawn time. Keep it immutable; if the user changes the package set, they spawn a new shell.
- Interaction with
anvil context save / restore: contexts would need to serialise the shim tempdir or regenerate on restore. Both workable. Pick one once context is stable; not a blocker for this feature.
- Lockfile integration: if
anvil lock has pinned a package set, this feature uses the same resolved set. No new lockfile semantics needed.
- Dynamic command re-resolution on
cd: direnv-style "the shim set changes as I move between project anvil files". Interesting but a different feature; call it shell v2.
- Cross-shell alias emission (Option B). Deferred unless there's direct demand.
Downstream impact on users
mypipe (and similar wrappers): once this ships, pipe myfilm shell substance behaves exactly like pipe myfilm substance minus the Painter launch. No pipeline-side changes needed.
anvil run path: unchanged. Aliases continue to resolve through the existing codepath.
- Users who currently
exec into commands inside an anvil shell subshell: gain an ergonomic win, no behaviour regression.
Problem
anvil shell <pkg>composes the package's env and spawns$SHELL, but thecommands:declarations from the package definition do not reach the interactive subshell. So a package likeworks through
anvil run substance -- substance, but typingsubstancein a shell opened viaanvil shell substancereturns command not found.The asymmetry is the bug:
anvil runknows about the alias map,anvil shelldrops it. A user who asks for "a shell with the package active" reasonably expects every command the package declares to be callable — the current behaviour breaks that mental model and sends them back toanvil runfor trivial cases.Current vs desired
anvil run substance -- substanceanvil shell substance→substancecommand not foundanvil shell substance→which substance$ANVIL_COMMAND_DIRanvil shell --env-only substanceOptions considered
A. Materialize commands as wrapper scripts on a prepended PATH (recommended)
On
anvil shell <pkg>:$TMPDIR/anvil-shell-<uuid>/.(name, command)in the mergedcommands:map, writechmod 755.PATH, exportANVIL_COMMAND_DIR=<tempdir>for introspection.$SHELL. Tempdir survives the session, garbage-collected on the nextanvil shellinvocation (sweep$TMPDIR/anvil-shell-*older thanshell.orphan_ttl).Pros
.cmdshims instead of shebang scripts.which substance/type substancereport a real path. Debuggable.xargs substance,watch substance -h, subshells, pipes).wrapsubcommand, all use PATH shims.Cons
substancebinary gets shadowed. Usually what the user wants;command -por absolute path still wins if not.B. Emit shell-specific RC snippets (aliases / functions)
Detect
$SHELL, generatealias substance='...'for bash/zsh,function substance; ...; endfor fish, etc. Spawn with the appropriate--rcfile/-iincantation.Pros
type substancereports "alias" correctly.Cons
xargs substance,find -exec substance).bash --rcfile Xsuppresses.bashrcso the user's prompt disappears; workarounds exist but they're shell-specific.C. Status quo plus documentation
"
shellis for env; userunfor commands." Easy to ship, unsatisfying because the package definition makes a promise the shell silently breaks.D. Hybrid — Option A as default plus
--inject-aliasesopt-in for BShip A, leave B as a flag for users who prefer native alias semantics. Twice the surface area; only worth it if there's demand.
Recommendation
Option A — PATH shims. Same model the rest of the tool ecosystem converged on for a reason: shell-agnostic, composable, debuggable, cleanly deallocated. Ship A first; revisit B only if the
type substance == aliasergonomic comes up from real users.Implementation outline
anvil/src/commands/shell.rs: add shim materialisation before the$SHELLspawn. Reuse the existingshell-wordssplit already used byanvil run.tempfile::TempDirwith a stable prefix (anvil-shell-) so the sweeper can find orphans. Keep the handle alive through theexec; let OS temp cleanup + our sweeper handle the rest.anvil shellentry, walk$TMPDIR/anvil-shell-*and unlink dirs older thanconfig.shell.orphan_ttl(default 1 hour). Cheap, runs before we materialise the current session's shims.anvil shell:--env-only— skip shim generation entirely.--no-sweep— skip orphan cleanup (for debugging)..cmdwrappers rather than shebang scripts. PATH semantics unchanged.PATH,DYLD_LIBRARY_PATH, etc. from the shell's env, which anvil already set before spawning.Escape hatches
anvil shell --env-only <pkg>— env composed, no shims. Preserves today's behaviour behind a flag for users who want to test the raw env.ANVIL_DISABLE_COMMAND_SHIMS=1— env-var equivalent, useful in CI.unset PATHfront-trim,command -p substance, or full path — users can always opt out.Testing story
materialise_commands(command_map, tempdir) -> Vec<PathBuf>writes correct shim contents, permissions, returns the list.expect-style harness): spawnanvil shell substance, sendwhich substance, assert the result points inside the tempdir. Sendsubstance --version, assert exit 0 and expected output.anvil-shell-*dirs in$TMPDIR, runanvil shell, assert only fresh dirs remain and the stale ones are gone.Docs impact
READMEshellsection: add "every command declared by the package is callable in the subshell" plus the--env-onlyescape hatch.Out of scope (for the first cut)
.cmdshims covercmd.exeand are enough for most anvil users today. Proper PowerShell sessions can follow once the POSIX path is solid.which substance → workswin.anvil context save/restore: contexts would need to serialise the shim tempdir or regenerate on restore. Both workable. Pick one oncecontextis stable; not a blocker for this feature.anvil lockhas pinned a package set, this feature uses the same resolved set. No new lockfile semantics needed.cd: direnv-style "the shim set changes as I move between project anvil files". Interesting but a different feature; call it shell v2.Downstream impact on users
mypipe(and similar wrappers): once this ships,pipe myfilm shell substancebehaves exactly likepipe myfilm substanceminus the Painter launch. No pipeline-side changes needed.anvil runpath: unchanged. Aliases continue to resolve through the existing codepath.execinto commands inside ananvil shellsubshell: gain an ergonomic win, no behaviour regression.