Skip to content
cgraf78Public

About

Declare CLI tools once; install them anywhere via brew, apt, dnf, pacman, GitHub releases, cargo

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

shdeps

Tests Latest release License Rust Bash API Platform

Declare your shell tools in one config file. shdeps installs and updates them everywhere — brew, apt, dnf, pacman, zypper, apk, GitHub repos, or GitHub release binaries. One manifest, any machine.

shdeps demo

Features

  • Declarative config — one line per dependency in *.conf files
  • Multiple install methods — system packages (brew/apt/dnf/pacman/zypper/apk), automatic GitHub release/repo selection, explicit GitHub repos, explicit GitHub release binaries, Rust crates (cargo install), Go modules (go install), Python CLI tools (uv tool install), Node.js CLI tools (npm install -g), or fully custom hooks
  • Cross-platform — Linux, macOS, WSL, and Android (Termux) with platform, host, package-manager, and account filtering per dep
  • Package manager abstraction — batched installs with individual retry fallback
  • Smart binary matching — multi-pass asset selection by OS, arch, and libc
  • TTL-based caching — avoids redundant network calls
  • Post-install hooks — run arbitrary setup when a dependency changes
  • Config composition — split deps across multiple *.conf files in a config directory
  • Usable from CLI, Bash, Lua, or Rust — Rust shdeps CLI, stable Bash functions via source shdeps.sh, a Lua runtime asset API, and a documented Rust crate API

Quick Start

curl -fsSL https://raw.githubusercontent.com/cgraf78/shdeps/main/install.sh | bash

This installs the latest release archive to ~/.local/share/shdeps and symlinks the Rust CLI into ~/.local/bin/shdeps. Re-running the installer is idempotent. Fresh bootstrap expects release assets; if they are unavailable, the installer fails instead of silently cloning and building a managed source checkout. Developer/source-checkout installs are still supported when you run install.sh from a git checkout or set SHDEPS_REPO explicitly.

The installer verifies the release archive's SHA-256 checksum. For manual provenance verification against the source commit and shared release signer, follow the attestation verification guide.

~/.local/bin must be on your PATH. The installer prints a hint when it isn't; add export PATH="$HOME/.local/bin:$PATH" to your shell startup file.

Then create a config and run:

mkdir -p ~/.config/shdeps
cat > ~/.config/shdeps/deps.conf << 'EOF'
jq    pkg
fzf   pkg
EOF

shdeps update

The CLI and the Bash API (source shdeps.sh) load all *.conf files (sorted alphabetically) from $SHDEPS_CONF_DIR, defaulting to ${XDG_CONFIG_HOME:-~/.config}/shdeps/. Split deps across multiple files for organization (e.g., 00-core.conf, 50-tools.conf, 99-local.conf).

Updating shdeps

shdeps self-update

Release installs update from the latest matching archive, while source checkouts use git pull --ff-only and skip updates when the working tree has uncommitted changes (active development). shdeps update also performs a quiet, best-effort self-check for supported shdeps installs before dependency updates. That automatic check is TTL-gated by SHDEPS_SELF_UPDATE_TTL; use --force or SHDEPS_FORCE=1 with shdeps update to bypass the self-update TTL.

Uninstalling

curl -fsSL https://raw.githubusercontent.com/cgraf78/shdeps/main/install.sh | bash -s -- --uninstall

Or manually: rm -rf ~/.local/share/shdeps && rm -f ~/.local/bin/shdeps ~/.local/lib/shdeps.

How shdeps Compares

shdeps occupies a deliberately narrow niche: "the set of CLI tools I want on every box I touch, installed via whatever native means is best on that box, and sourceable from my shell init." It is not a polyglot runtime version manager and does not try to be one. If you're choosing between shdeps and one of the established tools below, this section should help.

Tool Primary purpose Per-project versions Cross-OS package abstraction Sourceable from shell init
shdeps One CLI install list across all machines No Yes (brew/apt/dnf/pacman/zypper/apk + GitHub) Yes (source shdeps.sh exposes shdeps_* Bash API)
mise Polyglot language-runtime + tool version manager Yes (per directory) Mostly via plugins Yes (shell activation)
asdf Polyglot language-runtime version manager (asdf protocol; mise is the modern asdf-compatible alternative) Yes Via plugins Yes (shell activation)
aqua Declarative GitHub Releases-focused CLI installer Yes (per directory) No (downloads release archives) Yes (shell activation)
chezmoi Dotfile manager (with external-binary support) No No (basic external download) N/A (manages dotfiles, not shell API)
Homebrew Native package manager (macOS-first) No macOS-only (Linuxbrew variant) No (only PATH)
Nix / Home Manager Hermetic, reproducible package manager Implicit (per generation) Yes, hermetic Yes (after substantial setup)

Pick shdeps when

  • You want one config file that says "install jq, fzf, ripgrep, neovim everywhere" and shdeps figures out whether that means brew, apt, a GitHub release archive, or a Cargo crate on each host.
  • You write Bash dotfiles or hooks and want source shdeps.sh to give you shdeps_update, shdeps_dep_source, shdeps_platform, shdeps_pkg_mgr, etc. — so your .bashrc can both install and use tools without forking shells.
  • You're comfortable with "latest stable" semantics. shdeps does not pin tool versions, and that is a deliberate design choice — pinning would conflict with pkg-installed tools whose version is the distro's call.
  • You want custom escape hatches: any tool that doesn't fit pkg/github*/cargo/go/uv/npm gets a custom method with a 5-function Bash hook (exists/version/install/post/uninstall) and shdeps treats it as a first-class entry.

Pick something else when

  • You need per-project tool versions (Node 18 in repo A, Node 20 in repo B). Use mise or asdf — that's their job.
  • You want hermetic, byte-reproducible installs across machines. Use Nix.
  • You want a GitHub Releases-first registry with curated install recipes shared across users. Use aqua.
  • Your dotfile workflow is dominated by templating, encryption, or multi-host file rewriting, and tool install is incidental. Use chezmoi (and call shdeps update from a chezmoi script if you still want the shdeps install model).

Coexistence

shdeps does not own $PATH or shadow other tools. It is designed to run alongside mise, Homebrew, or asdf. Each entry in your config explicitly names its install method, so shdeps only touches what you told it to manage; everything else stays under whichever tool owns it. The pkg method defers to the system package manager and skips entries that are already present, so it never re-installs what your distro (or Homebrew, etc.) already provides.

Configuration

Config File Format

# name    method    [cmd]  [aliases]  [filter]
Field Required Description
name yes Dependency name (used for hooks, logging, tracking). For github, github:repo, and github:release: GitHub owner/repo. For go: full module path.
method yes Install method: pkg, github, github:repo, github:release, cargo, go, uv, npm, or custom
cmd no Command to check for existence (defaults to name). Supports mgr:name and higher-priority android:name qualifiers (e.g., android:fd,apt:fdfind).
aliases no For pkg: per-manager package overrides (apt:fd-find,dnf:fd-find). android:name takes priority on Android. Use NONE to skip a runtime.
filter no Platform, hostname, package-manager, and effective-account filter. Use hosts=nas,laptop;users=root,backup for host/account lists. Legacy os:, host:, mgr:, and repeated users= tokens remain supported. Android matches both os:linux and mgr:android.

