← Files JuicyLucy AdsARCHIVED FILE

skills/juicylucy-setup/scripts/install.sh

21 KB · Oct 4, 2026 · 12:34 UTC

↓ Download file

#!/bin/sh
# JuicyLucy: install everything ad production needs, under ~/.juicylucy, with no
# administrator password. The one thing written anywhere else is the skill
# describing `juicy`, which lands in ~/.agents/skills (see the juicy note).
#
# Three deliberate choices:
#
#   * Node comes from the official TARBALL, not the .pkg installer. The .pkg
#     writes to /usr/local and demands an admin password; the tarball unpacks
#     into a folder we own. Same binaries, no prompt.
#   * ffmpeg and ffprobe come from npm rather than Homebrew, because
#     `HYPERFRAMES_FFMPEG_PATH` / `HYPERFRAMES_FFPROBE_PATH` let hyperframes use
#     any binary we point it at. Verified working.
#     ffprobe comes from @ffprobe-installer, NOT from ffprobe-static: the
#     latter's "darwin/arm64" binary is actually x86_64 (checked with `file`),
#     so on Apple Silicon it runs only under Rosetta — which a fresh Mac may not
#     have, and installing Rosetta needs the admin password this whole design
#     avoids. @ffprobe-installer ships a real arm64 build, and LGPL-2.1 rather
#     than GPL. ffmpeg-static's binary IS native arm64, so it stays; it is
#     GPL-3.0-or-later, which is fine to install here but never to redistribute.
#   * Chrome is left to `hyperframes browser ensure`, which already knows how to
#     find or fetch the exact build the renderer expects.
#   * `juicy`, the command every image, video and music generation goes
#     through, is an npm package installed the same way as hyperframes, at the
#     NEWEST published version — the registry is asked every time this step
#     runs, which is how an update happens. The skill that tells the agent how
#     to use it is then generated by that same binary (`juicy skill --dir`)
#     into ~/.agents/skills/juicy-cli: Codex reads a user's own skills there,
#     and a skill with a shipped skill's name replaces the shipped one, so the
#     flags the agent reads are always the flags of the command it runs. The
#     plugin's own juicy-cli skill is the fallback until this has run. `juicy`
#     carries no credential; the user signs in once and the session lives
#     under ~/.juicylucy/juicy/.
#   * Python comes as a standalone build from python-build-standalone, the same
#     way Node comes as a tarball. A fresh Mac's /usr/bin/python3 is a stub that
#     opens Apple's Command Line Tools dialog the first time it runs — an
#     interactive install a non-technical user cannot be sent to Terminal for.
#
# npm is anchored with a package.json written into JUICYLUCY_HOME FIRST. Without
# it npm walks UP the directory tree looking for a package root, and a user with
# a ~/package.json — which is common — gets hyperframes' whole dependency tree
# installed into ~/node_modules instead. Observed on a real machine, not
# theoretical: it mixed ~60 MB of our transitive deps into someone's unrelated
# project.
#
# Idempotent: every step checks first, so re-running after a failure is safe.
# It edits no config file — it prints the environment block instead, because
# rewriting someone's config.toml unattended is not a risk worth taking.
#
#   sh install.sh
#   sh install.sh --only node|tools|juicy|chrome|python

set -eu

# Codex does not expand ${PATH} in config.toml, and a block written by an
# earlier version of this setup ended in exactly that — so every command Codex
# ran had our three directories and nothing else, not even /usr/bin. Put the
# system directories back so this script, and curl, tar and shasum, run
# regardless of what the current config says.
PATH="$PATH:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export PATH

