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.
- Declarative config — one line per dependency in
*.conffiles - 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
*.conffiles in a config directory - Usable from CLI, Bash, Lua, or Rust — Rust
shdepsCLI, stable Bash functions viasource shdeps.sh, a Lua runtime asset API, and a documented Rust crate API
curl -fsSL https://raw.githubusercontent.com/cgraf78/shdeps/main/install.sh | bashThis 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 updateThe 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).
shdeps self-updateRelease 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.
curl -fsSL https://raw.githubusercontent.com/cgraf78/shdeps/main/install.sh | bash -s -- --uninstallOr manually: rm -rf ~/.local/share/shdeps && rm -f ~/.local/bin/shdeps ~/.local/lib/shdeps.
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) |
- You want one config file that says "install
jq,fzf,ripgrep,neovimeverywhere" and shdeps figures out whether that meansbrew,apt, a GitHub release archive, or a Cargo crate on each host. - You write Bash dotfiles or hooks and want
source shdeps.shto give youshdeps_update,shdeps_dep_source,shdeps_platform,shdeps_pkg_mgr, etc. — so your.bashrccan 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/npmgets acustommethod with a 5-function Bash hook (exists/version/install/post/uninstall) and shdeps treats it as a first-class entry.
- 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 updatefrom a chezmoi script if you still want the shdeps install model).
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.
# 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.
| 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. |
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.
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.
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.
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.
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).
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).
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).
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).
No built-in install logic. Entirely managed by a post-install hook file.
nerd-fonts custom
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 forcustomdeps.version(name)— print version string to stdout. Optional.install(name)— perform the install unconditionally. shdeps only calls this whenexists()returns 1 or--reinstallis used.uninstall(name)— reverse whatinstall()orpost()created. Optional. Called byshdeps prunewhen 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 failedpost()is retried on the nextshdeps 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 failedpost()is retried on the nextshdeps update.
All public API functions are available to hook authors. See examples/hooks.d/example-hook.sh.
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.
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
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 removingFor pkg deps, prune warns that manual removal is needed (system packages may be shared). For custom deps, prune calls the optional uninstall() hook function.
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>
severityisfailorwarn(a closed set), fixed per kind.failmarks states the nextshdeps updatecannot repair by itself.packageis the dependency name, or-for state no single dependency owns.pathis the affected path, or-.detailis 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".
Sourcing shdeps.sh requires Bash 4.3 or newer.
export SHDEPS_CONF_DIR="$HOME/.config/myapp"
source /path/to/shdeps.sh
shdeps_updateFor 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_updateThe --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=1orSHDEPS_BOOTSTRAP_FORCE=1 - Is idempotent — safe to call multiple times
- Does not leak
set -einto 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.
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.
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.
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.
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-testThe 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.
After main is clean, current, and green in CI, cut a release with:
scripts/release.sh --pushThe 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.