Use - for fields you want to skip. See examples/deps.conf for a full example.

For host and account lists, separate filter keys with ; and names within each list with ,: hosts=nas,laptop;users=root,backup. Quote filters containing ; when passing them as shell arguments. hosts= is case-insensitive; users= is case-sensitive. A name prefixed with ! is excluded. Values within one dimension are alternatives, while different dimensions must all match. An empty or malformed host/account list blocks installation; that list alone does not authorize prune. Unknown clauses in the semicolon form also block installation and pruning. The existing comma-separated, repeated-prefix form remains supported: host:nas,host:laptop,users=root,users=backup. With this older form, each value needs its prefix; host:nas,laptop never selected laptop.

The older os: and mgr: filters continue to use repeated comma-separated tokens. For example, os:linux,mgr:apt,mgr:dnf means Linux with either APT or DNF. They can be combined with plural lists using ;, as in os:linux,mgr:apt;hosts=nas,laptop;users=root. A filter containing only package-manager exclusions matches unknown managers, so mgr:!brew,mgr:!pacman is a useful fallback route. Filtered declarations may share one dependency name; Shdeps selects the last matching declaration before applying its normal last-wins rule, preserving one manifest identity across provider transitions.

Use users=root to limit a dependency to the root account, or users=!root to exclude it. Shdeps resolves the process's effective UID through the OS account database; USER and LOGNAME do not affect this filter. If the effective account cannot be resolved, a users= dependency is skipped and its installed files are retained by prune until the identity can be checked.

Treat name as a stable manifest identity. To rename a dependency while reusing its command, first remove the old declaration, run shdeps prune, then add the replacement declaration. Shdeps rejects an unrecorded active replacement that conflicts with a differently named manifest row so the old command cannot be mistaken for proof that the new provider is installed. An already-recorded active owner may coexist with a configured-but-inactive platform provider or a prior command spelling; normal method checks revalidate that recorded owner and same-name updates refresh stale rows.

Environment Variables

Variable Default Description
SHDEPS_CONF_DIR ${XDG_CONFIG_HOME:-~/.config}/shdeps/ Config directory (all *.conf files loaded)
SHDEPS_HOOKS_DIR <conf_dir>/hooks.d Post-install hooks directory
SHDEPS_STATE_DIR ${XDG_STATE_HOME:-$HOME/.local/state}/shdeps Cache/state directory
SHDEPS_FORCE 0 Bypass TTL cache (check for updates now)
SHDEPS_BOOTSTRAP_FORCE 0 Force provider freshness during install.sh --bootstrap without forcing the subsequent dependency update
SHDEPS_REINSTALL 0 Force reinstall all deps
SHDEPS_QUIET 0 Suppress non-result output and interactive prompts
SHDEPS_REMOTE_TTL 3600 Cache TTL in seconds
SHDEPS_SELF_UPDATE_TTL 3600 Self-update attempt TTL in seconds for shdeps update
SHDEPS_GIT_DEV_DIR ~/git Dev clone directory used only by github:repo deps (prefers <dir>/<repo> over a managed clone)
SHDEPS_INSTALL_DIR ~/.local/share Base directory for shdeps-owned install roots (github:repo, archive-style github:release, cargo, go, uv, npm). Raw release binaries install into SHDEPS_BIN_DIR instead.
SHDEPS_BIN_DIR ~/.local/bin Directory for binary symlinks and raw github:release binaries
SHDEPS_LUA_DIR ~/.local/lib/shdeps Installer-owned stable link to the active Shdeps Lua API tree
SHDEPS_LOG_LEVEL 1 0=quiet, 1=normal, 2=verbose
SHDEPS_JOBS auto (nproc) Max concurrent probes and parallel non-package installs (github*, cargo, go, uv, npm). Explicit values win; 1 = sequential.
SHDEPS_STATE_LOCK_TIMEOUT_SECS 1800 Max seconds a mutating command waits for another live shdeps update/prune holder before failing with lock metadata.
SHDEPS_CHECKOUT_LOCK_TIMEOUT_SECS 1800 Max seconds a github:repo mutation waits for the shared installer/Shdeps checkout lock. Values use the strict v1 grammar: a nonnegative decimal integer of at most 9 digits.

Install Methods

pkg — System Packages

Installs via the detected package manager (brew, apt, dnf, pacman, zypper, or apk). Packages are batched into a single install command for speed. On Android, Termux's APT prefix is owned by the app user, so shdeps runs apt-get directly instead of requiring sudo.

On first adoption, and whenever the manager, package alias, or command changes, Shdeps verifies package-manager ownership even if the command already exists. It records that proof so later healthy updates retain the command-only fast path.

jq        pkg
bat       pkg    apt:batcat
fd        pkg    apt:fdfind       apt:fd-find,dnf:fd-find
go        pkg    -                android:golang,apt:golang-go,dnf:golang
dust      pkg    -                -                        os:macos
uv        pkg    -                -                        os:android
htop      pkg    -                -                        hosts=nas,laptop
backup    custom -                -                        hosts=nas,laptop;users=root

Use aliases to map names across package managers. An android: override wins over the underlying manager override, which lets Termux differ from Debian/Ubuntu even though both use APT. Use NONE to skip a dep on a specific manager or on Android. os:android targets Android specifically; Android also continues to match os:linux so existing Linux-inclusive configs remain valid. Use hosts= to limit deps to specific machines and users= to limit them to effective accounts.

github — Automatic GitHub Install

Resolves to an existing concrete GitHub method before install, status, prune, and method-transition logic runs. shdeps prefers github:release when the latest stable release has a compatible asset for the current host and requested command. If no compatible release asset is available on a first installation, it falls back to github:repo. Once the matching command is recorded as github:release, missing assets or unavailable metadata preserve that release ownership. Publishing a release and uploading its assets are separate upstream operations; a temporary asset gap must not silently switch installers. Use explicit github:repo to request a provider change.

cgraf78/ds    github

The manifest still records only the resolved concrete method (github:release or github:repo), and shdeps list shows the same resolved method. This keeps installed-state cleanup compatible with explicit methods and avoids turning bare github into a third artifact ownership model.

Local development clones in $SHDEPS_GIT_DEV_DIR/<repo> are considered only when github resolves to github:repo. A compatible release asset wins over a local clone; use explicit github:repo when live-checkout behavior is required. For stale or forced checks, a working release-backed install remains github:release when GitHub's public /releases/latest redirect reports the same tag. This avoids both the REST API quota and a gh auth token probe; a new tag or unusable local install still triggers authoritative asset and method selection through the API.