JUICYLUCY_HOME="${JUICYLUCY_HOME:-$HOME/.juicylucy}"
NODE_TRAIN="${JUICYLUCY_NODE_TRAIN:-latest-v22.x}"
# The hyperframes version the shipped skills were written against — the same
# value as `cli` in the repo's vendor/hyperframes/UPSTREAM.lock, kept in step
# by a test. The skills describe THIS CLI; a newer one may not match them.
HYPERFRAMES_VERSION="${JUICYLUCY_HYPERFRAMES_VERSION:-0.8.71}"
# What npm fetches for juicy. Deliberately NOT a pin: the newest published
# version, every time, and the skill describing it is generated from the
# installed binary right after (install_juicy). A path to a packed tarball lets
# an unpublished build be tested through this script.
JUICY_CLI_SPEC="${JUICYLUCY_JUICY_CLI_SPEC:-@juicylucy/cli@latest}"
# Where Codex reads a user's own skills, and where the generated juicy-cli
# skill goes. The doctor reads the same folder. It is outside ~/.juicylucy, so
# it needs its own entry in the sandbox's writable_roots (printed below):
# measured on codex-cli 0.154.0, a workspace-write sandbox whose roots list
# only ~/.juicylucy refuses the write here with "Operation not permitted".
AGENT_SKILLS="$HOME/.agents/skills"
# Its physical path for config.toml, whether or not it exists yet.
agent_skills_real() {
  if [ -d "$AGENT_SKILLS" ]; then (cd "$AGENT_SKILLS" && pwd -P)
  else printf '%s/.agents/skills' "$(cd "$HOME" && pwd -P)"; fi
}
NODE_MIN=22
ONLY=""
FORCE_NODE=0
for arg in "$@"; do
  [ "$arg" = "--force-node" ] && FORCE_NODE=1
done
[ "${1:-}" = "--only" ] && ONLY="${2:-}"

wanted() { [ -z "$ONLY" ] || [ "$ONLY" = "$1" ]; }
step() { printf '\n▶ %s\n' "$*"; }

ARCH=$(uname -m)
if [ "$ARCH" = "x86_64" ]; then NODE_ARCH=x64; FF_ARCH=x64; else NODE_ARCH=arm64; FF_ARCH=arm64; fi

mkdir -p "$JUICYLUCY_HOME"

# ── Node ──────────────────────────────────────────────────────────────────────
# Major version of a node binary, or 0 if it is not usable.
node_major() {
  [ -x "${1:-}" ] || { printf '0'; return 0; }
  ver=$("$1" --version 2>/dev/null | tr -d 'v')
  printf '%s' "${ver%%.*}"
}

