Standard: update
How every CLI in the shll toolkit upgrades itself in place. Each tool exposes an update subcommand that swaps its own keg via Homebrew and runs whatever post-upgrade steps it owns; shll composes those by delegating to each installed tool’s update rather than calling brew upgrade itself — so no tool’s post-upgrade side effects are lost.
This page is the producer-facing standard: what your tool’s update must do. The consumer side — shll update’s probe-first ordering, the per-tool summary tail, and the post-upgrade release digest — is shll’s job and lives in its own memory. A tool author’s entire obligation is keeping update conformant to this page.
Scope is the six roster tools — wt, idea, tu, run-kit, hop, fab-kit. shll itself is out of producer scope: inside the delegation loop it self-upgrades with a direct brew upgrade sahil87/tap/shll rather than calling an update subcommand on itself — shll is the consumer here, delegating to the tools below.
This standard implements principle №7 of the toolkit CLI principles (compose, don’t reinvent — shll update delegates to your update instead of reaching into your keg), its brew-handling clause serves principle №6 (stateless, therefore retry-safe — a corrupted mid-swap keg is the antithesis of retry-safe), and its prompt-free clause tightens principle №1 (non-interactive by default) for this one subcommand.
Invocation contract
<tool> update is uniform across the six roster tools:
- MUST expose an
updatesubcommand that upgrades the tool in place and runs the tool’s own post-upgrade side effects (e.g. run-kit restarts its daemon). This is whyshll updatedelegates to you instead of runningbrew upgradedirectly — only yourupdateknows what has to happen after the binary is swapped. - MUST continue to work standalone.
shll updatecomposes per-toolupdate; it never deprecates the direct invocation.
Prompt-free, unconditionally
shll update delegates to each installed tool’s update with inherited stdio, in the user’s own terminal — so mid-compose, stdin typically is a TTY. That makes this clause deliberately stricter than principle №1 of the toolkit CLI principles: №1’s reconciliation blesses Proceed? [y/N] when a TTY is present (reference implementation: shll uninstall), but that reconciliation does not survive composition — a №1-conformant prompt stalls the delegation loop at tool k of 6. A watching user can answer it, but the composed run has silently acquired an interaction requirement it never advertised, and it blocks indefinitely whenever nobody is watching — a walked-away shll update, an agent-driven pane.
So:
- MUST run to completion without any interactive prompt, in every environment — including when stdin is a TTY. No confirmation question, no pager, no “press enter to continue”.
- The obligation covers wrapped subprocesses too. An
updatealmost always wrapsbrew— the wrapped call MUST be invoked non-interactively; a prompt surfacing from a subprocess stalls the compose exactly like one from your own code. - There is nothing to confirm. An in-place upgrade is not a destructive write in the №5 sense — invoking
updateis the consent. A tool that wants a guard can offer--dry-run, never a prompt.
Failure mode. An update that prompts only when a TTY is present is conformant to №1 and to every other rule on this page — yet it breaks the compose in both directions. On a real TTY with no human watching — an agent driving shll update in a tmux/run-kit pane — the prompt hangs invisibly until the harness times out. And when stdin is not a TTY, the tool refuses fast exactly as №1 requires — but shll update’s delegated argv is fixed (<tool> update [--skip-brew-update]; there is no way to thread --yes through), so the compose hard-fails with no recourse for the caller.
Advertise and honor --skip-brew-update
shll update runs brew update once for the whole toolkit, then delegates to each tool’s update. To avoid N redundant metadata refreshes, it appends --skip-brew-update to every tool whose update advertises support.
Two rules with teeth:
- Advertise the flag as a literal substring.
<tool> update --helpMUST contain the exact string--skip-brew-update. Discovery is a substring presence check on the help text (strings.Contains), never a regex — the flag string is a frozen textual contract, exactly like the help-dump JSON shape. Reword the help line so the literal substring disappears and everyshll updaterun silently degrades to N redundantbrew updates. - Honor it. When invoked with
--skip-brew-update, the tool MUST skip its own internalbrew updatemetadata refresh (shll has already done it once, run-wide). The flag is a promise, not a hint.
Exit codes
The exit code is the truth shll update’s summary tail reads for pass/fail; the post-upgrade digest (“what an upgrade brought”) is driven separately, off the brew-read version transition per tool, not off this exit code. So the exit code MUST mean exactly what it says:
- MUST exit
0on success, and success includes already-up-to-date — “nothing to upgrade” is not a failure. - MUST exit non-zero only on genuine failure — a real upgrade error, a broken keg, a failed post-upgrade step.
A tool that exits non-zero when there is simply nothing to do makes shll update report a false failure for the whole run.
Brew-handling safety
An update almost always wraps brew upgrade, and brew mutations are not interruptible without damage — a keg swap runs brew unlink then brew link, and a process killed between them leaves the tool half-installed (the observed symptom: zsh: permission denied: <tool>). So:
- MUST NOT send
SIGKILLto a package-manager subprocess mid-transaction.SIGKILLcannot be trapped, so brew gets no chance to finish or roll back the keg swap. - MUST NOT impose a short hard timeout on
brew upgrade. brew can legitimately block for minutes on the network. Observed 2026-07-19: a stalledapi.github.comcall insidebrew upgrade(Homebrew 6 makes an un-timed GitHub API call during every tap-formula upgrade) exceeded a wrapper’s 120-second hard kill; theSIGKILLlanded mid-swap, betweenunlinkandlink, and corrupted the keg — leaving a broken binary. - If any bound exists, it SHOULD be generous and terminate gracefully. Prefer
SIGTERMplus a grace period (letting brew unwind cleanly), neverSIGKILL; a bound sized for a network transfer, not a local command. A tool that must bound the call should also considerHOMEBREW_NO_GITHUB_API=1to sidestep the un-timed API call rather than reaching for a timeout at all.
Failure mode. A short hard timeout plus SIGKILL is “conformant” to every other rule on this page while silently corrupting installs on any machine with a slow network moment — the exact incident that motivated this clause.
Self-update only when brew-installed
A tool distributed via Homebrew SHOULD self-update through brew only when it was brew-installed, detected via os.Executable() symlink resolution — a resolved path containing /Cellar/ marks a brew keg (the hop / fab-kit convention). A non-brew install (a go install dev build, a hand-placed binary) is not brew’s to upgrade: degrade with a clear message (“not a brew install — upgrade it however you installed it”) instead of erroring or running a brew upgrade that would target the wrong or a missing formula.
Naming and release alignment
The update path is where a tool’s brew/formula identity is load-bearing, so the toolkit’s naming and release conventions live here:
- One name, four places. The GitHub repo name, the roster/tool name shll knows it by, the tap formula leaf (
sahil87/tap/<leaf>), and the binary name onPATHMUST all be the same string.shll updatecomposesbrew upgrade sahil87/tap/<formula>and delegates to the<tool>binary by that one name — a mismatch breaks the compose. v{semver}release tags. Releases MUST be taggedv{semver}(e.g.v1.4.2), matching the brew formula version.shll changelogand theshll updatepost-upgrade digest consume these tags to render “what an upgrade brought” — an off-convention tag drops out of the digest.- A rename MUST ship a
formula_renames.jsonentry. Renaming the tool (repo/formula/binary) MUST add a tapformula_renames.jsonentry so an installed old-name keg migrates on the nextbrew upgradeinstead of orphaning. Therk→run-kitrename is the precedent, and it carries an ongoing migration-guard cost in shll (a legacy-name alias and a dual-keg sweep) — so a rename is a deliberate, tap-coordinated act, not a casual one.
Verifying conformance
Before shipping a change that touches update:
<tool> updateexits0on success, including when already up to date; non-zero only on a genuine failure.<tool> updateruns to completion with no interactive prompt in any environment, TTY included — no code path reads stdin for a confirmation.<tool> update --helpoutput contains the literal substring--skip-brew-update, and passing the flag skips the tool’s internalbrew update.- No code path sends
SIGKILLtobrew, and no short hard timeout capsbrew upgrade(any bound is generous and terminates viaSIGTERM+ grace). - If the tool self-updates via brew, it gates on a
/Cellar/-resolved executable path and degrades with a clear message off-brew. - The repo name, tool name, tap formula leaf, and binary name are one string; releases are tagged
v{semver}; any rename ships aformula_renames.jsonentry.