If the latest release has no compatible asset, an owned executable that passes a version probe is retained with a nonfatal warning naming the pending update. Neither release nor resolver freshness is cached for that incomplete check, so the next ordinary update checks for newly uploaded assets. Missing or unusable owned commands and explicit --reinstall still fail when no asset is available; --force checks immediately without granting permission to change providers. Shdeps continues to target the latest stable release and does not install an arbitrary older release as a fallback.

github:repo — GitHub Repos

Clones a GitHub repo into $SHDEPS_INSTALL_DIR/<owner>/<repo> (default ~/.local/share/<owner>/<repo>). Prefers local dev clones in $SHDEPS_GIT_DEV_DIR/<repo> (default ~/git/<repo>, symlinked for live development). Falls back to a shallow clone for fresh installs. Every executable directly under the repo's bin/ directory is symlinked into $SHDEPS_BIN_DIR; existing regular files in $SHDEPS_BIN_DIR are preserved so shdeps never overwrites a user-owned command.

Local clone discovery is intentionally limited to github:repo. Other methods, including github:release, ignore $SHDEPS_GIT_DEV_DIR so changing a dep's method cleanly changes ownership and install behavior instead of accidentally continuing to use a live checkout.

If the canonical managed root already contains an unrecorded checkout, shdeps preserves it unless it can prove safe ownership before any transition or live repository mutation. The adoption gate reads local Git control files only as inert data, independently fetches the configured default branch into a private environment, and requires the candidate branch, index, complete worktree, modes, and bytes to match that fetched revision. Normal GitHub URLs use the same canonical owner/repository identity as development checkouts. An explicit non-GitHub SHDEPS_<NAME>_REPO override instead requires the candidate origin to match that override byte-for-byte and admits only an isolated HTTPS, SSH, or absolute-path/file-URL transport. Repositories that publish a command directly under bin/ require an explicit command column for adoption, and the configured command must be a tracked executable regular file rather than a symlink. Foreign, dirty, malformed, or unsupported roots remain untouched and are reported as install failures; a fresh TTL never bypasses this proof. Private-repository verification retries GitHub SSH without inheriting user or system SSH configuration. The noninteractive retry requires a trusted ~/.ssh/known_hosts entry for GitHub and may reuse only a validated external SSH_AUTH_SOCK; it never accepts a new host key during adoption.

A selected $SHDEPS_GIT_DEV_DIR/<repo> checkout uses a separate development trust rule rather than the exact-revision adoption proof. The selected path must itself be the checkout root. For normal GitHub URLs, both the raw and Git-effective origin must identify the configured repository across the supported HTTPS/SSH spellings. For another explicit SHDEPS_<NAME>_REPO override, both values must exactly match that override. An explicitly configured command must already be a tracked 100755 regular file at HEAD whose live path remains a regular executable. Staged or unstaged edits to already tracked development content are allowed and served live; this check grants no claim that the checkout matches its remote. The accepted GitHub HTTPS, ssh://, and scp-style identity spellings are locked to the Actions-owned tests/fixtures/github-repo-identity-v1.tsv vectors so bootstrap and steady-state ownership checks cannot drift independently.

On a stale or forced check, shdeps updates a clean local clone with git pull --ff-only. If that pull fails, shdeps leaves the user-owned clone untouched and emits a non-fatal warning naming the dependency and the checkout state it could observe. The installed links keep working, but may serve stale code until the clone's branch divergence or connectivity problem is resolved. This warning is also emitted in JSONL progress so parent tools such as dotfiles managers cannot silently present the checkout as current.

A managed (non-development) clone is refreshed with git fetch followed by git merge --ff-only @{upstream}, so a failure names its real cause: fetch failed: <Git's first error line>, upstream branch deleted: <Git's line>, diverged from origin, dirty working tree, or fast-forward failed: <Git's error line>. A deleted upstream branch is told apart from a network failure by listing the tracked branch on origin (bounded, off the terminal) after the fetch failed on every transport; it needs a new clone, which picks up the repository's current default branch. The failure stays a non-fatal warning and is recorded in $SHDEPS_STATE_DIR/<owner>/<repo>.repo.pull-failure until a refresh succeeds, which lets shdeps health report a checkout that has been stuck for a day. When the fetch fails and origin is the configured GitHub HTTPS URL or its SSH form, shdeps retries once over the other transport and keeps that origin only if the retry fetched; otherwise origin is restored. The retry runs detached from the terminal, so it fails instead of prompting. Explicit SSH or non-GitHub SHDEPS_<NAME>_REPO origins are never rewritten.