install_node() {
  if [ -x "$JUICYLUCY_HOME/node/bin/node" ]; then
    printf '  already present: %s\n' "$("$JUICYLUCY_HOME/node/bin/node" --version)"
    return 0
  fi

  # Don't download 50 MB to duplicate a Node that is already here and new
  # enough. Asking a non-technical user to approve that reads as busywork, and
  # it is. --force-node overrides, for a machine whose Node is likely to move.
  system_node=$(command -v node 2>/dev/null || true)
  if [ "$FORCE_NODE" -eq 0 ] && [ "$(node_major "$system_node")" -ge "$NODE_MIN" ] 2>/dev/null; then
    printf '  using the Node already on this machine: %s (%s)\n' "$("$system_node" --version)" "$system_node"
    return 0
  fi

  # SHASUMS256.txt names the current build of the train and gives its checksum,
  # so we never hardcode a version that will rot.
  base="https://nodejs.org/dist/$NODE_TRAIN"
  line=$(curl -fsSL "$base/SHASUMS256.txt" | grep "darwin-$NODE_ARCH.tar.gz$" | head -1)
  [ -n "$line" ] || { echo "could not find a darwin-$NODE_ARCH build in $base" >&2; return 1; }
  sum=${line%% *}
  file=${line##* }

  printf '  downloading %s\n' "$file"
  tmp=$(mktemp -d)
  curl -fsSL -o "$tmp/$file" "$base/$file"

  printf '  verifying checksum\n'
  actual=$(shasum -a 256 "$tmp/$file" | cut -d' ' -f1)
  [ "$actual" = "$sum" ] || { echo "checksum mismatch for $file — refusing to install" >&2; rm -rf "$tmp"; return 1; }

  rm -rf "$JUICYLUCY_HOME/node"
  mkdir -p "$JUICYLUCY_HOME/node"
  tar xzf "$tmp/$file" -C "$JUICYLUCY_HOME/node" --strip-components=1
  rm -rf "$tmp"
  printf '  installed %s\n' "$("$JUICYLUCY_HOME/node/bin/node" --version)"
}

# ── hyperframes + the encoders ────────────────────────────────────────────────
# Wherever the Node we settled on lives — ours if we installed one, otherwise
# the machine's.
node_dir() {
  if [ -x "$JUICYLUCY_HOME/node/bin/node" ]; then
    printf '%s' "$JUICYLUCY_HOME/node/bin"
  else
    dirname "$(command -v node)"
  fi
}

# THE ANCHOR. npm resolves the package root by walking up from the working
# directory, so without a package.json of our own it can adopt ~/package.json
# and install into ~/node_modules. Writing one first pins it here. Both npm
# installs below go through this.
anchor_npm() {
  [ -f "$JUICYLUCY_HOME/package.json" ] || cat > "$JUICYLUCY_HOME/package.json" <<'JSON'
{
  "name": "juicylucy-tools",
  "private": true,
  "description": "Local toolchain for JuicyLucy ad production. Managed by /juicylucy-setup; safe to delete this whole folder."
}
JSON
}

install_tools() {
  bindir=$(node_dir)
  [ -x "$bindir/node" ] || { echo "Node is not available yet — run with --only node first" >&2; return 1; }
  anchor_npm

  # npm's launcher carries a `#!/usr/bin/env node` shebang, so the Node we chose
  # has to be on PATH for it — an absolute path to node is not enough.
  printf '  installing hyperframes and the video encoders (a few hundred MB)\n'
  ( cd "$JUICYLUCY_HOME" \
      && PATH="$bindir:$PATH" npm install --silent --no-audit --no-fund --save-exact \
        "hyperframes@$HYPERFRAMES_VERSION" ffmpeg-static @ffprobe-installer/ffprobe )

  # Verify rather than announce. The previous version fell back to printing
  # "installed" when the version call failed, so a install that put its files
  # somewhere else still reported success.
  hf="$JUICYLUCY_HOME/node_modules/.bin/hyperframes"
  [ -x "$hf" ] || {
    echo "install finished but $hf is not there — the packages landed somewhere else" >&2
    return 1
  }
  printf '  hyperframes %s\n' "$(PATH="$bindir:$PATH" "$hf" --version)"

  link_encoders
}

# ── juicy ─────────────────────────────────────────────────────────────────────
# The generation command. Same npm, same anchor, one pinned version, and a
# shim in bin/ beside ffmpeg so one directory on PATH carries every tool the
# skills call by bare name. No credential is written here: the user signs in
# afterwards (the setup skill § Signing in) and `juicy` keeps the session in
# ~/.juicylucy/juicy/credentials, mode 600, on this machine only.
install_juicy() {
  bindir=$(node_dir)
  [ -x "$bindir/node" ] || { echo "Node is not available yet — run with --only node first" >&2; return 1; }
  anchor_npm

  printf '  installing the newest juicy (the command that generates images, video and music)\n'
  ( cd "$JUICYLUCY_HOME" \
      && PATH="$bindir:$PATH" npm install --silent --no-audit --no-fund --save-exact "$JUICY_CLI_SPEC" )

  juicy="$JUICYLUCY_HOME/node_modules/.bin/juicy"
  [ -x "$juicy" ] || {
    echo "install finished but $juicy is not there — the package landed somewhere else" >&2
    return 1
  }
  mkdir -p "$JUICYLUCY_HOME/bin"
  ln -sf "$juicy" "$JUICYLUCY_HOME/bin/juicy"

  # Verify through the shim: "installed" is not the same as "runs".
  got=$(PATH="$bindir:$PATH" "$JUICYLUCY_HOME/bin/juicy" --version 2>/dev/null \
    | sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' | head -1)
  case "$got" in
    [0-9]*.[0-9]*.[0-9]*) ;;
    *) echo "juicy is linked but reports no version (got '${got:-nothing}') — refusing to call this installed" >&2; return 1 ;;
  esac
  printf '  juicy %s linked into %s/bin\n' "$got" "$JUICYLUCY_HOME"

  install_juicy_skill "$bindir" "$got"
}

