Skip to content

Expose package commands: inside anvil shell via PATH shims #4

@voidreamer

Description

@voidreamer

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 substancesubstance command not found runs Painter
anvil shell substancewhich 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>:

  1. Create $TMPDIR/anvil-shell-<uuid>/.
  2. For each (name, command) in the merged commands: map, write
    #!/usr/bin/env bash
    exec <shell-words-tokenised command> "$@"
    and chmod 755.
  3. Prepend the tempdir to PATH, export ANVIL_COMMAND_DIR=<tempdir> for introspection.
  4. 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

  1. anvil/src/commands/shell.rs: add shim materialisation before the $SHELL spawn. Reuse the existing shell-words split already used by anvil run.
  2. 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.
  3. 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.
  4. Config:
    # ~/.anvil.yaml
    shell:
      orphan_ttl: 3600         # seconds
      inject_commands: true    # false replicates today's env-only behaviour
  5. CLI flags on anvil shell:
    • --env-only — skip shim generation entirely.
    • --no-sweep — skip orphan cleanup (for debugging).
  6. Windows: emit .cmd wrappers rather than shebang scripts. PATH semantics unchanged.
  7. 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

  1. Unit: materialise_commands(command_map, tempdir) -> Vec<PathBuf> writes correct shim contents, permissions, returns the list.
  2. 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.
  3. Cleanup: create stale anvil-shell-* dirs in $TMPDIR, run anvil shell, assert only fresh dirs remain and the stale ones are gone.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions