#!/usr/bin/env bash # # screenless installer. # # curl -fsSL https://screenless.sh/install | bash # # Drops the CLI in ~/.screenless, puts a launcher on PATH, installs the skills # into every coding agent it finds, and — when there is a terminal to talk to — # goes straight into `screenless setup`, which verifies your phone number. One # command, from nothing to a working install; the line itself is armed # afterwards, in your agent, with `/screenless`. # # Deliberately does not install Node. A tool that silently mutates your # toolchain is a tool you cannot reason about later; if Node is missing we say # so and stop. set -euo pipefail readonly BASE_URL="${SCREENLESS_BASE_URL:-https://screenless.sh}" readonly INSTALL_DIR="${SCREENLESS_INSTALL_DIR:-$HOME/.screenless}" readonly LIB_DIR="$INSTALL_DIR/lib" readonly BIN_DIR="$INSTALL_DIR/bin" readonly MIN_NODE=20 # Colour only when a terminal is actually watching. Piped into a log, the # escape codes are noise. if [ -t 1 ]; then bold=$'\033[1m'; dim=$'\033[2m'; red=$'\033[31m'; green=$'\033[32m'; reset=$'\033[0m' else bold=''; dim=''; red=''; green=''; reset='' fi say() { printf '%s\n' "$*"; } ok() { printf '%s✓%s %s\n' "$green" "$reset" "$*"; } die() { printf '%s✗%s %s\n' "$red" "$reset" "$*" >&2; exit 1; } # ---------------------------------------------------------------- preflight case "$(uname -s)" in Darwin|Linux) ;; *) die "unsupported platform: $(uname -s). On Windows, use WSL." ;; esac command -v node >/dev/null 2>&1 || die "Node ${MIN_NODE}+ is required but not installed. macOS: brew install node Linux: https://nodejs.org/en/download Then run this again." node_major="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)" if [ "$node_major" -lt "$MIN_NODE" ]; then die "Node ${MIN_NODE}+ is required, found $(node -v). Upgrade Node and run this again." fi command -v tar >/dev/null 2>&1 || die "tar is required but not installed." # Not fatal. The CLI is useful on its own — it places calls and parks editions — # but the loop that writes them is a Claude Code skill, so say plainly what will # not work rather than installing a skill nothing can run. have_claude=1 command -v claude >/dev/null 2>&1 || have_claude=0 if command -v curl >/dev/null 2>&1; then fetch() { curl -fsSL "$1" -o "$2"; } elif command -v wget >/dev/null 2>&1; then fetch() { wget -qO "$2" "$1"; } else die "curl or wget is required." fi # ------------------------------------------------------------------ install say "" say "${bold}screenless${reset} ${dim}— a phone line for your team and its coding agents${reset}" say "" tmp="$(mktemp -d)" # Runs on failure too, so a half-downloaded tarball never survives the script. trap 'rm -rf "$tmp"' EXIT fetch "$BASE_URL/screenless.tar.gz" "$tmp/screenless.tar.gz" \ || die "could not download the CLI from $BASE_URL" # Only lib/ is replaced. The config file next to it holds a session token and # a verified phone number, and reinstalling is not a reason to lose either. rm -rf "$LIB_DIR" mkdir -p "$LIB_DIR" "$BIN_DIR" tar -xzf "$tmp/screenless.tar.gz" -C "$LIB_DIR" || die "the download was corrupt — try again" [ -f "$LIB_DIR/index.js" ] || die "the download was incomplete — try again" # A launcher rather than a symlink into lib/: this way `screenless` works even # where the shebang's `env node` resolves differently, and the path it execs is # fixed at install time. cat > "$BIN_DIR/screenless" </dev/null 2>&1; then say " ${dim}installing skills via skills.sh …${reset}" # --all: every skill into every detected agent · -g: global · -y: no prompts. # /dev/null 2>&1; then ok "skills installed via ${dim}npx skills add janwilmake/screenless${reset}" SKILLS_DONE=1 fi fi install_skills() { # $1 = an agent's global skills dir (fallback path) local root="$1" [ -f "$tmp/skill/SKILL.md" ] || return 0 rm -rf "$root/screenless" "$root/call-when-afk" "$root/morning-pr-review" mkdir -p "$root/screenless" "$root/call-when-afk" # screenless — the watcher, the core skill cp "$tmp/skill/SKILL.md" "$root/screenless/SKILL.md" [ -f "$tmp/skill/APPLY.md" ] && cp "$tmp/skill/APPLY.md" "$root/screenless/APPLY.md" # call-when-afk [ -f "$tmp/skill/CALL-WHEN-AFK.md" ] && cp "$tmp/skill/CALL-WHEN-AFK.md" "$root/call-when-afk/SKILL.md" # morning-pr-review — the optional nightly loop, with its press toolkit at the # path the skill spells out. if [ -f "$tmp/skill/MORNING-PR-REVIEW.md" ]; then mkdir -p "$root/morning-pr-review" cp "$tmp/skill/MORNING-PR-REVIEW.md" "$root/morning-pr-review/SKILL.md" if [ -f "$tmp/press.tar.gz" ]; then mkdir -p "$root/morning-pr-review/press" tar -xzf "$tmp/press.tar.gz" -C "$root/morning-pr-review/press" 2>/dev/null || rm -rf "$root/morning-pr-review/press" fi fi } # Fallback: only if the real CLI didn't run. Drop the bundled copies into each # agent's global skills dir, detecting agents by their home directory. if [ "${SKILLS_DONE:-0}" -ne 1 ]; then installed_agents="" for spec in \ "Claude Code:$HOME/.claude:$HOME/.claude/skills" \ "Cursor:$HOME/.cursor:$HOME/.cursor/skills" \ "Codex:$HOME/.codex:$HOME/.codex/skills" \ "Windsurf:$HOME/.windsurf:$HOME/.windsurf/skills" \ "Gemini:$HOME/.gemini:$HOME/.gemini/skills" \ "OpenCode:$HOME/.opencode:$HOME/.opencode/skills" ; do name="${spec%%:*}"; rest="${spec#*:}"; detect="${rest%%:*}"; dir="${rest#*:}" if [ -d "$detect" ]; then install_skills "$dir" installed_agents="$installed_agents $name," fi done [ -d "$HOME/.claude" ] || { install_skills "$HOME/.claude/skills"; installed_agents="$installed_agents Claude Code,"; } if [ -n "$installed_agents" ]; then agents_list="$(printf '%s' "$installed_agents" | sed 's/,$//; s/^ //')" ok "skills installed for:${dim}$agents_list${reset} ${dim}(offline copy)${reset}" fi fi if [ "$have_claude" -eq 0 ]; then say " ${dim}no coding agent found — screenless arms inside one (e.g. Claude Code): https://claude.com/claude-code${reset}" fi # ------------------------------------------------------- old scheduler # Before 20 August the installer scheduled two launchd jobs that ran `claude -p` # outside any session. They could not read a repo under ~/Desktop (macOS # denies that to anything launchd starts), could not approve a tool call, and # had no MCPs, so they fired every night and produced nothing. The loop now # runs inside a Claude Code session, armed with `/screenless` (and the optional # `/morning-pr-review`); anything # the old installer left behind is removed so it cannot keep firing. for label in com.screenless.nightly com.screenless.collect; do plist="$HOME/Library/LaunchAgents/$label.plist" if [ -f "$plist" ]; then command -v launchctl >/dev/null 2>&1 && launchctl unload "$plist" >/dev/null 2>&1 || true rm -f "$plist" say " ${dim}removed the old launchd job $label${reset}" fi done rm -f "$BIN_DIR/nightly.sh" "$BIN_DIR/collect.sh" # --------------------------------------------------------------------- PATH on_path=0 case ":$PATH:" in *":$BIN_DIR:"*) on_path=1 ;; esac if [ "$on_path" -eq 0 ]; then # Written to the rc file for future shells; the current one is handled by the # explicit export below, since a sourced rc cannot reach back into it. line="export PATH=\"$BIN_DIR:\$PATH\"" case "${SHELL##*/}" in zsh) rc="$HOME/.zshrc" ;; bash) if [ "$(uname -s)" = "Darwin" ]; then rc="$HOME/.bash_profile"; else rc="$HOME/.bashrc"; fi ;; fish) rc="$HOME/.config/fish/config.fish"; line="fish_add_path $BIN_DIR" ;; *) rc="" ;; esac if [ -n "$rc" ]; then mkdir -p "$(dirname "$rc")" if ! grep -qF "$BIN_DIR" "$rc" 2>/dev/null; then printf '\n# screenless\n%s\n' "$line" >> "$rc" ok "added to PATH in ${dim}$rc${reset}" fi else say " ${dim}add this to your shell profile:${reset} export PATH=\"$BIN_DIR:\$PATH\"" fi export PATH="$BIN_DIR:$PATH" fi # -------------------------------------------------------------------- setup # Piped through bash, stdin is the script itself, so setup's prompts have to # read from the terminal directly. When there isn't one — CI, a Dockerfile, # someone's provisioning script — we stop here and say what comes next. if [ "${SCREENLESS_NO_SETUP:-}" = "1" ] || [ ! -t 1 ] || [ ! -r /dev/tty ]; then say "" say "Next: ${bold}screenless setup${reset} ${dim}— verify your number${reset}" say "Then, in your agent: ${bold}/screenless${reset} ${dim}— arm the watcher first, and leave it open${reset}" say " ${dim}(it points at the repo you're in, and takes the team's calls)${reset}" say "" exit 0 fi say "" say "${bold}Setting up.${reset} ${dim}A code by SMS, then a card for the 7-day free trial.${reset}" say "" exec "$BIN_DIR/screenless" setup < /dev/tty