# The skill that tells the agent how to use juicy, written by juicy itself from
# the command specs its parser runs on — so it cannot describe flags this
# binary does not have. It goes to the user's own skill root, where its name
# replaces the plugin's copy (which describes whatever version the plugin was
# built against). Generated into a scratch folder first and moved into place
# whole, so a failure leaves whatever was there rather than half a skill.
install_juicy_skill() {
  tmp=$(mktemp -d)
  PATH="$1:$PATH" "$JUICYLUCY_HOME/bin/juicy" skill --dir "$tmp" >/dev/null || {
    echo "juicy skill --dir failed — the plugin's copy of the juicy-cli skill stays in use" >&2
    rm -rf "$tmp"; return 1
  }
  [ -f "$tmp/juicy-cli/SKILL.md" ] || { echo "juicy wrote no juicy-cli/SKILL.md" >&2; rm -rf "$tmp"; return 1; }
  mkdir -p "$AGENT_SKILLS"
  rm -rf "$AGENT_SKILLS/juicy-cli"
  mv "$tmp/juicy-cli" "$AGENT_SKILLS/juicy-cli"
  rm -rf "$tmp"
  printf '  wrote the skill describing juicy %s to %s/juicy-cli — Codex reads it at its next launch\n' "$2" "$AGENT_SKILLS"
}

