#!/bin/bash # get - install scripts from toolio.sh into a directory on $PATH # # Usage: # get [script ...] # get -a|--all # get -s|--skill # get -h # # Options: # -a, --all Install every script in the catalog # -s, --skill Install the toolio agent skill instead of any script # -h, --help Show help message # # Environment: # INSTALL_DIR Override the script install directory (default: ~/.local/bin) # SKILL_DIR Override the agent-skill root for --skill # # Downloads scripts from https://toolio.sh and installs them to $INSTALL_DIR # (default: ~/.local/bin), making them executable. With no arguments, lists # the available scripts grouped by category _get() ( local SCRIPT_NAME; SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")" # When piped via `curl ... | bash`, BASH_SOURCE[0] is empty or "bash", # so basename returns "bash". Fall back to the canonical name case "${BASH_SOURCE[0]}" in /dev/*|/proc/*) SCRIPT_NAME="" ;; esac case "$SCRIPT_NAME" in ""|bash|sh|zsh|dash) SCRIPT_NAME="get" ;; esac # The agent skill installed by --skill. Named for the site rather than the # repo: a skill directory called "sh" would collide with the shell itself local SKILL_NAME="toolio" # Harness skill roots probed when SKILL_DIR is unset. Claude Code and Codex # read the same /SKILL.md layout, so one payload serves both local SKILL_ROOTS="$HOME/.claude/skills $HOME/.codex/skills" _show_help() { local s="" local r="" if { [ -t 1 ] || [ -n "${CLICOLOR_FORCE:-}" ]; } && [ -z "${NO_COLOR:-}" ]; then s=$'\033[4m' r=$'\033[24m' fi cat <&2; } _warn() { echo "[WRN][$SCRIPT_NAME] $*" >&2; } _info() { echo "[INF][$SCRIPT_NAME] $*" >&2; } _expand_short_opts() { # $1 = string of short-opt letters that take a value (e.g. "nXHd"); "" for flag-only scripts # $2..$N = "$@" # Populates _EXPANDED; caller does: set -- "${_EXPANDED[@]}"; unset _EXPANDED local value_opts="$1"; shift _EXPANDED=() local passthru="" local arg local rest local c for arg in "$@"; do if [ -n "$passthru" ]; then _EXPANDED+=("$arg"); continue; fi case "$arg" in --) passthru=1; _EXPANDED+=("$arg") ;; --*|-|"") _EXPANDED+=("$arg") ;; -[a-zA-Z]?*) rest="${arg#-}" while [ -n "$rest" ]; do c="${rest%"${rest#?}"}"; rest="${rest#?}" _EXPANDED+=("-$c") case "$value_opts" in *"$c"*) [ -n "$rest" ] && _EXPANDED+=("$rest") rest="" ;; esac done ;; *) _EXPANDED+=("$arg") ;; esac done } _fetch_catalog() { local url="${_GET_TEST_INDEX_URL:-https://toolio.sh/INDEX.md}" local body if ! body="$(curl -fsS "$url" 2>&1)"; then _error "Failed to fetch INDEX.md: $body" return 1 fi printf '%s\n' "$body" | awk ' # Skip top-level title and the Jump-to line /^# / || /^\*\*Jump to:/ { next } # Capture category headings; ignore "## Notes" /^## / { sub(/^## /, "", $0) if ($0 == "Notes") { cat = ""; next } cat = $0 next } # Catalog rows: start with "| `NAME`" or "| [`NAME`]" (NAME is any non-backtick) /^\| `[^`]+`/ || /^\| \[`[^`]+`/ { line = $0 # Extract NAME from the first backtick pair i = index(line, "`") if (i == 0) next rest = substr(line, i + 1) j = index(rest, "`") if (j == 0) next name = substr(rest, 1, j - 1) # Description is between the 2nd and last "|" n = 0 second_pipe = 0 for (k = 1; k <= length(line); k++) { if (substr(line, k, 1) == "|") { n++ if (n == 2) { second_pipe = k; break } } } if (second_pipe == 0) next last_pipe = 0 for (k = length(line); k >= 1; k--) { if (substr(line, k, 1) == "|") { last_pipe = k; break } } if (last_pipe <= second_pipe) next desc = substr(line, second_pipe + 1, last_pipe - second_pipe - 1) # Trim leading/trailing whitespace from desc sub(/^[ \t]+/, "", desc) sub(/[ \t]+$/, "", desc) if (cat != "" && name != "") { printf "%s\t%s\t%s\n", cat, name, desc } } ' } _format_listing() { local catalog="$1" local max_width; max_width="$(printf '%s\n' "$catalog" | awk -F'\t' '{ if (length($2) > m) m = length($2) } END { print m+0 }')" printf '%s\n' "$catalog" | awk -F'\t' -v w="$max_width" ' BEGIN { cur = "" } { if ($1 != cur) { if (cur != "") print "" print $1 cur = $1 } printf " %-*s %s\n", w, $2, $3 } ' } _install_one() { local name="$1" local dir="$2" local url="${_GET_TEST_SCRIPT_URL_PREFIX:-https://toolio.sh}/$name" local tmp; tmp="$(mktemp)" || { echo "failed: $name (mktemp)"; return 1; } if ! curl -fsS "$url" -o "$tmp"; then echo "failed: $name (fetch)" rm -f "$tmp" return 1 fi if [ ! -s "$tmp" ] || ! /bin/bash -n "$tmp" 2>/dev/null; then echo "failed: $name (invalid content)" rm -f "$tmp" return 1 fi local target="$dir/$name" if [ -e "$target" ] && cmp -s "$tmp" "$target"; then echo "up to date: $name" rm -f "$tmp" return 2 # 2 == up-to-date sentinel (caller maps to counter) fi local was_present=0 [ -e "$target" ] && was_present=1 if ! mv "$tmp" "$target"; then echo "failed: $name (mv)" rm -f "$tmp" return 1 fi if ! chmod +x "$target"; then echo "failed: $name (chmod)" rm -f "$target" return 1 fi if [ "$was_present" -eq 1 ]; then echo "updated: $name" return 3 # 3 == updated sentinel else echo "installed: $name" return 0 # 0 == installed fi } # Resolve the skill roots to install into: SKILL_DIR when set (the only # target, no probing), else each harness root that already exists. Probing # rather than mkdir-ing both keeps a ~/.codex tree off a machine that has # never run Codex. Prints one root per line; empty output means none found _skill_targets() { if [ -n "${SKILL_DIR:-}" ]; then printf '%s\n' "${SKILL_DIR%/}" return 0 fi local root for root in $SKILL_ROOTS; do # The harness config dir, not the skills subdir: that is what marks # the harness as present, and skills/ may not exist yet if [ -d "$(dirname "$root")" ]; then printf '%s\n' "$root" fi done } # Install the skill payload into one skills root. Mirrors _install_one's # fetch/validate/compare/atomic-replace flow and sentinel returns, but the # payload is markdown: `bash -n` and chmod +x would both be wrong, so it # validates the frontmatter instead and leaves the file non-executable _install_skill_to() { local root="$1" local url="${_GET_TEST_SKILL_URL:-https://toolio.sh/skills/$SKILL_NAME/SKILL.md}" local dir="$root/$SKILL_NAME" local tmp; tmp="$(mktemp)" || { echo "failed: $SKILL_NAME (mktemp)"; return 1; } if ! curl -fsS "$url" -o "$tmp"; then echo "failed: $SKILL_NAME -> $dir (fetch)" rm -f "$tmp" return 1 fi # A skill is only loadable with frontmatter naming it, so validate both # rather than trusting a 200 that returned an error page if [ ! -s "$tmp" ] || [ "$(head -1 "$tmp")" != "---" ] \ || ! grep -q "^name: $SKILL_NAME\$" "$tmp"; then echo "failed: $SKILL_NAME -> $dir (invalid content)" rm -f "$tmp" return 1 fi if ! mkdir -p "$dir"; then echo "failed: $SKILL_NAME -> $dir (mkdir)" rm -f "$tmp" return 1 fi local target="$dir/SKILL.md" if [ -e "$target" ] && cmp -s "$tmp" "$target"; then echo "up to date: $SKILL_NAME -> $dir" rm -f "$tmp" return 2 # 2 == up-to-date sentinel, as in _install_one fi local was_present=0 [ -e "$target" ] && was_present=1 if ! mv "$tmp" "$target"; then echo "failed: $SKILL_NAME -> $dir (mv)" rm -f "$tmp" return 1 fi # mktemp creates 0600; the harness only reads it, but a private-by-default # temp file is surprising for tracked-style content, so widen to 0644 chmod 644 "$target" 2>/dev/null || : if [ "$was_present" -eq 1 ]; then echo "updated: $SKILL_NAME -> $dir" return 3 # 3 == updated sentinel fi echo "installed: $SKILL_NAME -> $dir" return 0 } # Clean up inner functions when returning if ! command -v curl >/dev/null 2>&1; then _error "curl is required" return 3 fi local all_mode=0 local skill_mode=0 local requested=() _expand_short_opts "" "$@" set -- "${_EXPANDED[@]}"; unset _EXPANDED while [ $# -gt 0 ]; do case "$1" in -h|--help) _show_help return 0 ;; -a|--all) all_mode=1 ;; -s|--skill) skill_mode=1 ;; -*) _error "Unknown argument '$1'. Run \`$SCRIPT_NAME -h\` for usage" return 2 ;; *) requested+=("$1") ;; esac shift done if [ "$all_mode" -eq 1 ] && [ "${#requested[@]}" -gt 0 ]; then _error "--all cannot be combined with script names. Run \`$SCRIPT_NAME -h\` for usage" return 2 fi # --skill is a standalone mode: it installs no scripts, so it needs neither # the catalog nor $INSTALL_DIR. Handled before the fetch and returned from # directly, which also keeps the combination checks below unambiguous if [ "$skill_mode" -eq 1 ]; then if [ "$all_mode" -eq 1 ] || [ "${#requested[@]}" -gt 0 ]; then _error "--skill cannot be combined with --all or script names. Run \`$SCRIPT_NAME -h\` for usage" return 2 fi local targets; targets="$(_skill_targets)" if [ -z "$targets" ]; then _error "No agent skill directory found (looked for $SKILL_ROOTS). Set SKILL_DIR to choose one" return 2 fi local n_installed=0 local n_updated=0 local n_up_to_date=0 local n_failed=0 local n_targets=0 local root while IFS= read -r root; do [ -n "$root" ] || continue n_targets=$((n_targets + 1)) _install_skill_to "$root" case $? in 0) n_installed=$((n_installed + 1)) ;; 2) n_up_to_date=$((n_up_to_date + 1)) ;; 3) n_updated=$((n_updated + 1)) ;; *) n_failed=$((n_failed + 1)) ;; esac done <<< "$targets" printf '%d skill %s: %d installed, %d updated, %d up to date, %d failed\n' \ "$n_targets" "$(if [ "$n_targets" -eq 1 ]; then echo location; else echo locations; fi)" \ "$n_installed" "$n_updated" "$n_up_to_date" "$n_failed" if [ "$n_failed" -gt 0 ]; then return 1 fi return 0 fi # Fetch catalog once local catalog if ! catalog="$(_fetch_catalog)"; then return 1 fi if [ -z "$catalog" ]; then _error "Catalog is empty" return 1 fi # --all: populate requested from the full catalog if [ "$all_mode" -eq 1 ]; then local name while IFS= read -r name; do [ -n "$name" ] && requested+=("$name") done <<< "$(printf '%s\n' "$catalog" | awk -F'\t' '{print $2}')" fi # Validate requested names against catalog (skipped for --all; Task 7) if [ "$all_mode" -eq 0 ] && [ "${#requested[@]}" -gt 0 ]; then local names; names="$(printf '%s\n' "$catalog" | awk -F'\t' '{print $2}')" local unknown=() local r local match local name for r in "${requested[@]}"; do match=0 while IFS= read -r name; do if [ "$name" = "$r" ]; then match=1 break fi done <<< "$names" if [ "$match" -eq 0 ]; then unknown+=("$r") fi done if [ "${#unknown[@]}" -gt 0 ]; then _error "Unknown script(s): ${unknown[*]}. Run \`$SCRIPT_NAME\` with no args to see the catalog" return 2 fi fi if [ "${#requested[@]}" -gt 0 ]; then local install_dir="${INSTALL_DIR:-$HOME/.local/bin}" if [ ! -d "$install_dir" ]; then if ! mkdir -p "$install_dir"; then _error "Failed to create install dir: $install_dir" return 1 fi _info "Created $install_dir" fi # Resolve to absolute path so the PATH warning's export line isn't CWD-dependent install_dir="$(cd "$install_dir" && pwd)" local n_installed=0 local n_updated=0 local n_up_to_date=0 local n_failed=0 local name for name in "${requested[@]}"; do _install_one "$name" "$install_dir" local rc=$? case $rc in 0) n_installed=$((n_installed + 1)) ;; 2) n_up_to_date=$((n_up_to_date + 1)) ;; 3) n_updated=$((n_updated + 1)) ;; *) n_failed=$((n_failed + 1)) ;; esac done local total=${#requested[@]} printf '%d requested: %d installed, %d updated, %d up to date, %d failed\n' \ "$total" "$n_installed" "$n_updated" "$n_up_to_date" "$n_failed" # Warn if install_dir is not on $PATH # Normalize (strip trailing slash) for PATH membership check local norm_install="${install_dir%/}" local norm_path=":${PATH}:" norm_path="${norm_path//\/:/:}" case "$norm_path" in *":$norm_install:"*) : ;; *) _warn "$install_dir is not on your \$PATH. To fix, add this to your shell rc:" echo "export PATH=\"$install_dir:\$PATH\"" >&2 ;; esac if [ "$n_failed" -gt 0 ]; then return 1 fi return 0 fi # Listing mode: no args, no --all if [ "$all_mode" -eq 0 ] && [ "${#requested[@]}" -eq 0 ]; then _format_listing "$catalog" return 0 fi return 0 ) _get "$@" __get_rc=$? unset -f _get if [ -n "${BASH_SOURCE[0]}" ] && [ "${BASH_SOURCE[0]}" != "$0" ]; then eval "unset __get_rc; return $__get_rc" fi eval "unset __get_rc; exit $__get_rc"