← Files JuicyLucy AdsARCHIVED FILE
skills/juicylucy-setup/scripts/install.sh
21 KB · Oct 3, 2026 · 06:35 UTC
#!/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