# ── encoder shims ─────────────────────────────────────────────────────────────
# `ffmpeg-static` and `@ffprobe-installer/ffprobe` expose their binaries as
# package exports and declare no `bin`, so npm creates no shim and
# node_modules/.bin gets `hyperframes` but no `ffmpeg`. The env block then puts
# our Node and hyperframes on PATH while bare `ffmpeg` still resolves to
# whatever the system has — Homebrew's build, or nothing at all.
#
# That is not academic: the ad skills tell the agent to run ffmpeg and ffprobe
# directly (probe a reference, dump a contact sheet, pull a first frame, check a
# render is not silent). Observed on a real run — the agent reached Homebrew's
# ffmpeg, which is built without libfreetype, hit "drawtext: Filter not found",
# and abandoned the framework rather than the binary. On a machine set up by
# this script there is no Homebrew ffmpeg at all, so those same commands would
# simply not be found.
link_encoders() {
  ffmpeg_bin="$JUICYLUCY_HOME/node_modules/ffmpeg-static/ffmpeg"
  ffprobe_bin="$JUICYLUCY_HOME/node_modules/@ffprobe-installer/darwin-$FF_ARCH/ffprobe"

  mkdir -p "$JUICYLUCY_HOME/bin"
  for pair in "ffmpeg:$ffmpeg_bin" "ffprobe:$ffprobe_bin"; do
    name=${pair%%:*}
    target=${pair#*:}
    [ -x "$target" ] || {
      echo "expected $name at $target, but it is not there" >&2
      return 1
    }
    ln -sf "$target" "$JUICYLUCY_HOME/bin/$name"
  done

  # Verify through the shim, not the target: a symlink that resolves to nothing
  # still looks fine in `ls`.
  "$JUICYLUCY_HOME/bin/ffmpeg" -hide_banner -version >/dev/null 2>&1 || {
    echo "the ffmpeg shim does not run — $JUICYLUCY_HOME/bin/ffmpeg" >&2
    return 1
  }
  printf '  ffmpeg and ffprobe linked into %s/bin\n' "$JUICYLUCY_HOME"
}

# ── Python QA (statics) ──────────────────────────────────────────────────────
# The statics engine's QA scripts (contact sheets, campaign verification) are
# Python with one dependency, Pillow. The interpreter is OUR OWN: a relocatable
# CPython from python-build-standalone, unpacked under JUICYLUCY_HOME like Node.
#
# Not the Mac's python3, on purpose. On a machine without Apple's Command Line
# Tools, /usr/bin/python3 is a stub whose first run opens a GUI installer and
# tells the user to complete it in Terminal — the one thing this setup promises
# never to do. A private interpreter needs no venv either: nothing else can
# install into it. One shim, `adspython`, exposes it beside ffmpeg in bin/.
#
# The release is pinned. Old releases stay downloadable, the asset names are
# stable within one, and Pillow does not care which 3.12 it runs on — so a pin
# cannot rot the way a floating "latest" can break. Bump PY_RELEASE deliberately.
PY_RELEASE="${JUICYLUCY_PY_RELEASE:-20260901}"
PY_SERIES="3.12"

install_python() {
  py="$JUICYLUCY_HOME/python/bin/python3"
  if [ ! -x "$py" ]; then
    [ "$ARCH" = "x86_64" ] && py_arch=x86_64 || py_arch=aarch64
    base="https://github.com/astral-sh/python-build-standalone/releases/download/$PY_RELEASE"

    # SHA256SUMS names the exact asset and gives its checksum, so nothing here
    # hardcodes a patch version and nothing unverified is unpacked.
    line=$(curl -fsSL "$base/SHA256SUMS" \
      | grep -E "cpython-${PY_SERIES}\.[0-9]+\+${PY_RELEASE}-${py_arch}-apple-darwin-install_only_stripped\.tar\.gz$" \
      | head -1)
    [ -n "$line" ] || { echo "no ${PY_SERIES} build for ${py_arch} in python-build-standalone $PY_RELEASE" >&2; return 1; }
    sum=${line%% *}
    file=${line##* }

    printf '  downloading %s (about 25 MB)\n' "$file"
    tmp=$(mktemp -d)
    curl -fsSL -o "$tmp/$file" "$base/$file"

    printf '  verifying checksum\n'
    actual=$(shasum -a 256 "$tmp/$file" | cut -d' ' -f1)
    [ "$actual" = "$sum" ] || { echo "checksum mismatch for $file — refusing to install" >&2; rm -rf "$tmp"; return 1; }

    rm -rf "$JUICYLUCY_HOME/python"
    mkdir -p "$JUICYLUCY_HOME/python"
    tar xzf "$tmp/$file" -C "$JUICYLUCY_HOME/python" --strip-components=1
    rm -rf "$tmp"
    [ -x "$py" ] || { echo "unpacked, but $py is not there" >&2; return 1; }
    printf '  installed %s\n' "$("$py" --version)"
  else
    printf '  already present: %s\n' "$("$py" --version)"
  fi

  printf '  installing Pillow\n'
  "$py" -m pip install --quiet --disable-pip-version-check "pillow>=10,<12"

  # The shim is a wrapper script, NOT a symlink: a symlink would resolve to a
  # path the interpreter no longer recognises as its own home.
  mkdir -p "$JUICYLUCY_HOME/bin"
  printf '#!/bin/sh\nexec "%s" "$@"\n' "$py" > "$JUICYLUCY_HOME/bin/adspython"
  chmod +x "$JUICYLUCY_HOME/bin/adspython"

  # Verify through the shim: it must import the one dependency the scripts need.
  "$JUICYLUCY_HOME/bin/adspython" -c "import PIL" 2>/dev/null || {
    echo "adspython cannot import Pillow — the install failed" >&2
    return 1
  }
  printf '  adspython ready: %s\n' "$("$JUICYLUCY_HOME/bin/adspython" --version 2>&1)"
}

# ── Chrome ────────────────────────────────────────────────────────────────────
install_chrome() {
  hf="$JUICYLUCY_HOME/node_modules/.bin/hyperframes"
  [ -x "$hf" ] || { echo "hyperframes is not installed yet — run with --only tools first" >&2; return 1; }
  printf '  asking hyperframes to find or download Chrome\n'
  PATH="$(node_dir):$PATH" "$hf" browser ensure
}

# The complete PATH for config.toml. Codex takes the value literally — it does
# not expand ${PATH} — so the system directories have to be spelled out, after
# ours. Our bin/ goes FIRST, ahead even of the Node directory: when the Node in
# use is Homebrew's, its directory is also where Homebrew's ffmpeg lives, and
# that build lacks drawtext. Homebrew's bin is kept, last, when it exists and
# is not already on the list.
full_path() {
  p="$JUICYLUCY_HOME/bin:$(node_dir):$JUICYLUCY_HOME/node_modules/.bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
  case ":$p:" in
    *:/opt/homebrew/bin:*) ;;
    *) [ -d /opt/homebrew/bin ] && p="$p:/opt/homebrew/bin" ;;
  esac
  printf '%s' "$p"
}

