| 1 | #!/bin/sh |
| 2 | set -eu |
| 3 | |
| 4 | repo="codewhale-hq/CodeWhale" |
| 5 | version="${CODEWHALE_VERSION:-latest}" |
| 6 | release_base="${CODEWHALE_RELEASE_BASE_URL:-${DEEPSEEK_TUI_RELEASE_BASE_URL:-}}" |
| 7 | |
| 8 | usage() { |
| 9 | cat <<'USAGE' |
| 10 | Codewhale GitHub release installer for new macOS and Linux installations. |
| 11 | For an existing direct install, run its codewhale update command. |
| 12 | |
| 13 | Usage: |
| 14 | curl -fsSL https://codewhale.net/install.sh | sh |
| 15 | |
| 16 | Environment: |
| 17 | CODEWHALE_INSTALL_DIR Install directory. Default: $HOME/.local/bin |
| 18 | CODEWHALE_VERSION Release tag for a fresh directory. Default: latest |
| 19 | CODEWHALE_RELEASE_BASE_URL |
| 20 | Custom release asset base URL ending in /download |
| 21 | CODEWHALE_INSTALL_COMPILED_HOST=1 |
| 22 | Also install a qualified optional Bun image + notices/source |
| 23 | Node remains the default; missing eligibility fails loudly |
| 24 | CODEWHALE_SKIP_GLIBC_CHECK=1 |
| 25 | Skip Linux arm64 glibc compatibility preflight |
| 26 | |
| 27 | Examples: |
| 28 | curl -fsSL https://codewhale.net/install.sh | CODEWHALE_INSTALL_DIR="$HOME/.local/codewhale/bin" sh |
| 29 | curl -fsSL https://codewhale.net/install.sh | CODEWHALE_VERSION=vX.Y.Z sh |
| 30 | USAGE |
| 31 | } |
| 32 | |
| 33 | case "${1:-}" in |
| 34 | -h|--help) |
| 35 | usage |
| 36 | exit 0 |
| 37 | ;; |
| 38 | esac |
| 39 | |
| 40 | say() { |
| 41 | printf '%s\n' "$*" |
| 42 | } |
| 43 | |
| 44 | fail() { |
| 45 | printf 'codewhale install: %s\n' "$*" >&2 |
| 46 | exit 1 |
| 47 | } |
| 48 | |
| 49 | if [ -n "${CODEWHALE_INSTALL_DIR:-}" ]; then |
| 50 | install_dir="$CODEWHALE_INSTALL_DIR" |
| 51 | else |
| 52 | [ -n "${HOME:-}" ] || fail "HOME is not set; set CODEWHALE_INSTALL_DIR" |
| 53 | install_dir="$HOME/.local/bin" |
| 54 | fi |
| 55 | |
| 56 | need_cmd() { |
| 57 | command -v "$1" >/dev/null 2>&1 || fail "missing required command: $1" |
| 58 | } |
| 59 | |
| 60 | download() { |
| 61 | url="$1" |
| 62 | out="$2" |
| 63 | if command -v curl >/dev/null 2>&1; then |
| 64 | curl -fsSL "$url" -o "$out" |
| 65 | elif command -v wget >/dev/null 2>&1; then |
| 66 | wget -q "$url" -O "$out" |
| 67 | else |
| 68 | fail "curl or wget is required" |
| 69 | fi |
| 70 | } |
| 71 | |
| 72 | sha256_file() { |
| 73 | file="$1" |
| 74 | if command -v sha256sum >/dev/null 2>&1; then |
| 75 | sha256sum "$file" | awk '{print $1}' |
| 76 | elif command -v shasum >/dev/null 2>&1; then |
| 77 | shasum -a 256 "$file" | awk '{print $1}' |
| 78 | else |
| 79 | fail "sha256sum or shasum is required to verify downloads" |
| 80 | fi |
| 81 | } |
| 82 | |
| 83 | verify_asset() { |
| 84 | asset="$1" |
| 85 | file="$2" |
| 86 | manifest="$3" |
| 87 | expected="$( |
| 88 | awk -v name="$asset" ' |
| 89 | { |
| 90 | digest = tolower($1) |
| 91 | file = $2 |
| 92 | sub(/^\*/, "", file) |
| 93 | if (file == name && digest ~ /^[0-9a-f]{64}$/) { |
| 94 | print digest |
| 95 | exit |
| 96 | } |
| 97 | } |
| 98 | ' "$manifest" |
| 99 | )" |
| 100 | [ -n "$expected" ] || fail "checksum not found for $asset" |
| 101 | actual="$(sha256_file "$file" | tr '[:upper:]' '[:lower:]')" |
| 102 | [ "$actual" = "$expected" ] || fail "checksum mismatch for $asset" |
| 103 | } |
| 104 | |
| 105 | glibc_version() { |
| 106 | if command -v getconf >/dev/null 2>&1; then |
| 107 | getconf GNU_LIBC_VERSION 2>/dev/null | awk '{ print $NF; exit }' |
| 108 | return |
| 109 | fi |
| 110 | if command -v ldd >/dev/null 2>&1; then |
| 111 | ldd --version 2>/dev/null | awk 'NR == 1 { |
| 112 | for (i = 1; i <= NF; i++) { |
| 113 | if ($i ~ /^[0-9]+\.[0-9]+/) { |
| 114 | print $i |
| 115 | exit |
| 116 | } |
| 117 | } |
| 118 | }' |
| 119 | fi |
| 120 | } |
| 121 | |
| 122 | version_at_least() { |
| 123 | have="$1" |
| 124 | need="$2" |
| 125 | awk -v have="$have" -v need="$need" ' |
| 126 | BEGIN { |
| 127 | split(have, h, ".") |
| 128 | split(need, n, ".") |
| 129 | for (i = 1; i <= 3; i++) { |
| 130 | hv = h[i] + 0 |
| 131 | nv = n[i] + 0 |
| 132 | if (hv > nv) exit 0 |
| 133 | if (hv < nv) exit 1 |
| 134 | } |
| 135 | exit 0 |
| 136 | } |
| 137 | ' |
| 138 | } |
| 139 | |
| 140 | check_glibc() { |
| 141 | case "$target" in |
| 142 | linux-arm64) ;; |
| 143 | *) return ;; |
| 144 | esac |
| 145 | |
| 146 | # Linux arm64 assets became static musl builds in v0.9.6. `latest` and |
| 147 | # explicit v0.9.6+ installs therefore have no glibc floor. Keep the |
| 148 | # preflight only for explicitly requested older releases, whose arm64 |
| 149 | # assets were linked against GNU libc on Ubuntu 24.04. |
| 150 | if [ "$version" = "latest" ]; then |
| 151 | return |
| 152 | fi |
| 153 | numeric_version="${version#v}" |
| 154 | if awk -v have="$numeric_version" ' |
| 155 | BEGIN { |
| 156 | if (have !~ /^[0-9]+\.[0-9]+\.[0-9]+$/) exit 1 |
| 157 | split(have, h, ".") |
| 158 | if (h[1] > 0) exit 0 |
| 159 | if (h[1] < 0) exit 1 |
| 160 | if (h[2] > 9) exit 0 |
| 161 | if (h[2] < 9) exit 1 |
| 162 | exit !(h[3] >= 6) |
| 163 | } |
| 164 | '; then |
| 165 | return |
| 166 | fi |
| 167 | |
| 168 | [ "${CODEWHALE_SKIP_GLIBC_CHECK:-}" = "1" ] && return |
| 169 | [ "${DEEPSEEK_TUI_SKIP_GLIBC_CHECK:-}" = "1" ] && return |
| 170 | [ "${DEEPSEEK_SKIP_GLIBC_CHECK:-}" = "1" ] && return |
| 171 | |
| 172 | required="2.39" |
| 173 | host="$(glibc_version || true)" |
| 174 | if [ -z "$host" ] || ! version_at_least "$host" "$required"; then |
| 175 | cat >&2 <<EOF |
| 176 | codewhale install: Codewhale $version $target assets require glibc $required or newer. |
| 177 | This system reports glibc ${host:-unavailable}. |
| 178 | |
| 179 | Linux arm64 assets before v0.9.6 were GNU libc builds from Ubuntu 24.04. |
| 180 | Current v0.9.6+ assets are static musl builds. Build this older release from |
| 181 | source with Cargo or set |
| 182 | CODEWHALE_SKIP_GLIBC_CHECK=1 to bypass this check at your own risk. |
| 183 | EOF |
| 184 | exit 1 |
| 185 | fi |
| 186 | } |
| 187 | |
| 188 | detect_platform() { |
| 189 | os="$(uname -s)" |
| 190 | arch="$(uname -m)" |
| 191 | |
| 192 | if [ -n "${TERMUX_VERSION:-}" ] || [ "$(uname -o 2>/dev/null || true)" = "Android" ]; then |
| 193 | fail "Android/Termux needs the Android arm64 preview archive, not a Linux binary. See https://github.com/codewhale-hq/CodeWhale/blob/main/docs/INSTALL.md" |
| 194 | fi |
| 195 | |
| 196 | case "$os" in |
| 197 | Darwin) platform="macos" ;; |
| 198 | Linux) platform="linux" ;; |
| 199 | *) fail "unsupported OS: $os. Use the matching asset at https://github.com/codewhale-hq/CodeWhale/releases/latest; npm and Cargo are secondary options." ;; |
| 200 | esac |
| 201 | |
| 202 | case "$arch" in |
| 203 | x86_64|amd64) cpu="x64" ;; |
| 204 | arm64|aarch64) cpu="arm64" ;; |
| 205 | riscv64) fail "Linux riscv64 prebuilt assets are temporarily unavailable because the locked rquickjs-sys dependency does not ship riscv64gc bindings." ;; |
| 206 | *) fail "unsupported CPU architecture: $arch. Use Cargo or build from source." ;; |
| 207 | esac |
| 208 | |
| 209 | printf '%s-%s' "$platform" "$cpu" |
| 210 | } |
| 211 | |
| 212 | if [ -z "$release_base" ]; then |
| 213 | if [ "$version" = "latest" ]; then |
| 214 | release_base="https://github.com/$repo/releases/latest/download" |
| 215 | else |
| 216 | release_base="https://github.com/$repo/releases/download/$version" |
| 217 | fi |
| 218 | fi |
| 219 | |
| 220 | target="$(detect_platform)" |
| 221 | check_glibc |
| 222 | cli_asset="codewhale-$target" |
| 223 | shim_asset="codew-$target" |
| 224 | manifest_asset="codewhale-artifacts-sha256.txt" |
| 225 | |
| 226 | tmpdir="$(mktemp -d 2>/dev/null || mktemp -d -t codewhale-install)" |
| 227 | trap 'rm -rf "$tmpdir"' EXIT INT TERM |
| 228 | |
| 229 | say "Installing Codewhale for $target" |
| 230 | say "Release assets: $release_base" |
| 231 | say "Install dir: $install_dir" |
| 232 | |
| 233 | download "$release_base/$manifest_asset" "$tmpdir/$manifest_asset" |
| 234 | download "$release_base/$cli_asset" "$tmpdir/codewhale" |
| 235 | download "$release_base/$shim_asset" "$tmpdir/codew" |
| 236 | |
| 237 | verify_asset "$cli_asset" "$tmpdir/codewhale" "$tmpdir/$manifest_asset" |
| 238 | verify_asset "$shim_asset" "$tmpdir/codew" "$tmpdir/$manifest_asset" |
| 239 | say "Checksums verified" |
| 240 | |
| 241 | chmod 755 "$tmpdir/codewhale" "$tmpdir/codew" |
| 242 | if command -v xattr >/dev/null 2>&1; then |
| 243 | xattr -d com.apple.quarantine "$tmpdir/codewhale" "$tmpdir/codew" 2>/dev/null || true |
| 244 | fi |
| 245 | |
| 246 | # Resolve the real directory before applying managed-prefix checks. Never use |
| 247 | # sudo or allow an install directory symlink to obscure which files will change. |
| 248 | case "$install_dir" in |
| 249 | /*) ;; |
| 250 | *) fail "CODEWHALE_INSTALL_DIR must be an absolute path" ;; |
| 251 | esac |
| 252 | [ ! -L "$install_dir" ] || fail "install directory is a symlink: $install_dir; choose a fresh user directory" |
| 253 | mkdir -p "$install_dir" || fail "cannot create $install_dir; choose a writable user directory (no sudo is used)" |
| 254 | install_dir="$(cd -P "$install_dir" && pwd)" |
| 255 | case "$install_dir/" in |
| 256 | /bin/*|/sbin/*|/usr/bin/*|/usr/sbin/*|/nix/store/*|/gnu/store/*|*/node_modules/*|*/Cellar/*|*/.linuxbrew/*|*/linuxbrew/*|*/.cargo/bin/*) |
| 257 | fail "refusing managed/system directory $install_dir; use a fresh user directory" |
| 258 | ;; |
| 259 | esac |
| 260 | [ -w "$install_dir" ] || fail "$install_dir is not writable; choose a user directory (no sudo is used)" |
| 261 | |
| 262 | check_destination() { |
| 263 | destination="$1" |
| 264 | source="$2" |
| 265 | mode="${3:-755}" |
| 266 | destination_exists=0 |
| 267 | if [ -e "$destination" ] || [ -L "$destination" ]; then |
| 268 | if [ ! -L "$destination" ] && [ -f "$destination" ] && { [ "$mode" != 755 ] || [ -x "$destination" ]; } && cmp -s "$source" "$destination"; then |
| 269 | destination_exists=1 |
| 270 | return |
| 271 | fi |
| 272 | cat >&2 <<EOF |
| 273 | codewhale install: refusing to replace existing $destination. |
| 274 | It may be a newer build, another installation, or a symlink. No existing file was changed. |
| 275 | For an existing direct Codewhale install, run its full path with 'update'. |
| 276 | To migrate safely from a package manager or mixed installation, create a fresh directory: |
| 277 | mkdir -p "\$HOME/.local" |
| 278 | codewhale_install_dir="\$(mktemp -d "\$HOME/.local/codewhale-release.XXXXXX")" |
| 279 | curl -fsSL https://codewhale.net/install.sh | CODEWHALE_INSTALL_DIR="\$codewhale_install_dir" sh |
| 280 | "\$codewhale_install_dir/codewhale" --version |
| 281 | export PATH="\$codewhale_install_dir:\$PATH" |
| 282 | hash -r |
| 283 | command -v codewhale codew |
| 284 | EOF |
| 285 | exit 1 |
| 286 | fi |
| 287 | } |
| 288 | |
| 289 | # Check every command before publishing any of them. Existing identical release |
| 290 | # files are an idempotent install; anything different uses the canonical updater. |
| 291 | # An enabled companion must exist in this same checksummed release. No |
| 292 | # installer fetches a Bun runtime or invents Android/musl/cross-arch support. |
| 293 | if [ "${CODEWHALE_INSTALL_COMPILED_HOST:-}" = 1 ]; then |
| 294 | host_asset="codewhale-extension-host-$target" |
| 295 | for companion in "$host_asset" "$host_asset-LICENSES.txt" "$host_asset-relink-source.tar.gz" codewhale-extension-hosts.json; do |
| 296 | download "$release_base/$companion" "$tmpdir/$companion" |
| 297 | verify_asset "$companion" "$tmpdir/$companion" "$tmpdir/codewhale-artifacts-sha256.txt" |
| 298 | done |
| 299 | mv "$tmpdir/$host_asset" "$tmpdir/codewhale-extension-host" |
| 300 | mv "$tmpdir/$host_asset-LICENSES.txt" "$tmpdir/codewhale-extension-host.LICENSES.txt" |
| 301 | mv "$tmpdir/$host_asset-relink-source.tar.gz" "$tmpdir/codewhale-extension-host.relink-source.tar.gz" |
| 302 | mv "$tmpdir/codewhale-extension-hosts.json" "$tmpdir/codewhale-extension-host.release.json" |
| 303 | if [ "$target" = linux-x64 ] || [ "$target" = linux-arm64 ]; then |
| 304 | required="$(grep -aoE 'GLIBC_[0-9]+\.[0-9]+(\.[0-9]+)?' "$tmpdir/codewhale-extension-host" 2>/dev/null | sed 's/GLIBC_//' | awk -F. '{ code=$1*1000000+$2*1000+$3; if(code>best){best=code; value=$0} } END {print value}' || true)" |
| 305 | if [ -n "$required" ]; then |
| 306 | available="$(glibc_version || true)" |
| 307 | [ -n "$available" ] && version_at_least "$available" "$required" || fail "optional Bun host requires GLIBC_$required; CLI remains static musl. Use Node on this installation." |
| 308 | fi |
| 309 | fi |
| 310 | for companion in codewhale-extension-host codewhale-extension-host.LICENSES.txt codewhale-extension-host.relink-source.tar.gz codewhale-extension-host.release.json; do |
| 311 | mode=644 |
| 312 | [ "$companion" != codewhale-extension-host ] || mode=755 |
| 313 | check_destination "$install_dir/$companion" "$tmpdir/$companion" "$mode" |
| 314 | done |
| 315 | fi |
| 316 | |
| 317 | check_destination "$install_dir/codewhale" "$tmpdir/codewhale" |
| 318 | check_destination "$install_dir/codew" "$tmpdir/codew" |
| 319 | legacy_tui="$install_dir/codewhale-tui" |
| 320 | if [ -e "$legacy_tui" ] || [ -L "$legacy_tui" ]; then |
| 321 | check_destination "$legacy_tui" "$tmpdir/codewhale" |
| 322 | fi |
| 323 | |
| 324 | stage="" |
| 325 | stage_dir="" |
| 326 | # Commands this run published. If a later publication fails they are removed |
| 327 | # again, but only while each is still the exact file this run wrote, so a |
| 328 | # failed install leaves no half-installed pair and never touches a file that |
| 329 | # was already installed. |
| 330 | published="$tmpdir/.published" |
| 331 | : > "$published" |
| 332 | rollback_published() { |
| 333 | while IFS= read -r name; do |
| 334 | destination="$install_dir/$name" |
| 335 | if [ ! -L "$destination" ] && [ -f "$destination" ] && cmp -s "$tmpdir/$name" "$destination"; then |
| 336 | rm -f "$destination" |
| 337 | say "Removed $destination: this install did not complete." >&2 |
| 338 | fi |
| 339 | done < "$published" |
| 340 | } |
| 341 | on_exit() { |
| 342 | status=$? |
| 343 | if [ -n "$stage" ]; then rm -f "$stage"; fi |
| 344 | if [ -n "$stage_dir" ]; then rmdir "$stage_dir"; fi |
| 345 | if [ "$status" -ne 0 ]; then rollback_published; fi |
| 346 | rm -rf "$tmpdir" |
| 347 | } |
| 348 | trap on_exit EXIT |
| 349 | trap 'exit 130' INT |
| 350 | trap 'exit 143' TERM |
| 351 | install_binary() { |
| 352 | source="$1" |
| 353 | destination="$2" |
| 354 | # Recheck immediately before publication. Never replace a file another |
| 355 | # process created since preflight: linking the staged inode is no-clobber. |
| 356 | check_destination "$destination" "$source" "${3:-755}" |
| 357 | if [ "$destination_exists" -eq 1 ]; then |
| 358 | say "Already installed: $destination" |
| 359 | return |
| 360 | fi |
| 361 | stage_dir="$(mktemp -d "$install_dir/.codewhale-install.XXXXXX")" |
| 362 | stage="$stage_dir/$(basename "$destination")" |
| 363 | cp "$source" "$stage" |
| 364 | chmod "${3:-755}" "$stage" |
| 365 | # Pass the intended parent as the directory operand. Passing destination |
| 366 | # itself would make ln treat a raced-in directory/symlink as a container. |
| 367 | ln "$stage" "$install_dir/" || fail "destination appeared during install: $destination; it was not replaced" |
| 368 | [ ! -L "$destination" ] && [ -f "$destination" ] && cmp -s "$stage" "$destination" || fail "installed path changed during publication: $destination" |
| 369 | basename "$destination" >> "$published" |
| 370 | rm -f "$stage" |
| 371 | rmdir "$stage_dir" |
| 372 | stage="" |
| 373 | stage_dir="" |
| 374 | } |
| 375 | |
| 376 | install_binary "$tmpdir/codewhale" "$install_dir/codewhale" |
| 377 | install_binary "$tmpdir/codew" "$install_dir/codew" |
| 378 | if [ "${CODEWHALE_INSTALL_COMPILED_HOST:-}" = 1 ]; then |
| 379 | install_binary "$tmpdir/codewhale-extension-host" "$install_dir/codewhale-extension-host" |
| 380 | for companion in codewhale-extension-host.LICENSES.txt codewhale-extension-host.relink-source.tar.gz codewhale-extension-host.release.json; do |
| 381 | install_binary "$tmpdir/$companion" "$install_dir/$companion" 644 |
| 382 | done |
| 383 | say "Installed qualified opt-in image and its notice/relink-source closure; Node remains default." |
| 384 | fi |
| 385 | |
| 386 | say "Installed checksummed release commands:" |
| 387 | say " $install_dir/codewhale" |
| 388 | say " $install_dir/codew" |
| 389 | |
| 390 | say "" |
| 391 | say "Use this installation: \"$install_dir/codewhale\"" |
| 392 | say "Future updates: \"$install_dir/codewhale\" update" |
| 393 | path_selected=1 |
| 394 | for command_name in codewhale codew; do |
| 395 | resolved="$(command -v "$command_name" 2>/dev/null || true)" |
| 396 | if [ "$resolved" != "$install_dir/$command_name" ]; then |
| 397 | say "PATH selects ${resolved:-no $command_name command}; this install is $install_dir/$command_name" |
| 398 | path_selected=0 |
| 399 | fi |
| 400 | done |
| 401 | if [ "$path_selected" -eq 0 ]; then |
| 402 | # Print the persistent line for the user's login shell. The installer never |
| 403 | # edits shell profiles itself; the user runs the line once. |
| 404 | path_dir="$install_dir" |
| 405 | if [ -n "${HOME:-}" ] && [ "$install_dir" = "$(cd -P "$HOME/.local/bin" 2>/dev/null && pwd)" ]; then |
| 406 | path_dir="\$HOME/.local/bin" |
| 407 | fi |
| 408 | shell_name="${SHELL:-}" |
| 409 | shell_name="${shell_name##*/}" |
| 410 | say "" |
| 411 | case "$path_dir" in |
| 412 | *[\'\"\`\\\$]*|*" |
| 413 | "*) |
| 414 | # Only the literal $HOME form may carry a shell-special character. Any |
| 415 | # other one would break the printed quoting, or run as a command on |
| 416 | # every shell start once the line is in a profile. |
| 417 | if [ "$path_dir" != "\$HOME/.local/bin" ]; then |
| 418 | shell_name="unsafe-path" |
| 419 | fi |
| 420 | ;; |
| 421 | esac |
| 422 | case "$shell_name" in |
| 423 | fish) |
| 424 | say "Put $install_dir first on PATH in future shells (run once; this installer does not edit shell profiles):" |
| 425 | say " fish_add_path \"$path_dir\"" |
| 426 | say "It takes effect in this fish shell and in new ones (fish 3.2 or newer)." |
| 427 | ;; |
| 428 | zsh|bash|sh|dash|ksh|mksh|ash|"") |
| 429 | case "$shell_name" in |
| 430 | zsh) profile=".zshrc" ;; |
| 431 | bash) |
| 432 | case "$target" in |
| 433 | # Login bash reads the first of these that exists; creating |
| 434 | # ~/.bash_profile would stop an existing ~/.profile from loading. |
| 435 | macos-*) |
| 436 | profile=".bash_profile" |
| 437 | for candidate in .bash_profile .bash_login .profile; do |
| 438 | if [ -n "${HOME:-}" ] && [ -e "$HOME/$candidate" ]; then |
| 439 | profile="$candidate" |
| 440 | break |
| 441 | fi |
| 442 | done |
| 443 | ;; |
| 444 | *) profile=".bashrc" ;; |
| 445 | esac |
| 446 | ;; |
| 447 | *) profile=".profile" ;; |
| 448 | esac |
| 449 | say "Put $install_dir first on PATH in future shells (run once; this installer does not edit shell profiles):" |
| 450 | say " echo 'export PATH=\"$path_dir:\$PATH\"' >> ~/$profile" |
| 451 | say "Then run: . ~/$profile (or open a new terminal)" |
| 452 | say "Or for this shell only:" |
| 453 | say " export PATH=\"$path_dir:\$PATH\"; hash -r" |
| 454 | ;; |
| 455 | unsafe-path) |
| 456 | say "Add $install_dir first to PATH in your shell's startup file; its name contains shell-special characters, so no command line is printed for it." |
| 457 | ;; |
| 458 | *) |
| 459 | say "Add $install_dir first to PATH in your shell's startup file; this installer has no PATH line for $shell_name." |
| 460 | ;; |
| 461 | esac |
| 462 | say "Verify: command -v codewhale codew" |
| 463 | say "PATH help: https://github.com/codewhale-hq/CodeWhale/blob/main/docs/INSTALL.md#put-it-on-your-path" |
| 464 | fi |
| 465 | if ! command -v node >/dev/null 2>&1; then |
| 466 | say "Computer Use is included and needs Node.js 20 or newer on PATH." |
| 467 | say "Install Node.js from https://nodejs.org/, then restart Codewhale to enable Computer Use." |
| 468 | fi |
| 469 |