Repository-root publication and replacement uses a private same-parent recovery journal while the shared checkout lock is held whenever an owned managed directory and a development symlink change places. The same recovery also covers replacement of an owned managed checkout. A later Shdeps run rolls an interrupted move back or finishes it from the exact recorded inode (a reboot that renumbers the filesystem's device does not invalidate the record); if the generated checkout installer filled the temporarily vacant path with a new managed checkout, that new generation wins and Shdeps retires only its own backup. Publishing into an absent/unowned root and restoring a parked backup use atomic no-replace renames, so a late destination is preserved rather than overwritten. Android never calls renameat2 (older app seccomp policies may kill the process on it), and kernels or filesystems that decline the atomic form fall back too: there Shdeps refuses a destination that already exists and then renames, so only an entry an unrelated process creates in that instant could be replaced and lost. Raw-command method transitions, which need an atomic two-path exchange, are refused on Android before anything is installed. Installer-owned .install.transaction state is not reimplemented by Shdeps: it fails closed with guidance to rerun the installer that owns that format.

Public command relinking is desired-first. Shdeps inventories the complete repo bin/ directory, records the exact intended link/target pairs before the first publication, publishes usable desired links, atomically records a superset recovery ledger, and only then removes stale tracked symlinks before writing final ownership. A stale link is removed only while it still points into the dependency's own install, so a command another dependency has since taken over keeps working. It never removes a previously live desired command before its replacement is published. After a crash, cleanup can claim every exact desired link that became live, while a later update publishes any remaining desired commands and converges the ledger. A regular file at a tracked or desired command path is preserved byte-for-byte and is omitted from the final Shdeps ownership state. This no-clobber rule is serialized across Shdeps runs; as with the existing extras linker, an unrelated process racing to replace the same path between a no-follow check and atomic rename/unlink is outside that cooperative locking contract.

cgraf78/ds    github:repo

The owner/repo is the name field. Override the repo URL with SHDEPS_<NAME>_REPO env vars.

github:release — GitHub Release Binaries

Downloads the latest release binary from GitHub, matching the current OS and architecture. Handles tarballs, zips, compressed singles (.gz, .bz2, .zst), and raw binaries. Archive-style releases install into a shdeps-owned root under $SHDEPS_INSTALL_DIR/<owner>/<repo> so bundled assets can be linked; their public command preserves an existing launcher, either a regular file or a symlink shdeps did not create (for example a dotfiles overlay linking a tracked script into $SHDEPS_BIN_DIR, even before the first install). A symlink is shdeps' own only when its own target, not its final resolution, lies inside this dependency's root (including a dangling link left behind by a removed root) or inside the root of another dependency the manifest still records (a renamed dependency or a command moved from another method); any other link is left in place by installs, updates, --reinstall, and prune (a later method change away from github:release hands the command to the new method, which replaces symlinks as every other method does), and once a release is recorded, freshness checks probe the payload in the marked root (or the standalone installer's adoptable root) instead of whatever the launcher runs. Raw and compressed single-binary releases install directly to $SHDEPS_BIN_DIR/<cmd> and preserve the historical Bash replacement behavior. If an upstream changes between archive and single-binary packaging, shdeps fails closed and requires a manual layout migration because the two layouts own different paths.

Hosts bootstrapped by a project's standalone install.sh from the shared cgraf78/actions release installer are adopted automatically. With default directories that installer publishes ${XDG_DATA_HOME:-~/.local/share}/cgraf78/<repo> as a link to .<repo>-standalone/current, which is the root shdeps owns for a cgraf78/<repo> archive release. On its next update, even when the installed release is already current, shdeps reinstalls the selected release into its own marked root in place of that link, so dependency hooks such as dot's post run, but only after verifying it is the installer's own layout: its ownership marker, exact relative links that stay inside its private directory, release metadata naming this repository, real directories and files owned by you, and no installer lock (a lock left by a killed installer blocks the update with a message naming it). A launcher in front of that root, whether a regular file or a symlink shdeps did not create, is preserved through the adoption. Where the filesystem can swap two paths atomically (Linux renameat2, macOS renamex_np where the volume supports it) the root never disappears during the switch. Elsewhere, including Android/Termux where the syscall is never attempted and filesystems that decline it (NFS, WSL1 drvfs, older ZFS), shdeps first points a public command symlink straight at the binary it runs, then parks the root link under a fixed name, so an interrupted switch is recognized and finished by the next archive install; a regular launcher in front of the root (such as dot's client-launcher.sh), or a symlink launcher whose chain runs through it, depends on the root for the instant between those two renames, so a crash exactly there leaves it failing until shdeps update runs again. A transient download failure during adoption keeps the working command and retries on the next run. The installer's private .<repo>-standalone directory is left in place, because a running old binary may still use it; once the public command points into the new root it is inert and safe to delete. A raw single-binary asset over that layout is refused like any other archive-to-raw format change.

neovim/neovim    github:release    nvim
mvdan/sh         github:release

The owner/repo is the name field.

Forced and stale checks for an installed release use GitHub's public /releases/latest redirect before the GitHub REST API. If its tag matches the installed command version, the dependency is current without consuming the unauthenticated API's per-IP quota or probing gh auth token. Fresh installs and changed releases still use the API because shdeps needs authoritative asset metadata; set GH_TOKEN for those checks when operating a large fleet or using private repositories.

An upstream release with no host-compatible asset keeps an already-owned, usable release command with an update-pending warning and retries on the next update. Fresh installations and --reinstall require a compatible asset and fail if it is unavailable. Download and checksum failures remain errors.

A release metadata check that fails (an exhausted API quota or a GitHub outage) keeps a usable installed command and retries on the next update, but reports it as a warning, not as current: the installed release may be out of date. A missing command and --reinstall still fail. Hosts sharing one egress IP share the unauthenticated quota, so set GH_TOKEN for unattended fleet updates.

cargo — Rust Crates

Installs crates from crates.io via cargo install --locked --root "$SHDEPS_INSTALL_DIR/<name>" <name>. Binaries land in a per-dep install directory and are symlinked into $SHDEPS_BIN_DIR.

ripgrep   cargo    rg
tokei     cargo

The name field is the crate name. Override cmd when the crate installs a binary with a different name (e.g., the ripgrep crate installs rg). --reinstall passes --force to cargo.

Requires cargo on $PATH. If it is absent and no active pkg dep has cargo as its command, shdeps update exits with status 1 before installing any dependency (the TTL-gated shdeps self-update check may already have run).

go — Go Modules

Installs Go binaries via GOBIN=... go install <module>@latest. Supports any module host (github.com, golang.org, k8s.io, private registries).

github.com/junegunn/fzf             go
golang.org/x/tools/cmd/goimports    go
github.com/charmbracelet/gum        go    gum

The name field is the full Go module path (including any cmd/... subpath). cmd defaults to the basename of the module path.

Requires go on $PATH. If it is absent and no active pkg dep has go as its command, shdeps update exits with status 1 before installing any dependency (the TTL-gated shdeps self-update check may already have run).

uv — Python CLI Tools

Installs Python CLI tools from PyPI via uv tool install. Each dep gets its own isolated venv under $SHDEPS_INSTALL_DIR/<name>/tools/ with the binary at $SHDEPS_INSTALL_DIR/<name>/bin/<cmd>, symlinked into $SHDEPS_BIN_DIR.

ruff      uv
black     uv
mypy      uv
poetry    uv

The name field is the PyPI package name. Override cmd when the package's executable name differs from the package name. --reinstall passes --force to uv tool install.

Requires uv on $PATH (install via pipx install uv, brew install uv, or Astral's installer). If it is absent and no active pkg dep has uv as its command, shdeps update exits with status 1 before installing any dependency (the TTL-gated shdeps self-update check may already have run).

npm — Node CLI Packages

Installs Node CLI packages from the npm registry via npm install -g --prefix "$SHDEPS_INSTALL_DIR/<name>" <name>. Binaries land under the per-dep install directory and are symlinked into $SHDEPS_BIN_DIR.

prettier     npm
typescript   npm   tsc

The name field is the npm package name. Override cmd when the package's executable name differs from the package name.

Requires npm on $PATH. If it is absent and no active pkg dep has npm as its command, shdeps update exits with status 1 before installing any dependency (the TTL-gated shdeps self-update check may already have run).

custom — Hook-Only

No built-in install logic. Entirely managed by a post-install hook file.

nerd-fonts    custom

Hooks

Place hook files in <hooks_dir>/<name>.sh. For methods whose name contains path segments (github:*, go), hooks nest accordingly — e.g., hooks.d/owner/repo.sh or hooks.d/github.com/owner/repo.sh. For custom deps, hooks define the full install lifecycle. For other methods, hooks provide optional post-install setup.

Custom dep hooks:

  • exists(name) — return 0 if installed (or not applicable), 1 if missing. Required for custom deps.
  • version(name) — print version string to stdout. Optional.
  • install(name) — perform the install unconditionally. shdeps only calls this when exists() returns 1 or --reinstall is used.
  • uninstall(name) — reverse what install() or post() created. Optional. Called by shdeps prune when removing an orphaned dep (any method). For custom deps, this is the only cleanup. For other methods, runs before the built-in cleanup. If it fails, prune keeps the dep tracked (and its files in place), exits 1, and retries on the next run. A hook that needs sudo while prune has no terminal is deferred instead: the dep stays tracked, prune prints one warning, and exits 0.
  • post(name) — optional post-install setup. A failed post() is retried on the next shdeps update.

Non-custom dep hooks (pkg, github, github:repo, github:release, cargo, go, uv, npm):

  • post(name) — runs after shdeps installs/updates the dep (symlinking, config, etc.). A failed post() is retried on the next shdeps update.

All public API functions are available to hook authors. See examples/hooks.d/example-hook.sh.

Man Pages & Completions

shdeps automatically discovers man pages and shell completions bundled inside github:repo installs and archive-style github:release installs, then symlinks them into standard XDG user-local directories. Tools like neovim, gum, ripgrep, fd, bat, and hyperfine ship these files but they're not discoverable without this linking. Raw single-binary releases and cargo, go, uv, and npm deps do not provide bundled extras through shdeps — generate completions from the tool itself in a post() hook (see examples/hooks.d/example-hook.sh).

What gets linked:

Type Target directory Auto-discovered by shell?
Man pages ~/.local/share/man/man<N>/ No
Bash completions ~/.local/share/bash-completion/completions/ Yes
Zsh completions ~/.local/share/zsh/site-functions/ No
Fish completions ~/.local/share/fish/vendor_completions.d/ Yes

Required shell config (bash and fish need nothing):

# Man pages — add to your shell env
export MANPATH="$HOME/.local/share/man:$MANPATH"

# Zsh completions — add before compinit
fpath=("$HOME/.local/share/zsh/site-functions" $fpath)

Symlinks are tracked per-dep in $SHDEPS_STATE_DIR/<name>.links. Running shdeps prune removes symlinks along with the dep. Updates clean stale symlinks that still point into the dep's install before re-linking (a name another dep has since taken over is left alone), and retire tracked links left dangling by an install root that no longer exists. A custom hook that calls shdeps_link_extras should pass the same install dir on every run, since only stale links pointing into that dir are removed.

CLI Usage

Usage: shdeps [options] <command> [args]

Commands:
  update                 Install/update all dependencies
  self-update            Update shdeps itself
  list                   List all configured dependencies with status
  check <name>           Check if a specific dependency is installed
  dep-root <name>        Print a configured dependency root directory
  dep-path <name> <rel>  Print a path below a configured dependency root
  dep-file <name> <rel>  Print a readable regular file below a dependency root
  dep-links <name>       Print public command links owned by a dependency
  health                 Report problems with installed dependencies
  prune                  Remove orphaned deps no longer in config
  version                Print shdeps version
  help                   Show this help message

Options:
  -c, --config <path>   Config directory or file (default: $SHDEPS_CONF_DIR
                        or ${XDG_CONFIG_HOME:-~/.config}/shdeps/); a path that
                        is not a directory selects its parent
  -f, --force           Bypass TTL cache (check for updates now)
  -R, --reinstall       Force reinstall all dependencies (implies --force)
  -q, --quiet           Suppress non-result output and interactive prompts
  -v, --verbose         Verbose output (log level 2)

Prune options:
  -y                    Skip confirmation prompt
  --dry-run             Show what would be removed without removing

Examples:
  shdeps update
  shdeps -c ~/.config/myapp/ update
  shdeps --force update
  shdeps list
  shdeps check jq
  shdeps prune --dry-run
  shdeps prune -y
  shdeps health

Health output (stable): one problem per line, exactly five tab-separated
fields: <severity> <package> <kind> <path> <detail>
  severity is fail or warn; package and path are - when not applicable.
  New kinds may be added; ignore unknown kinds. Healthy prints nothing.
  Local reads only: no network, hooks, writes, or locks.

Exit codes:
  0  Success (health: no problems)
  1  Error (health: problems reported)
  2  Usage error
  3  health: report incomplete (state unreadable, or output not written)
  128+N  Interrupted by signal N after owned subprocess cleanup

Removing Dependencies

When you remove a dep from your config (or exclude it from the current host with an os: filter, a users= filter with a known effective account, or an mgr: filter once the package manager is detected), shdeps update will notify you that it's orphaned. Run shdeps prune to clean up the artifacts:

# Remove a dep from config, then update
shdeps update
# ==> 1 orphaned dep(s) (removed from config but still installed):
#   neovim/neovim (github:release)
#   Run: shdeps prune

shdeps prune           # interactive confirmation
shdeps prune -y        # skip confirmation
shdeps prune --dry-run # preview without removing

For pkg deps, prune warns that manual removal is needed (system packages may be shared). For custom deps, prune calls the optional uninstall() hook function.

Health Checks

shdeps health reports problems with installed dependencies for health dashboards such as dot doctor. It reads only local config and state, lstats the paths they name, and looks pkg commands up on PATH: no network, hooks, package-manager queries, state writes, or locks, so it is safe beside a running shdeps update and takes milliseconds. It honors -c/SHDEPS_CONF_DIR and the other root variables like every command; global options go before the command (shdeps -c DIR health).

The output is a stable machine contract. A healthy host prints nothing; otherwise each problem is one line of exactly five tab-separated fields:

<severity>\t<package>\t<kind>\t<path>\t<detail>
  • severity is fail or warn (a closed set), fixed per kind. fail marks states the next shdeps update cannot repair by itself.
  • package is the dependency name, or - for state no single dependency owns.
  • path is the affected path, or -.
  • detail is one line ending in a remediation hint. Tabs and newlines inside fields are replaced with spaces, so every row has exactly five fields.
  • New kinds may be added in later releases; consumers should ignore kinds they do not know. Columns are never added.
Kind Severity Meaning
missing-binlink warn An expected public command link (as shdeps dep-links reports it) is absent
dangling-binlink warn A public command link points at a missing path
wrong-target warn A public command link resolves somewhere other than its expected target
not-executable fail A public command resolves to a non-executable file
dangling-link warn A tracked man page or completion link (<name>.links) dangles
not-installed warn A configured github*, cargo, go, uv, or npm dependency has no recorded install, or a pkg dependency's command is not on PATH (see below)
install-root-unmanaged fail shdeps update would refuse to upgrade a github:release root (symlinked, unmarked, missing behind a public link other than a client's symlink launcher, or corrupt marker)
archive-backup warn An interrupted archive update left a *.shdeps-archive-backup-* sibling
deferred-post warn A post() hook needed sudo without a terminal; run shdeps update from a terminal
deferred-uninstall warn An uninstall() hook needed sudo without a terminal; run shdeps prune from a terminal
pending-post warn A post() hook has not completed and will be retried
recovery-state warn An interrupted update or prune left recovery records the next run finishes by itself
stale-remote warn A github:repo checkout or github:release install has not refreshed for over a day (plus the remote TTL) while other dependencies did, or at least three consecutive recorded pull failures span that long; a checkout with a recorded pull failure counts from its first failure, so time asleep or offline before it does not count; the detail names the cause
blocked-transition fail An interrupted method change, command handoff, checkout publication, or checkout installer transaction that recovery refuses; shdeps update (or that checkout's update) fails until it is resolved as the detail says
temp-tree warn An interrupted clone or release staging tree (<root>.tmp.<pid>, .<root>.tmp.<pid>) that shdeps never removes
unreadable-state fail Config or state could not be read, so the report is incomplete

A regular executable file at a command path (a raw release binary, or a launcher a client deliberately placed there) is not a problem: shdeps preserves such files. The same holds for a working symlink launcher in front of an archive release. A dangling one is reported as dangling-binlink with a hint to repair or remove it, because shdeps update deliberately leaves it alone, and a non-executable one gets a hint to repair it rather than reinstall. Like a regular launcher, it is checked only when the archive links no helper commands, since health then inspects the tracked helper links instead. pending-post, recovery-state, archive-backup, stale-remote, and temp-tree are omitted while the recorded state-lock owner is still running, because that update or prune is creating and retiring them itself. stale-remote compares stamps with each other rather than with the clock, so a host that was asleep stays quiet; its threshold adds the SHDEPS_REMOTE_TTL seen by health, so set the same value for health as for update. blocked-transition is reported even while the state-lock owner runs, because that update fails on it too; only a checkout journal or installer transaction whose checkout lock has a provably live owner is left out as work in progress. For a refused handoff record or checkout journal, the detail is recovery's own reason without the record's path, which the path column already carries. Each pending record is classified the way the next update's recovery treats it. Two inputs are forecasts, since health never contacts GitHub or reads hook state the way update does: a bare github entry uses the cached resolver answer (or the installed method), and a custom target whose config still matches reads as retryable even if its hook changed. When the config cannot be read completely (reported as unreadable-state), a pending install is only a recovery-state warning: whether the next update retries or refuses it cannot be judged.

A pkg dependency is checked by looking up its command in SHDEPS_BIN_DIR and on the caller's PATH, as update does first; the package manager is never asked. Any executable with that name counts, whichever method provided it. A miss is reported only when the name is known to be a command: the last clean package scan (the package-check cache in the state dir, written for the same package manager and host) found it, or, with no such record, the config names it explicitly in the cmd field. A defaulted cmd may be no command at all (completion data, fonts, libraries), and git-* commands may live in Git's exec path, so those are not guessed missing without a record. Entries excluded on this host by a filter or a NONE package override are skipped, as are packages the last full package scan found unavailable from this manager (update skips those without failing; it records them in pkg-unavailable in the state dir). The check is skipped entirely when PATH is empty or no package manager is detected on it.

Exit status: 0 healthy, 1 problems reported, 3 the report is incomplete (some state could not be read, reported as unreadable-state alongside any other problems, or the report could not be written). 2 remains the usage-error status, which is also what a Shdeps release without health returns for the unknown command, so callers can treat 2 as "unsupported".

Bash API

Sourcing shdeps.sh requires Bash 4.3 or newer.

export SHDEPS_CONF_DIR="$HOME/.config/myapp"
source /path/to/shdeps.sh
shdeps_update

Bootstrapping (Client Integration)

For projects that embed shdeps (e.g., dotfiles managers), install.sh --bootstrap provides a single sourceable entry point that handles discovery, sourcing, and CLI symlink setup:

# Set your project's config before bootstrapping
export SHDEPS_CONF_DIR="$HOME/.config/myapp"
export SHDEPS_HOOKS_DIR="$HOME/.config/myapp/hooks.d"

# Source shdeps — finds it automatically, installs if missing
. ~/git/shdeps/install.sh --bootstrap ||
  . ~/.local/share/shdeps/install.sh --bootstrap ||
  { echo "shdeps not found"; return 1; }

# shdeps_update is now available
shdeps_update

The --bootstrap flag:

  • Finds shdeps.sh via $SHDEPS_LIB → $SHDEPS_GIT_DEV_DIR/shdeps/ → $SHDEPS_DIR/ → fresh install
  • Sources it into the caller (all shdeps_* functions become available)
  • Symlinks the CLI into $SHDEPS_BIN (default ~/.local/bin/shdeps)
  • Symlinks the Lua API tree into $SHDEPS_LUA_DIR (default ~/.local/lib/shdeps)
  • Keeps bootstrap fast by avoiding release freshness checks unless SHDEPS_FORCE=1 or SHDEPS_BOOTSTRAP_FORCE=1
  • Is idempotent — safe to call multiple times
  • Does not leak set -e into the caller's shell

Call shdeps_update after bootstrap to install dependencies and run the TTL-gated self-check.

dot, a dotfiles manager, uses --bootstrap this way for its optional Shdeps provider.

Public API

The sourceable Bash API defines the public shdeps_ functions and delegates behavior to the Rust binary through a small hidden bridge. This is the complete shell contract available to callers and hook authors, and it is intended to remain stable because Bash hook code depends on it. A Rust crate API also exists; this Bash section documents the shell-facing contract specifically.

Function Description
shdeps_update Install/update all dependencies
shdeps_self_update Update shdeps itself (latest release archive, or git pull --ff-only for source checkouts, skipping dirty trees); takes no arguments
shdeps_prune [-y] [--dry-run] Remove orphaned deps no longer in config
shdeps_load Parse config and return dep count
shdeps_version Print version string
shdeps_filter_match <spec> Check if the current platform, host, package manager, and effective account match a filter spec (e.g., os:linux,mgr:apt;hosts=nas,laptop;users=root)
shdeps_platform_match <spec> Check if current platform matches a spec (e.g., linux,macos, !wsl)
shdeps_host_match <spec> Check if current hostname matches a spec (e.g., nas,laptop, !workstation)
shdeps_platform Print normalized platform name (linux, macos, wsl)
shdeps_force Return 0 if force mode is active (TTL bypass)
shdeps_reinstall Return 0 if reinstall mode is active
shdeps_pkg_mgr Print detected package manager (brew, apt, dnf, pacman, zypper, apk, or empty)
shdeps_pkg_install <package> Install one package immediately via the detected package manager
shdeps_pkg_install_for_mgr <mgr:package>... Install the package spec matching the detected package manager
shdeps_install_dir Print base install directory ($SHDEPS_INSTALL_DIR, default ~/.local/share)
shdeps_git_dev_dir Print git dev clone directory ($SHDEPS_GIT_DEV_DIR, default ~/git)
shdeps_bin_dir Print binary symlink directory ($SHDEPS_BIN_DIR, default ~/.local/bin)
shdeps_dep_root <name> Print a configured dependency root when shdeps owns one
shdeps_dep_path <name> <rel> Print a path below a dependency root; rejects absolute and parent-traversal paths
shdeps_dep_file <name> <rel> Print a dependency asset path only when it is a readable regular file
shdeps_dep_links <name> Print shdeps-owned command links as command<TAB>public-path<TAB>target-path rows
shdeps_dep_source <name> <rel> Source an existing dependency asset into the current Bash process
shdeps_link_extras <name> <dir> Discover and symlink man pages and completions from an install dir
shdeps_unlink_extras <name> Remove all extras symlinks tracked for a dep
shdeps_github_release_install <name> <cmd> [repo] [bin-path] Install one GitHub release binary for <name> (default repo <name>, default path $SHDEPS_BIN_DIR/<cmd>)
shdeps_mark_changed <name> Mark a dep as changed during the current shdeps update so its post() hook runs (held while the dep is inactive on this host, dropped if it is also not installed or no config entry owns it); no-op outside an update
shdeps_skip <dep> [reason] Record a .skipped marker (with optional reason) under the dep's install dir
shdeps_skipped <dep> Return 0 if the dep is marked skipped
shdeps_skip_reason <dep> Print the recorded skip reason; returns 1 if not skipped
shdeps_unskip <dep> Remove the skip marker
shdeps_find_runtime [--path DIR]... [--reject SUBSTR] [--verify] <name>... For each name in order, print the first qualifying executable in the --path dirs, then $PATH; returns 1 if none
shdeps_write_wrapper [--env VAR=value]... <name> <interp> [args...] -- <payload> Write an executable $SHDEPS_BIN_DIR/<name> that execs <interp> <args...> <payload> "$@"; prints its path
shdeps_require_sudo Acquire sudo; returns 0 if root or sudo obtained
shdeps_curl <curl-args...> Run curl with bounded connect/stall handling and transient retries
shdeps_log Normal log line
shdeps_warn Warning line on stderr
shdeps_log_warn Alias of shdeps_warn
shdeps_log_ok Alias of shdeps_log (no extra styling)
shdeps_log_dim Alias of shdeps_log (no extra styling)
shdeps_log_header Alias of shdeps_log (no extra styling)

shdeps_require_sudo first checks root or cached credentials with sudo -n. The initial mutating hook attempt runs in a detached session with a dedicated process group. If it needs interactive authentication, the attached shdeps update or shdeps prune parent prompts and retries that hook once in a new process group while preserving the parent's session and controlling terminal. That lets sudo policies with terminal-scoped timestamps recognize the freshly cached credential without weakening timeout cleanup; hook stdin remains closed in both attempts. Quiet mode never prompts or retries, and an already current custom dependency never reaches the sudo helper. Without a controlling terminal (cron, systemd timers, CI, agent shells) shdeps update does not answer a hook's sudo request with sudo at all, since it could not read a password: a post() stays pending for the next update that can authenticate and is reported once as a warning, not a failure, while a custom install() fails. In quiet mode shdeps_require_sudo still just returns 1, so a hook can fall back to a sudo-free path; if the hook fails instead, a run without a terminal defers that post() the same way (the warning keeps any shdeps_warn detail, since the fallback may have been what failed). shdeps prune treats a sudo-needing uninstall() alike: without a terminal it runs no sudo, keeps the row for a later prune, and reports one warning without failing. Without a terminal that warning (and update's) is printed only when the set of deferred deps gains an entry, so cron is not mailed every run about the same state; shdeps prune --dry-run always lists deferred uninstalls. Because the retry restarts the hook function, hooks must call shdeps_require_sudo before making filesystem changes or starting other side effects.

Parent interfaces that consume SHDEPS_PROGRESS=jsonl and draw on the same terminal may set SHDEPS_PROGRESS_PROMPT_ACK to a private FIFO they have created. Callers MUST require the additive prompt-fifo-reader-before-event-v1 capability before using this handshake; older builds can emit the event before opening the FIFO. A capable Shdeps opens its nonblocking read end before flushing a prompt event. On that event, the parent must suspend or clear its live display, open the FIFO write-only/nonblocking, and write exactly ready\n. The exact token alone completes the acknowledgement, so the writer may stay open across prompts and closing is optional. ENXIO means no live Shdeps reader remains. Shdeps waits up to five seconds for the acknowledgement, then writes a visible status line to /dev/tty and starts sudo. If the variable is unset, JSONL output keeps its standalone behavior and does not wait for an acknowledgement. The FIFO capability is advertised only on Unix platforms, where this handshake is available.

shdeps_dep_root follows the same ownership rules as installation. For github:repo, it prefers $SHDEPS_GIT_DEV_DIR/<repo> when a local development clone exists, then falls back to $SHDEPS_INSTALL_DIR/<owner>/<repo>. For methods with per-dependency install directories (cargo, go, uv, npm, and archive-style github:release installs), it returns $SHDEPS_INSTALL_DIR/<name> when present. Raw single-binary github:release installs and package-manager deps do not have shdeps-owned roots.

shdeps_dep_links is for health checks and integrations that need to verify shdeps-owned public commands without duplicating shdeps' link rules. It is a cheap local lookup: no hooks, package-manager probes, GitHub calls, or version commands run. github:repo dependencies report every executable directly under the resolved dependency bin/ directory; single-binary methods report the configured command and manifest target.

Lua API

shdeps ships a Lua module at lua/shdeps.lua inside the shdeps root. Installer activation publishes that whole lua/ tree through the stable $SHDEPS_LUA_DIR link (default ~/.local/lib/shdeps). The link points at the active source, developer, or release tree and is retargeted before an obsolete install is removed. A real file or directory already at that path is preserved and activation fails instead of overwriting caller-owned state.

SHDEPS_LUA_DIR is intentionally separate from $SHDEPS_DIR: selecting a developer checkout removes a stale release install under $SHDEPS_DIR, while Lua hosts still need one durable entry point. Release staging treats lua/shdeps.lua and the lua/shdeps/ implementation modules as required public artifacts.

The Lua API is for runtime asset resolution from Lua hosts such as Neovim and WezTerm. It deliberately delegates to the shdeps CLI instead of parsing configuration itself, so install-root ownership, local development checkout preference, filters, and path validation remain single-sourced in the Rust implementation.

local shdeps = dofile(os.getenv("HOME") .. "/.local/lib/shdeps/shdeps.lua")
local api = shdeps.new({ home = os.getenv("HOME") })

local setup = api.dep_file("cgraf78/termnav", "lib/termnav/nvim/setup.lua")
local env = api.env()

For a complete host-neutral resolver that uses the provider-managed bootstrap, validated asset lookup, and child environment together, see examples/lua/resolve-asset.lua.

Lua hosts that start before shell init has normalized PATH should load the bootstrap helper from the stable provider-owned tree. The helper then locates the active API module and binary without making the host duplicate Shdeps' source-versus-release selection policy:

local bootstrap = dofile(os.getenv("HOME") .. "/.local/lib/shdeps/shdeps/bootstrap.lua")
local shdeps = bootstrap.load({ home = os.getenv("HOME") })
local api = shdeps.new({ home = os.getenv("HOME") })

shdeps.new(options) accepts:

Option Description
home Home directory to use for child commands; also defaults SHDEPS_CONF_DIR to <home>/.config/shdeps
conf_dir Explicit shdeps config directory for child commands
bin Explicit shdeps binary path; useful for tests and embedded launchers
bin_dir Directory containing the shdeps binary
root Explicit shdeps root containing shdeps, shdeps.sh, and lua/shdeps.lua
env Table of extra environment overrides copied into child commands

The returned object exposes:

Function Description
api.dep_root(name) Return a configured dependency root, or nil when shdeps cannot resolve one
api.dep_path(name, rel) Return a validated path below a dependency root, or nil
api.dep_file(name, rel) Return a readable regular file below a dependency root, or nil
api.env() Return a fresh table of environment overrides for shdeps-aware child tools

The module also exposes default-object helpers with the same names: shdeps.dep_root, shdeps.dep_path, shdeps.dep_file, and shdeps.env.

api.env() includes the selected HOME, SHDEPS_CONF_DIR, and a PATH prefixed with the selected shdeps binary directory when the binary is known by absolute path. That keeps dependency-owned child tools that shell out to shdeps aligned with the same install that Lua used for resolution.

lua/shdeps/bootstrap.lua exposes:

Function Description
bootstrap.load(options) Locate and return the shdeps Lua API module
bootstrap.new(options) Locate the Lua API module and return shdeps.new(options)
bootstrap.paths(options) Return candidate lua/shdeps.lua paths in the order they will be checked

bootstrap.load(options) checks options.lua, $SHDEPS_LUA, options.root, $SHDEPS_LIB's sibling root, $SHDEPS_GIT_DEV_DIR/shdeps, <home>/git/shdeps, $SHDEPS_DIR, <home>/.local/share/shdeps, and finally the shdeps.lua module beside the bootstrap file's Lua tree. That final sibling lookup is what makes the provider-managed $SHDEPS_LUA_DIR link self-contained.

Rust API

shdeps builds a normal Rust library crate (shdeps) alongside the CLI. The binary, tests, and future Rust consumers call into documented modules such as shdeps::config, shdeps::runtime, shdeps::status, shdeps::update, and shdeps::prune.

The Rust API is intentionally documented in code because it owns the real implementation boundaries. Its stability promise is narrower than the CLI and Bash API while the Rust port is still landing: downstream Rust callers can use the crate, but module boundaries may still tighten before the first public crate release. shdeps does not currently promise a stable C ABI or libshdeps.a static archive.

Testing

cargo test --locked
cargo test --locked --test cli -- --ignored --test-threads=1
tests/shell/helpers-test
tests/shell/install-sh-test
tests/shell/install-interruption-test
tests/shell/completion-test
tests/shell/examples-test
tests/shell/installer-flow-test
tests/shell/lua-api-test
tests/shell/lua-bootstrap-test
tests/shell/release-scripts-test
SHDEPS_RUST_CLI=target/debug/shdeps tests/shell/shdeps-wrapper-test

The second Cargo command runs the four wall-clock performance budgets serially. They are ignored by the first command to avoid contention with parallel functional tests; CI requires both commands on every standard Rust matrix platform.

The standalone CLI is a Rust binary. The sourceable Bash API and hook prelude require Bash 4.3+; install.sh itself is kept compatible with the stock macOS Bash 3.2 installer path. A sourced bootstrap returns 125 when transaction cleanup cannot prove recovery state is safe, and 128+signal when HUP, INT, or TERM cancels it; callers must not downgrade those statuses to best effort.

The owned-subprocess-cancellation-v1 capability is additive to wrapper ABI 1. It is an acknowledgement contract: the standalone binary catches HUP, INT, QUIT, and TERM and returns 128+signal only after it has proved cleanup of its synchronously owned child trees. Commands needing the caller's terminal stay in its session but run in an owned process group, temporarily receiving the controlling terminal foreground when one is available. Their retained descendants are signaled without touching the caller's process group. An internal inherited boundary marker keeps ordinary descendants attributable if they change process group or session and their leader exits. A private inherited descriptor makes the common no-descendant exit observable without scanning the system process table. A process that deliberately strips the marker, closes the ownership descriptor, and daemonizes out of every owned topology is outside the synchronous subprocess contract. An open ownership descriptor vetoes an otherwise-empty cleanup proof, so a marker lookup failure cannot produce a false signal acknowledgement. Detached hooks and timed commands receive TERM, bounded KILL escalation, output draining, and complete attributed-lineage cleanup; adopted descendants are reaped directly on kernels that support subreapers. Linux and Android can also recover a direct adopted descendant that erased its marker and closed the ownership descriptor, but only while attribution to one active boundary is unambiguous. A process with those attributes is reparented away on macOS and cannot be discovered safely; marker-preserving escapes remain observable. Linux and Android runtime-probe pidfd operations for escaped-process authority. With exact pidfd authority, the initial cohort and later discoveries each receive a catchable signal once. The portable fallback sends one catchable signal to the retained group and uses the later group KILL for members created after that delivery. Other Unix platforms safely signal retained process groups but never send a raw signal to an unpinned escaped PID. For descendants still evidenced by a retained group, an open ownership descriptor, a matching marker, or an already-observed pinned identity, unavailable authority, delivery, reaping, draining, or verification produces diagnostics and status 1 instead of a false 128+signal acknowledgement. A descendant that removes every attribution channel and escapes all owned topology before its first observation is deliberately outside this contract and cannot be detected portably. A supervising caller retains the exact Shdeps PID and forwards cancellation to it while leaving Shdeps in the caller's session so parent-session sudo authentication remains usable.

Releasing

After main is clean, current, and green in CI, cut a release with:

scripts/release.sh --push

The script creates the UTC committer-timestamp/hash release tag and pushes it. GitHub Actions then builds and smokes the release artifacts. Archive names use:

shdeps-YYYYMMDD-HHMMSS-<8hex>-<asset-platform>.tar.gz
shdeps-YYYYMMDD-HHMMSS-<8hex>-<asset-platform>.tar.gz.sha256

Published platforms include musl Linux on x86_64/aarch64, Bionic Android on x86_64/aarch64 (including Termux), and macOS on Intel/Apple Silicon. The installer and self-update choose the Android artifact matching the runtime architecture before considering a generic Linux flavor.

CI cross-builds both Android archives and verifies their ELF identity plus the Bionic /system/bin/linker64 interpreter. The x86_64 build also runs inside the official Termux app on a hardware-accelerated emulator, covering the actual runtime while the AArch64 artifact retains structural cross-build coverage.

After publication, verify the archives and checksums using the shared attestation verification guide, which identifies the source and signing workflow at their release-time commits.

License

MIT

About

Declare CLI tools once; install them anywhere via brew, apt, dnf, pacman, GitHub releases, cargo

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages