330 lines
12 KiB
Bash
330 lines
12 KiB
Bash
#!/bin/sh
|
|
# hanzo installer
|
|
#
|
|
# curl -fsSL hanzo.sh | sh
|
|
# curl -fsSL hanzo.sh | bash
|
|
#
|
|
# copyright (c) Hanzo AI, Inc.
|
|
# https://hanzo.ai
|
|
#
|
|
# Downloads one prebuilt native binary per tool, verifies its sha256, and puts it
|
|
# on PATH. That is the whole design. Nothing is built here, no package manager is
|
|
# involved, and there is no runtime to install first.
|
|
#
|
|
# POSIX sh on purpose. The site published `| bash` and llms.txt published `| sh`,
|
|
# and the script was bash-only — so every reader who copied the `sh` form (every
|
|
# agent reading llms.txt) got `set: Illegal option -o pipefail` from dash and
|
|
# nothing installed. Rather than pick a winner and leave the other broken, this
|
|
# runs under both: no arrays, no [[ ]], no <<<, no echo -e, no pipefail. Line 1
|
|
# of this file is what the site hands curl, and it says #!/bin/sh, so this also
|
|
# makes that shebang honest.
|
|
#
|
|
# It does NOT re-implement downloading. hanzoai/cli/install.sh is the one
|
|
# implementation of "fetch a Hanzo binary" — platform detection, asset naming,
|
|
# checksum verification and the second-name symlink all live there, once. This
|
|
# fetches it and drives it once per tool. Two copies of platform detection would
|
|
# be the same class of bug as two copies of a route table.
|
|
#
|
|
# Every tool below is a published, public, checksummed native binary. A tool that
|
|
# is not one is NOT installed by some other mechanism to pad the list — it is
|
|
# named, with the reason. An installer that quietly reaches for a package manager
|
|
# to look complete is the defect this file exists to remove.
|
|
|
|
set -eu
|
|
|
|
HANZO_DIR="${HANZO_INSTALL_DIR:-$HOME/.local/bin}"
|
|
HANZO_QUIET="${HANZO_QUIET:-0}"
|
|
CLI_REPO="${HANZO_CLI_REPO:-hanzoai/cli}"
|
|
|
|
# The complete set of Hanzo tools that ship as a public, checksummed, native
|
|
# binary today, as <name>:<repo>:<binary>:<second-name>.
|
|
#
|
|
# The binary is also the asset prefix and the name inside the tarball — the
|
|
# convention hanzoai/cli/install.sh relies on. Adding a tool is one line, the day
|
|
# its repo is public and publishes <binary>-<os>-<arch>.tar.gz.sha256.
|
|
#
|
|
# `dev` takes no second name on purpose. cli/install.sh defaults the alias to
|
|
# `hanzo-node`, which the CLI already owns — giving it to `dev` too would point
|
|
# the delegate name at a different program.
|
|
TOOLS='hanzo:hanzoai/cli:hanzo:hanzo-node
|
|
mcp:hanzoai/mcp:hanzo-mcp:mcp
|
|
dev:hanzoai/dev:dev:'
|
|
|
|
# The installable names, read off TOOLS so no message can name a different set
|
|
# than the loop installs.
|
|
tool_names() {
|
|
printf '%s\n' "$TOOLS" | while IFS= read -r n_row; do
|
|
printf ' %s' "${n_row%%:*}"
|
|
done
|
|
}
|
|
|
|
# Named, not silently skipped, and never substituted with something else.
|
|
unavailable_rows() {
|
|
cat <<'ROWS'
|
|
node|source is not public yet
|
|
desktop|`hanzo desktop` is in the CLI; the standalone app is not public yet
|
|
bot|`hanzo bot` is in the CLI; the standalone node is not native yet
|
|
ROWS
|
|
}
|
|
|
|
if [ -t 1 ] && [ "${TERM:-}" != dumb ]; then
|
|
R=$(printf '\033[0;31m'); G=$(printf '\033[0;32m'); Y=$(printf '\033[0;33m')
|
|
C=$(printf '\033[0;36m'); N=$(printf '\033[0m'); BD=$(printf '\033[1m')
|
|
DM=$(printf '\033[2m')
|
|
else
|
|
R=''; G=''; Y=''; C=''; N=''; BD=''; DM=''
|
|
fi
|
|
|
|
INSTALLED_N=0
|
|
FAILED_N=0
|
|
FAILED_LIST=''
|
|
|
|
log() { [ "$HANZO_QUIET" = 1 ] || printf ' %s\n' "$1"; }
|
|
ok() { printf ' %s✓%s %s\n' "$G" "$N" "$1"; }
|
|
warn() { printf ' %s!%s %s\n' "$Y" "$N" "$1"; }
|
|
fail() { printf ' %s✗%s %s\n' "$R" "$N" "$1"; }
|
|
die() { fail "$1"; exit 1; }
|
|
|
|
banner() {
|
|
[ "$HANZO_QUIET" = 1 ] && return 0
|
|
printf '%s' "$DM"
|
|
cat <<'ART'
|
|
__
|
|
/ /_ ____ _____ ____ ____
|
|
/ __ \/ __ `/ __ \/_ / / __ \
|
|
/ / / / /_/ / / / / / /_/ /_/ /
|
|
/_/ /_/\__,_/_/ /_/ /___/\____/
|
|
ART
|
|
printf '%s\n' "$N"
|
|
}
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
hanzo installer
|
|
|
|
usage: curl -fsSL hanzo.sh | sh
|
|
curl -fsSL hanzo.sh | sh -s -- [options] [tool...]
|
|
|
|
Downloads one prebuilt native binary per tool and verifies its checksum.
|
|
With no tool named, installs every tool that ships as a public native binary.
|
|
|
|
tools:
|
|
hanzo the Hanzo CLI (also installed as hanzo-node, the same build)
|
|
mcp the MCP server (also installed as hanzo-mcp, the same build)
|
|
dev Hanzo Dev, the coding agent `hanzo code` runs by default
|
|
|
|
options:
|
|
-d, --dir PATH install directory (default: ~/.local/bin)
|
|
--version TAG pin the CLI release (default: latest)
|
|
-q, --quiet less output
|
|
-h, --help show this help
|
|
|
|
shortcuts:
|
|
curl -fsSL hanzo.sh | sh # every installable tool
|
|
curl -fsSL hanzo.sh/cli | sh # the CLI only
|
|
curl -fsSL hanzo.sh/mcp | sh # the MCP server only
|
|
|
|
Re-run any time to upgrade; installing is just downloading the current build.
|
|
EOF
|
|
exit 0
|
|
}
|
|
|
|
WANT=''
|
|
parse_args() {
|
|
while [ $# -gt 0 ]; do
|
|
case $1 in
|
|
--dir|-d) HANZO_DIR="$2"; shift 2 ;;
|
|
--version) HANZO_VERSION="$2"; export HANZO_VERSION; shift 2 ;;
|
|
--quiet|-q) HANZO_QUIET=1; shift ;;
|
|
--help|-h) usage ;;
|
|
# Retired flags. Installing IS upgrading — there is one path and it
|
|
# always fetches the current build — so these mean nothing now.
|
|
# Accept and ignore rather than failing an install someone scripted.
|
|
--upgrade|-u|--force|-f) shift ;;
|
|
--bundle|-b) WANT="$WANT $2"; shift 2 ;;
|
|
-*) warn "ignoring unknown option: $1"; shift ;;
|
|
*) WANT="$WANT $1"; shift ;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
# wanted <name> <binary> — did the caller ask for this tool?
|
|
# `default`, `full` and `all` named the old bundles. They meant "everything",
|
|
# which is what naming nothing means, so they map to that rather than failing an
|
|
# install someone already scripted.
|
|
wanted() {
|
|
for w in $WANT; do
|
|
case $w in
|
|
"$1"|"$2"|default|full|all) return 0 ;;
|
|
esac
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# hanzoai/cli/install.sh, fetched once and reused for every tool.
|
|
#
|
|
# Prefer the API with an explicit raw Accept: it serves the CURRENT file, while
|
|
# raw.githubusercontent sits behind a CDN that hands back a copy from some
|
|
# minutes ago. Serving a stale script is the exact bug this rewrite removes, so
|
|
# do not risk it — but fall back to raw when the API is unreachable or has
|
|
# rate-limited an anonymous caller, because a rate limit must not stop an install.
|
|
ONE_INSTALLER=''
|
|
fetch_one_installer() {
|
|
api="https://api.github.com/repos/${CLI_REPO}/contents/install.sh?ref=main"
|
|
token="${HANZO_INSTALL_TOKEN:-${GH_TOKEN:-${GITHUB_TOKEN:-}}}"
|
|
|
|
# Never interpolate a token into an unquoted argument list; branch instead.
|
|
if [ -n "$token" ]; then
|
|
curl -fsSL -H "Accept: application/vnd.github.raw" \
|
|
-H "Authorization: Bearer $token" "$api" -o "$1" 2>/dev/null && return 0
|
|
else
|
|
curl -fsSL -H "Accept: application/vnd.github.raw" "$api" -o "$1" 2>/dev/null && return 0
|
|
fi
|
|
curl -fsSL "https://raw.githubusercontent.com/${CLI_REPO}/main/install.sh" -o "$1" 2>/dev/null
|
|
}
|
|
|
|
# install <repo> <binary> <second-name>
|
|
install_tool() {
|
|
i_repo="$1"; i_bin="$2"; i_alias="$3"
|
|
|
|
# HANZO_VERSION names a CLI release. Passing it to anything else would pin a
|
|
# tag that repo has never published, so it reaches exactly one tool.
|
|
i_pin=''
|
|
if [ "$i_repo" = "$CLI_REPO" ]; then i_pin="${HANZO_VERSION:-}"; fi
|
|
|
|
if i_out=$(HANZO_INSTALL_PREFIX="$HANZO_DIR" \
|
|
HANZO_INSTALL_REPO="$i_repo" \
|
|
HANZO_INSTALL_BIN="$i_bin" \
|
|
HANZO_INSTALL_ALIAS="$i_alias" \
|
|
HANZO_VERSION="$i_pin" \
|
|
sh "$ONE_INSTALLER" 2>&1); then
|
|
# Surface the installer's own warnings (PATH shadowing, PATH missing).
|
|
# They are the difference between an install that worked and one the user
|
|
# will never actually run — so carry the CONTINUATION lines too. The
|
|
# shadow warning's second line is the half that says what to do about it,
|
|
# and a filter that keeps only the alarm and drops the remedy is worse
|
|
# than not warning at all.
|
|
printf '%s\n' "$i_out" \
|
|
| grep -E 'WARNING|Remove it, or put|not on PATH|export PATH' \
|
|
| sed 's/^/ /' || true
|
|
else
|
|
printf '%s\n' "$i_out" | sed 's/^/ /'
|
|
FAILED_N=$((FAILED_N + 1)); FAILED_LIST="$FAILED_LIST $i_bin"
|
|
fail "$i_bin"; return 1
|
|
fi
|
|
|
|
# Trust the artifact, not the log line: ask the binary that is now on disk.
|
|
if [ ! -x "$HANZO_DIR/$i_bin" ]; then
|
|
FAILED_N=$((FAILED_N + 1)); FAILED_LIST="$FAILED_LIST $i_bin"
|
|
fail "$i_bin (nothing at $HANZO_DIR/$i_bin)"; return 1
|
|
fi
|
|
# The first token that looks like a version, not the last token on the line:
|
|
# `--version` output is not one shape across tools (`hanzo 1.9.18` ends on
|
|
# the version, `hanzo dev 0.6.91 (sha) 2026-04-01` ends on a date), and
|
|
# printing a build date where a version belongs is its own small lie.
|
|
i_ver=$("$HANZO_DIR/$i_bin" --version 2>/dev/null | head -1 \
|
|
| tr ' ' '\n' | grep -m1 -E '^v?[0-9]+\.[0-9]+' | sed 's/^v//')
|
|
[ -n "${i_ver:-}" ] || i_ver='?'
|
|
|
|
if [ -n "$i_alias" ]; then
|
|
# Both names or it is not a finished install. A missing second name falls
|
|
# through to whatever older copy is already on the box, silently.
|
|
if [ ! -e "$HANZO_DIR/$i_alias" ]; then
|
|
FAILED_N=$((FAILED_N + 1)); FAILED_LIST="$FAILED_LIST $i_alias"
|
|
fail "$i_alias was not installed alongside $i_bin"; return 1
|
|
fi
|
|
ok "$i_bin $i_ver ${DM}(+ $i_alias, same build)${N}"
|
|
else
|
|
ok "$i_bin $i_ver"
|
|
fi
|
|
INSTALLED_N=$((INSTALLED_N + 1))
|
|
}
|
|
|
|
show_unavailable() {
|
|
[ "$HANZO_QUIET" = 1 ] && return 0
|
|
printf '\n %snot included:%s\n' "$BD" "$N"
|
|
unavailable_rows | while IFS='|' read -r u_name u_why; do
|
|
printf ' %s○ %-8s %s%s\n' "$DM" "$u_name" "$u_why" "$N"
|
|
done
|
|
}
|
|
|
|
finish() {
|
|
printf '\n'
|
|
if [ "$INSTALLED_N" -eq 0 ]; then die "nothing was installed"; fi
|
|
|
|
# Never say "ready" over a failure. A partial install that reads as success
|
|
# is how someone spends an afternoon on a tool that was never there.
|
|
if [ "$FAILED_N" -gt 0 ]; then
|
|
printf ' %s%sincomplete%s — did not install:%s\n' "$R" "$BD" "$N" "$N"
|
|
for f in $FAILED_LIST; do printf ' %s✗%s %s\n' "$R" "$N" "$f"; done
|
|
printf '\n'
|
|
return 0
|
|
fi
|
|
|
|
printf ' %sready%s %s→ %s%s\n\n' "$G" "$N" "$DM" "$HANZO_DIR" "$N"
|
|
printf ' quick start:\n'
|
|
printf ' %shanzo auth login%s # authenticate\n' "$C" "$N"
|
|
printf ' %shanzo code%s # start a coding session\n' "$C" "$N"
|
|
printf ' %shanzo up%s # run the cloud on this machine\n' "$C" "$N"
|
|
printf ' %shanzo --help%s # every command\n' "$C" "$N"
|
|
if [ -x "$HANZO_DIR/hanzo-mcp" ]; then
|
|
printf ' %shanzo-mcp --help%s # the MCP server\n' "$C" "$N"
|
|
fi
|
|
printf '\n docs: https://docs.hanzo.ai\n\n'
|
|
}
|
|
|
|
main() {
|
|
parse_args "$@"
|
|
banner
|
|
|
|
command -v curl >/dev/null 2>&1 || die "need curl on PATH"
|
|
|
|
tmp=$(mktemp -d)
|
|
trap 'rm -rf "$tmp"' EXIT
|
|
ONE_INSTALLER="$tmp/install.sh"
|
|
fetch_one_installer "$ONE_INSTALLER" \
|
|
|| die "could not fetch the installer from ${CLI_REPO}"
|
|
[ -s "$ONE_INSTALLER" ] || die "the installer fetched from ${CLI_REPO} is empty"
|
|
|
|
mkdir -p "$HANZO_DIR"
|
|
|
|
selected=0
|
|
for row in $TOOLS; do
|
|
t_name=${row%%:*}; rest=${row#*:}
|
|
t_repo=${rest%%:*}; rest=${rest#*:}
|
|
t_bin=${rest%%:*}; t_alias=${rest#*:}
|
|
if [ -n "$WANT" ] && ! wanted "$t_name" "$t_bin"; then continue; fi
|
|
selected=1
|
|
install_tool "$t_repo" "$t_bin" "$t_alias" || true
|
|
done
|
|
|
|
if [ "$selected" -eq 0 ]; then
|
|
# Asked for something real that we publish no binary for. Answer with the
|
|
# reason from the one place that holds it, rather than a "no such tool"
|
|
# that reads like a typo. Non-zero: they asked for an install and did not
|
|
# get one.
|
|
for w in $WANT; do
|
|
why=$(unavailable_rows | while IFS='|' read -r u_name u_why; do
|
|
if [ "$u_name" = "$w" ]; then printf '%s' "$u_why"; fi
|
|
done)
|
|
if [ -n "$why" ]; then
|
|
fail "$w — $why"
|
|
printf ' %sinstallable today:%s%s\n' "$DM" "$(tool_names)" "$N"
|
|
exit 1
|
|
fi
|
|
done
|
|
die "no such tool:$WANT — installable today:$(tool_names)"
|
|
fi
|
|
|
|
if [ -z "$WANT" ]; then show_unavailable; fi
|
|
finish
|
|
|
|
# A failed download or a missing second name is a failed install, and the
|
|
# exit code has to say so — a half-install discovered days later is worse
|
|
# than one that stopped here.
|
|
if [ "$FAILED_N" -gt 0 ]; then exit 1; fi
|
|
exit 0
|
|
}
|
|
|
|
main "$@"
|