wanted node && { step "Node"; install_node; }
wanted tools && { step "hyperframes and the video encoders"; install_tools; }
wanted juicy && { step "juicy, the generation command"; install_juicy; }
wanted chrome && { step "headless Chrome"; install_chrome; }
wanted python && { step "Python QA (statics)"; install_python; }

# ── what the caller still has to do ───────────────────────────────────────────
cat <<EOF

▶ Done installing. Three things left, and none is a file this script should write.

1. Merge these into the [shell_environment_policy.set] table in ~/.codex/config.toml
   (merge into the existing table — never add a second one with the same name):

  HYPERFRAMES_FFMPEG_PATH = "$JUICYLUCY_HOME/node_modules/ffmpeg-static/ffmpeg"
  HYPERFRAMES_FFPROBE_PATH = "$JUICYLUCY_HOME/node_modules/@ffprobe-installer/darwin-$FF_ARCH/ffprobe"
  HYPERFRAMES_SKIP_SKILLS = "1"
  HYPERFRAMES_NO_TELEMETRY = "1"
  PATH = "$(full_path)"

   That PATH is the whole list, not an addition: Codex does not expand \${PATH},
   so a value that ends in it leaves every command without /usr/bin.

2. In the same file, open Codex's sandbox for juicy: its default blocks every
   network host and every write outside the project folder, so juicy could
   neither reach the generation service, nor keep its session and cache under
   $JUICYLUCY_HOME, nor write its own skill into $AGENT_SKILLS. With these
   two lines it can, and no call needs approving one by one:

  [sandbox_workspace_write]
  network_access = true
  writable_roots = ["$(cd "$JUICYLUCY_HOME" && pwd -P)", "$(agent_skills_real)"]

   Merge into that table if it already exists; if writable_roots is already
   there, add these paths to the list rather than replacing it. The paths are
   the folders' real ones: Codex refuses a root that resolves through a symlink.

3. Sign the user in to JuicyLucy. The method belongs to juicy and will change,
   so do not assume one — run

  $JUICYLUCY_HOME/bin/juicy auth help

   and follow what it prints: what to ask the user for, the command to run, what
   never to do with what they gave you. The session lands in
   $JUICYLUCY_HOME/juicy/credentials on this machine. Nothing about it goes in
   config.toml, and nothing sends the user to Terminal.

Before restarting, re-run doctor.sh: its config and network lines read config.toml
and catch a wrong PATH or a missing switch now, while it is still cheap to fix;
its juicy-login line says whether the sign-in took.

Then RESTART CODEX — once. config.toml is read at startup, so none of the above
is in effect until you quit Codex and open it again.

After the restart, re-run doctor.sh. Every line should read ok.
EOF

SHA-256: 12c3277c2217521d56d8c4ba08c51deb802d3aa4b9105e8090798b3e04de087a