#!/bin/sh # The fibers installer (ENG-560). One line in a host's setup script instead # of a hundred and fifty: this file is the only copy of the install logic, we # deploy it, and every host picks up a fix on its next boot. # # Served at https://fibers.dev/install.sh by src/pages/install.sh.ts beside # this file. The reviewable source is apps/marketing/src/install.sh in # github.com/assemblyuk/fibers — read it before you pipe it to a shell. # # In a sandbox's setup script or post-clone step: # # if [ -n "${FIBERS_API_TOKEN:-}" ]; then # curl -fsSL https://fibers.dev/install.sh | sh || echo "fibers: install failed" # fi # # In a claude.ai cloud environment's Setup script box, put a marker line above # it — `echo "fibers: install line v1"`. That environment snapshots a setup # script that succeeded and reuses the snapshot, re-running it only when the # script text or the allowed hosts change, and because this script exits 0 even # when it failed, a failed run is snapshotted too. Bumping the marker is how # you force a re-run. # # PUT IT FIRST in a setup field that has other steps. Nothing here can fail a # boot, but nothing protects it from an EARLIER step's failure either: under # `set -e` a failed tool install above it ends the whole script and everything # below is skipped in silence. Both of 2026-10-26's failures were that shape. # # WHAT IT DOES # 1. Installs the fibers CLI as ~/.local/bin/fibers, which every agent can # run. Claude Code puts a plugin's bin/ on PATH only inside its own # sessions, so the plugin alone does not serve Gemini, a cron job or a # plain shell — this is the part that makes an installer necessary. # 2. When `claude` is on PATH, hands the hooks and skills to Claude Code's # own plugin install, which checks the archive against the `sha256` in # the published manifest. Nothing here writes a settings file or merges # hook JSON: that wiring is the harness's job, not ours. # # RE-RUNNING IT IS THE UPDATE PATH on a host that lives longer than one boot: # it re-resolves the newest published version and replaces what it installed # before. It leaves a machine that has the plugin and none of its own files # completely alone. # # IT ALWAYS EXITS 0. A missing board is never worth a failed boot, so each # failure prints one line and the script succeeds anyway. Read the lines it # prints, or run `fibers doctor`, to find out what actually happened. # # ~/.local/bin HAS TO BE ON THE AGENT'S PATH. A piped shell cannot change its # parent's environment, so this script can only say so when it is not; the # host is what puts it there (in a Blocks sandbox it already is). # # NEEDS curl and node 20 or newer, and nothing else. # # THE ENVIRONMENT STILL NEEDS, separately from this script: # - `FIBERS_API_TOKEN`, the board credential, readable by the agent's own # session. Not necessarily by this script: a claude.ai cloud Setup script # runs before the environment's variables are set. # - these hosts allow-listed: fibers.dev, # charming-swordfish-323.eu-west-1.convex.cloud and # charming-swordfish-323.eu-west-1.convex.site. HTTP actions are served # from the .convex.site host while the client library talks to # .convex.cloud, so allowing one of the pair breaks every command. # # Everything runs inside main(), called on the last line: a download that # truncates leaves an unterminated function, so the shell runs nothing at all # rather than half an install. set -u say() { echo "fibers: $1"; } warn() { echo "fibers: $1" >&2; } # The newest published version, so a cold host tracks releases without anyone # editing the paste. Empty when the manifest could not be read or parsed. published_version() { curl -fsS --connect-timeout 5 --max-time 20 "$1/fibers-marketplace.json" 2>/dev/null | node -e 'let s="";process.stdin.on("data",(d)=>{s+=d}).on("end",()=>{try{const v=JSON.parse(s).plugins[0].version;if(/^[0-9]/.test(v))process.stdout.write(v)}catch{}})' 2>/dev/null || true } # A launcher that fetches the CLI on first use and says why when it cannot, # so a failed download never leaves a `fibers` on PATH that dies with # MODULE_NOT_FOUND. A cloud agent hit exactly that and reported the lesson: # PATH presence is not availability. Exit 127 is the shell's own "command not # found", which is what a caller testing for availability expects. # # The cache path carries the version, so the launcher can never run a version # other than the one baked into it. An unversioned path would: a re-run that # resolved a newer release and then failed to download it would leave the # previous release's file sitting there, non-empty, and the rewritten launcher # would keep running it without ever retrying. # # Written to a temp file and moved into place, never straight to the # destination: a redirection truncates the file before the write, so a write # that then failed would replace a working launcher with a partial script — # and `chmod` would succeed on it and hide that. Returns non-zero if anything # went wrong, leaving whatever was there untouched. # # The heredoc is unquoted so the URL and the cache path are baked in now; every # other `$` is escaped so it is evaluated when the launcher runs. write_launcher() { ltmp="$1.$$" cat >"$ltmp" <&2 exit 127 fi fi # Lets node's fetch traverse a sandbox proxy (HTTPS_PROXY). export NODE_USE_ENV_PROXY="\${NODE_USE_ENV_PROXY:-1}" exec node "\$lib" "\$@" LAUNCHER if [ ! -s "$ltmp" ]; then rm -f "$ltmp" return 1 fi if ! chmod +x "$ltmp" || ! mv "$ltmp" "$1"; then rm -f "$ltmp" return 1 fi } # Remove the hook commands scripts/cloud-setup-fibers-block.sh wrote, once the # plugin is there to provide its own. Without this, a warm sandbox that block # had provisioned runs both sets against the same events and posts every # observation twice: registration, stop and compaction each mint a fresh event # id, so nothing downstream can collapse the pair. # # Only the legacy commands go, recognised by the unversioned runtime path they # name plus `hook`. The plugin's own handlers run from the plugin and are not # in this file. Pruned per COMMAND rather than per entry, so a hook the sandbox # put beside ours survives; an entry left with no commands is dropped, since an # empty list runs nothing and only adds noise. A settings file that will not # parse is left untouched: overwriting it would cost the sandbox its own hooks. drop_legacy_hooks() { [ -f "$1" ] || return 0 node - "$1" <<'NODE' const fs = require("node:fs"); const [, , settingsPath] = process.argv; let settings; try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); } catch (error) { console.error( `fibers: ${settingsPath} is not readable JSON, so its hooks were left alone (${error.message})`, ); process.exit(0); } const isLegacy = (h) => typeof h?.command === "string" && /fibers-node\.mjs/.test(h.command) && /\bhook\b/.test(h.command); const hooks = settings.hooks ?? {}; let removed = 0; for (const event of Object.keys(hooks)) { hooks[event] = (hooks[event] ?? []) .map((entry) => { const before = entry?.hooks ?? []; const kept = before.filter((h) => !isLegacy(h)); removed += before.length - kept.length; return kept.length === before.length ? entry : { ...entry, hooks: kept }; }) .filter((entry) => (entry?.hooks ?? []).length > 0); if (hooks[event].length === 0) delete hooks[event]; } if (removed > 0) { settings.hooks = hooks; fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`); console.log( `fibers: removed ${removed} hook command(s) left by the old setup block, now the plugin provides its own`, ); } NODE } main() { origin="${FIBERS_RELEASES_URL:-https://fibers.dev}" origin="${origin%/}" fallback_version="0.10.0" bin_dir="$HOME/.local/bin" lib_dir="$HOME/.local/lib" cli="$bin_dir/fibers" # Claude Code's plugin store for this machine. `CLAUDE_CONFIG_DIR` moves it, # and a second account is commonly run that way, so reading `~/.claude` # outright would miss an installed plugin and shadow it — the same # resolution packages/doctor/src/pluginCache.ts uses (ENG-468). plugin_cache="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/plugins/cache/fibers" # A machine that already has the plugin, and no launcher of ours, keeps it # untouched: ~/.local/bin sits ahead of the plugin's own directory on PATH, # so installing here would shadow a launcher that already works, and on # somebody's laptop this script is not what they want. # # Where our launcher IS present, the install carries on and refreshes it. # Otherwise a host installed this way would keep running whatever version # it first downloaded: `fibers update` moves the plugin, which the launcher # does not read, and an early return here would make re-running the # installer no way out of it either. Re-running it is now the update path. if [ -d "$plugin_cache" ] && [ ! -f "$cli" ]; then say "the plugin is already installed here, so nothing was changed" return 0 fi have_plugin=false if [ -d "$plugin_cache" ]; then have_plugin=true; fi if ! command -v curl >/dev/null 2>&1; then warn "no curl on PATH, so nothing was installed" return 0 fi if ! command -v node >/dev/null 2>&1; then warn "no node on PATH; the CLI needs node 20 or newer, so nothing was installed" return 0 fi node_major="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || true)" case "$node_major" in '' | *[!0-9]*) warn "could not read node's version, so trying anyway" ;; *) if [ "$node_major" -lt 20 ]; then warn "node $node_major is older than the node 20 the CLI needs, so nothing was installed" return 0 fi ;; esac version="$(published_version "$origin")" if [ -z "$version" ]; then warn "could not read the published version from $origin/fibers-marketplace.json (is $origin allow-listed?), so falling back to $fallback_version" version="$fallback_version" fi if ! mkdir -p "$bin_dir" "$lib_dir"; then warn "cannot write to $HOME/.local, so nothing was installed" return 0 fi cli_url="$origin/releases/plugin/$version/fibers-node.mjs" node_cli="$lib_dir/fibers-node-$version.mjs" if ! write_launcher "$cli" "$cli_url" "$version"; then warn "could not write $cli, so nothing was changed" return 0 fi # Warm the cache so the first real call is fast. Downloading to a temp file # and publishing on success matters: `curl -f` leaves a partial or # error-body file behind, which would look installed and fail on every # call. Not fatal either way — the launcher retries on first use. tmp="$node_cli.$$" if curl -fsS --connect-timeout 10 --max-time 120 -o "$tmp" "$cli_url" && [ -s "$tmp" ]; then mv "$tmp" "$node_cli" say "CLI $version installed at $cli" # Drop the copies earlier runs of THIS script cached, this version's # aside. Unlinking a file a running launcher has open is safe; it keeps # reading the inode. # # The unversioned `fibers-node.mjs` is deliberately left alone: it belongs # to scripts/cloud-setup-fibers-block.sh, whose six hook commands name it # directly, so deleting it on a warm sandbox provisioned by that block # would break those hooks with MODULE_NOT_FOUND — and leave the session # with no hooks at all if the plugin install below then failed. The # installer only ever removes files it wrote itself. for stale in "$lib_dir"/fibers-node-*.mjs; do if [ -f "$stale" ] && [ "$stale" != "$node_cli" ]; then rm -f "$stale"; fi done else rm -f "$tmp" warn "could not download the CLI from $cli_url (404 = no such release; is $origin allow-listed?), so $cli will retry on first use" fi # The Claude extras, when there is a Claude Code to give them to and it has # not got them already. stdin is /dev/null because this script is itself # usually read from a pipe, and a command that reads stdin would eat the # rest of it. installed_plugin=false if [ "$have_plugin" = true ]; then say "the plugin is already installed, so its hooks and skills were left as they are" elif command -v claude >/dev/null 2>&1; then if ! claude plugin marketplace add "$origin/fibers-marketplace.json" --scope user