A tiny per-project command publisher. spg reads a project's spg.toml and
exposes each declared command on your machine — either as a wrapper script in
~/bin or as a function defined in your interactive shell — with rich zsh tab
completion driven by the same declarations.
Think of it as a portable, per-project alternative to hand-rolled ~/bin
scripts and .zshrc aliases: the commands live with the project, version
controlled, and spg materializes them onto your $PATH (or into your shell).
Most projects accumulate a handful of commands you actually want on your
$PATH — a deploy script, a dev-server launcher, a cd-into-the-worktree
helper. spg lets a project declare those once, in spg.toml, and publish
them with a single spg install. The declarations also drive tab completion,
so mycmd <TAB> offers the right arguments without any extra wiring.
spg is a Python package managed with uv. It is
not on PyPI (the spg name there belongs to an unrelated project), so install
it straight from the repository:
uv tool install git+https://github.com/shr3kst3r/spg.git # install the CLI globally
# or, from a clone:
uv tool install . # install the CLI from your working copy
uv run spg --help # run without installingTo pick up new commits later, re-run the install with --force, or
uv tool upgrade spg.
Add completion (zsh) to your ~/.zshrc:
source <(spg completion zsh)This both enables tab completion and defines any shell_function commands
in your interactive shell. Make sure ~/bin is on your $PATH.
From a project you want to publish commands for:
spg init # write a starter spg.toml
$EDITOR spg.toml # declare your commands
spg install # write ~/bin/<cmd> wrappers and register the project
spg list # confirm what's registered
spg help # list every published command, grouped by project
spg help <cmd> # show one command's declared usage[project]
name = "my-project" # required; [A-Za-z_][A-Za-z0-9_.-]*
# Kind 1 — wrapper script (the default). Becomes ~/bin/deploy.
[commands.deploy]
run = "./scripts/deploy.sh" # arbitrary shell, run from the repo root
description = "Deploy the app"
args = [
{ name = "target", description = "environment", values = ["staging", "prod"] },
{ name = "config", description = "config file", type = "files" },
{ name = "--region", description = "AWS region", values = ["us-east-1", "eu-west-1"] },
{ name = "--dry-run", description = "no changes" }, # boolean flag
]
complete_hook = "./scripts/deploy.sh __complete" # optional dynamic completion
# Kind 2 — shell function (runs in the parent shell). For cd/export/etc.
[commands.gocd]
description = "cd into a resolved worktree"
shell_function = 'cd "$(./scripts/resolve.sh "$@")"'
# Symlinks — publish a path in this repo to somewhere on your machine.
[links.my-skill]
source = "skills/my-skill" # relative to the repo root
target = "~/.claude/skills/" # trailing slash: link *into* this directory,
# leaf name = the table name ("my-skill")
description = "Publish this repo's skill to Claude Code"
[links.rgconf]
source = "config/ripgreprc"
target = "~/.config/ripgrep/config" # no trailing slash: the exact link pathspg.toml can also bootstrap a machine. [installs.<name>] declares something
to install, requires orders items and keeps one from going ahead without
another, and [project].include splits a big config across files:
# spg.toml
[project]
name = "dotfiles"
include = ["spg.d/*.toml"] # paths or globs, relative to this directory
# spg.d/10-brew.toml
[installs.homebrew]
run = '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'
creates = "/opt/homebrew/bin/brew" # exists once installed
[installs.cli-tools]
brew = ["ripgrep", "fd", "jq"] # shorthand: generates run + check
cask = ["wezterm"]
requires = ["homebrew"]
[installs.uv]
run = "curl -LsSf https://astral.sh/uv/install.sh | sh"
check = "test -x ~/.local/bin/uv" # exits 0 once installed
# spg.d/20-zsh.toml
[installs.oh-my-zsh]
run = 'RUNZSH=no CHSH=no KEEP_ZSHRC=yes sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"'
creates = "~/.oh-my-zsh"
[links.zshrc]
source = "zsh/zshrc" # still relative to the project root
target = "~/.zshrc"
requires = ["oh-my-zsh"] # linked only once oh-my-zsh is in place- An install needs
runplus a probe:check(shell; exit 0 means already installed) orcreates(a path that exists once installed). spg runs the probe first and only runsrunif needed, so installs are safe on everyspg install/spg sync. Afterrun, the probe has to pass. If it still fails (usually because the tool landed somewhere not onPATH), the install counts as failed. brew = [...]/cask = [...]generate both for you. They findbreweven when a fresh Homebrew isn't onPATHyet.runandcheckexecute with the project root as CWD and your terminal attached.requireslists selectors (name,install:name,cmd:name,link:name) and works on installs, commands, and links. Items run in dependency order; withoutrequires, installs go first, then commands, then links. When an install fails, everything that requires it (directly or indirectly) is skipped and reported, and keeps whatever state it had before. The rest still installs, and the command exits 1. Cycles and unknown names are config errors.- spg never uninstalls software.
spg uninstallremoves wrappers and links only. - Included files may declare
[commands],[links], and[installs], but not[project]. A glob that matches nothing is fine. A literal path that doesn't exist is an error, and so is declaring the same item in two files.
Rules in brief:
- Exactly one of
runorshell_functionper command. runproduces a~/bin/<name>wrapper thatcds into the repo root and runssh -c '<run> "$@"'with your arguments. Use it for normal commands. Beforecding, the wrapper exports$SPG_INVOCATION_DIR— the directory you invoked the command from — so a command can act on where you are rather than where it's installed (e.g.some-tool "${SPG_INVOCATION_DIR:-.}").shell_functionproduces a function defined in your interactive shell (it cancd,export, set shell variables — things a subprocess can't). Available only in interactive zsh with the completion script sourced.argsdeclare positionals (no leading-) and flags (leading-/--).values= a closed set;type="files"or"directories". A flag with neither is boolean.complete_hooksupplies dynamic completion candidates (git branches, hostnames, etc.) when staticargscan't. It's invoked as<hook> <position> <words…>and prints onevalue[:description]per line; the sentinels__files__/__directories__request path completion.[links.<name>]symlinkssource(a path inside the repo) totarget. Use it to publish repo content that a tool expects to find in a fixed location — a Claude Code skill in~/.claude/skills/, a dotfile, an editor config.sourcemust be relative to the repo root and must exist;targetmust be absolute (~is expanded). Atargetending in/means "link into that directory" with<name>as the leaf; otherwisetargetis the link itself. Missing parent directories are created. Links are created, repaired, and removed byspg install/sync/uninstallalongside wrappers.
See prompts/add-spg-support.md for the full,
authoritative schema and an agent-ready guide to adding spg to any project.
| Command | What it does |
|---|---|
spg init |
Write a starter spg.toml in the current directory. |
spg install |
Register the project, run declared installs, write ~/bin wrappers, and create declared links. --skip-installs skips the installs. |
spg uninstall [name] |
Remove a project's wrappers, links, and registry entry. |
spg sync |
Re-read every registered project's spg.toml, run any missing installs, refresh wrappers and links, and prune orphaned ones. --skip-installs skips the installs. |
spg disable <sel>… |
Stop installing specific commands, links, or installs for this project (removes wrappers/links now; never uninstalls software). |
spg enable <sel>… |
Install previously disabled items again. |
spg list |
Show registered projects with their commands, links, and anything disabled. |
spg help [cmd] |
List every published command grouped by project, or show one command's declared usage. |
spg status |
Diagnose registry / ~/bin / link drift, and installs whose probe fails. |
spg completion zsh |
Print the zsh completion script. |
Everything a project declares is installed by default. If you don't want a
particular command or link on your machine, decline it — the choice is yours
alone and lives on your machine, not in the project's spg.toml:
spg install --without deploy --without link:my-skill # decline at install time
spg install -i # pick from a checklist
spg disable deploy # change your mind later
spg enable deploy # and change it backA selector names one declarable item: <name>, or cmd:<name> /
link:<name> / install:<name> when two kinds of item share a name. --without is repeatable
and mutually exclusive with -i; disable/enable take one or more selectors
and take effect immediately (the wrapper or symlink is removed or recreated).
Declining an install (--without install:homebrew) means you manage it
yourself: spg never runs it, and items that require it go ahead anyway.
Declined items are remembered, so spg sync won't put them back. spg list
shows them in a Disabled column, every install and sync prints a
⊘ disabled line, and spg status stays quiet about them — their absence is
expected, not drift. Declining is also the escape hatch when a project's command
name collides with something you already own in ~/bin.
If a project later stops declaring something you declined, the choice is kept in
case it comes back, and spg status mentions it as a note (never as a problem).
Run spg enable <sel> on it to forget the choice for good.
Note that spg uninstall forgets a project's declined items along with the rest
of its registry entry, so uninstall/reinstall is not a way to repair an install.
- The registry lives at
~/.config/spg/registry.toml(respectsXDG_CONFIG_HOME). It records each project's root, its commands, and the links it published, plus aversionmarking the file's format. - It also records the commands and links you declined for that project
(
excluded_commands/excluded_links/excluded_installs), which is why they are absent. Installs themselves are not recorded: each one's probe is the source of truth. Everyspg installandspg synchonors them, and only spg writes that file — you change what's declined with--without,-i,disable, andenable. - Wrappers are written to
~/bin/<cmd>. Each carries a# spg-managed:<project>:<command>marker sospgknows which files it owns — don't edit them by hand. Wrapper and registry writes are atomic, and the registry is updated under an exclusive file lock so concurrentspginvocations can't corrupt it. spg installrefuses to clobber a non-spg file or another project's command; pass--forceonly to overwrite a non-spg~/bin/<cmd>.- Links get the same treatment: spg only ever removes a path it recorded and
that is still a symlink, so it can't delete your files. A foreign symlink or
regular file at a link's path needs
--force; a real directory there is refused outright, even with--force. - Completion dispatches into a hidden
spg __completecommand at completion time, so registry/spg.tomledits show up in your next shell automatically.
This project self-hosts: its own commands are declared in spg.toml.
uv run pytest # run the test suite
uv run ty check # type-check src/
uv run spg --help # run the CLI from sourceSource lives in src/spg/; tests in tests/. See AGENTS.md for
the architecture map and contributor conventions.