#!/bin/bash # ───────────────────────────────────────────────────────────────────────────── # Grace installer for macOS (Apple silicon) https://parsimic.com/download # # /bin/bash -c "$(curl -fsSL https://parsimic.com/install.sh)" # # What it does — it asks before it installs anything: # 1. Checks this Mac: Apple silicon, free disk, Claude Code. # 2. Signs this Mac in to your Grace account: it shows a code, you confirm it at # parsimic.com/geraet. The sign-in is kept in your Keychain, so this happens once. # A sign-in belongs to this Mac: the installer sends a one-way ID of the Mac # (a SHA-256 of the sign-in and the Mac's hardware UUID, never the UUID itself). # 3. Checks that the account has an active Grace subscription or a free day # (first day free, started with one click when you confirm the code, or at # parsimic.com/gratistag; one free day per Mac — for that it also sends a second # one-way ID: a SHA-256 of the hardware UUID alone). # Without either it stops here and downloads nothing. # 4. Offers to install what Grace needs: Node.js, using Homebrew. # 5. Downloads Grace, verifies its size and SHA-256, registers it with # Claude Code as the plugin "grace", and checks that Grace sees the sign-in. # 6. Asks where your Claude projects are (optional), so Grace builds new ones there. # # Run the same command again at any time to update Grace. # The installer never uninstalls or deletes anything of yours, never uses sudo # itself (only Homebrew's own installer may ask for your password), and never # prints your device key. # # Deutsch: Installiert Grace für Claude Code. Fragt vor jeder Installation. # Ohne aktives Abo oder Gratistag wird nichts geladen. Erneut ausführen = aktualisieren. # ───────────────────────────────────────────────────────────────────────────── if [ -z "${BASH_VERSION:-}" ]; then # shellcheck disable=SC2016 # the command is meant literally echo 'Please run the Grace installer with bash: /bin/bash -c "$(curl -fsSL https://parsimic.com/install.sh)"' >&2 exit 1 fi set -euo pipefail # Files the installer itself writes (temp files, the Grace folder, a new ~/.zprofile) # are private to you. Programs of others (Homebrew, Node.js, Claude Code) run # with the umask your Terminal had — see fremd(). URSPRUNG_UMASK="$(umask)" umask 077 # ── Fixed settings ─────────────────────────────────────────────────────────── GRACE_SEITE_FEST="https://parsimic.com" INSTALLER_FASSUNG="Grace installer 2" # The Keychain item the Grace plugin reads (WebAI-dev packages/security/src/keychain.ts). KC_KONTO="grace.token" KC_DIENST="WebAI:grace.token" KC_ART="WebAI" # Written by /usr/bin/security, so the plugin (which also uses /usr/bin/security) can read it without a prompt. SECURITY_BIN="/usr/bin/security" CLAUDE_INSTALL_SEITE="https://claude.com/claude-code" # ── State (set -u needs every name defined) ───────────────────────────────── SEITE="" TEST=0 CURL_PROTO="=https" M="" MAC="" # Free day (Gratistag): sha256("grace-test-v1:") — the same for every account on this Mac, # so that each Mac gets one free day. Sent only to /api/download*, never stored on this Mac. TEST_MAC="" # 1 = the account uses its free day (server: status "test"); GRATISTAG_BIS = its end (ISO, UTC), once started. GRATISTAG=0 GRATISTAG_BIS="" # Must match src/lib/gratistag.ts → GRATISTAG_STUNDEN / ENTFERNEN_NACH_STUNDEN (scripts/test-download.mjs checks it). GRATISTAG_STUNDEN=24 KONTO="" HTTP_CODE="" HTTP_BODY="" TMPD="" ZIEL="" CLAUDE_BIN="" INSTALLIERT="" # 1 = the server says this subscription starts with a download (download_pflicht): download # Grace even if the same version is already installed (an install from an earlier # subscription does not start a new one). DOWNLOAD_PFLICHT=0 PAKET_VERSION="" PAKET_GROESSE="" PAKET_SHA="" # Oldest Node.js this Grace version accepts — the server sends it with each version (paket.node). NODE_MIN="22.13" NODE_OK=0 # 1 = this run installed something or changed PATH: Claude Code must start from a NEW Terminal window. NEUES_FENSTER=0 BREW_NEU_IM_PFAD="" MP_QUELLE="" MP_PFAD="" MP_AKTION="add" PL_DA=0 PL_AN="" PL_PFAD="" # ── Output ─────────────────────────────────────────────────────────────────── if [ -t 1 ]; then FETT=$'\033[1m'; DUENN=$'\033[2m'; ROT=$'\033[31m'; GRUEN=$'\033[32m'; GELB=$'\033[33m'; AUS=$'\033[0m' else FETT=""; DUENN=""; ROT=""; GRUEN=""; GELB=""; AUS="" fi sag() { printf '%s\n' "$*"; } schritt() { printf '\n%s==> %s%s\n' "$FETT" "$*" "$AUS"; } ok() { printf ' %s✓%s %s\n' "$GRUEN" "$AUS" "$*"; } warn() { printf ' %s!%s %s\n' "$GELB" "$AUS" "$*"; } leise() { printf ' %s%s%s\n' "$DUENN" "$*" "$AUS"; } stirb() { printf '\n%sStopped:%s %s\n' "$ROT" "$AUS" "$1" >&2 shift local zeile for zeile in "$@"; do printf ' %s\n' "$zeile" >&2; done exit 1 } # Text from the server, without control characters (no terminal escape sequences). sauber() { printf '%s' "$1" | LC_ALL=C tr -d '\000-\010\013-\037\177'; } # Runs someone else's program with the umask this Terminal had before the installer # started — as if you had typed the command yourself. (With the installer's own umask 077 # Homebrew and Node.js would be unusable for other accounts on this Mac.) fremd() { (umask "$URSPRUNG_UMASK" && "$@"); } # ── Questions (always read from the terminal, also with `curl … | bash`) ───── terminal_oeffnen() { if [ "$TEST" = 1 ] && [ -n "${GRACE_TEST_EINGABE:-}" ]; then exec 3<"$GRACE_TEST_EINGABE" return 0 fi if ( exec 3/dev/null; then exec 3." ;; esac CURL_PROTO="=http,https" if [ -n "${GRACE_TEST_SECURITY:-}" ]; then SECURITY_BIN="$GRACE_TEST_SECURITY"; fi printf '%s!!! TEST MODE — talking to %s, not parsimic.com. Never use this for a real install. !!!%s\n' "$ROT" "$SEITE" "$AUS" >&2 fi } # ── HTTP + JSON ────────────────────────────────────────────────────────────── # post_json → HTTP_CODE, HTTP_BODY. The body goes through a pipe # (printf is a shell builtin), so the device key never appears in a process list. post_json() { local pfad="$1" rumpf="$2" aus HTTP_CODE="000" HTTP_BODY="" if ! aus="$(printf '%s' "$rumpf" | curl -sS --proto "$CURL_PROTO" --connect-timeout 15 --max-time 60 \ -X POST -H 'content-type: application/json' -H 'accept: application/json' -A "$INSTALLER_FASSUNG" \ --data-binary @- -w '\n%{http_code}' "$SEITE$pfad")"; then return 1 fi HTTP_CODE="${aus##*$'\n'}" HTTP_BODY="${aus%$'\n'*}" return 0 } # jwert → value, or empty. jwert_streng fails when the key is missing. jwert() { printf '%s' "$2" | plutil -extract "$1" raw -o - - 2>/dev/null || true; } jwert_streng() { printf '%s' "$2" | plutil -extract "$1" raw -o - - 2>/dev/null; } # ", server says: …" for error messages, or nothing. fehlertext() { local f f="$(sauber "$(jwert fehler "$HTTP_BODY")")" if [ -n "$f" ]; then printf ' — %s' "$f"; fi } # Only links to the Grace website are shown. sicherer_link() { case "$1" in "$SEITE"/*) sauber "$1" ;; *) printf '%s/pricing' "$SEITE" ;; esac } ist_merkmal() { case "$1" in "" | *[!0-9a-f]*) return 1 ;; esac [ "${#1}" -eq 64 ] } # ── Keychain ───────────────────────────────────────────────────────────────── kc_lesen() { "$SECURITY_BIN" find-generic-password -a "$KC_KONTO" -s "$KC_DIENST" -w 2>/dev/null || true; } # Interactive mode reads the command from the pipe: the key is neither an argument of a # process (ps) nor typed at a password prompt. Same item attributes as the plugin writes. kc_schreiben() { printf 'add-generic-password -U -a %s -s %s -D %s -w %s\n' "$KC_KONTO" "$KC_DIENST" "$KC_ART" "$1" | "$SECURITY_BIN" -i >/dev/null 2>&1 || true } # The Mac's one-way ID for the device key M: sha256("grace-mac-v1::"). # The key goes through a pipe (printf is a shell builtin), never onto a command line. mac_kennung() { local uuid MAC="" uuid="$(ioreg -rd1 -c IOPlatformExpertDevice 2>/dev/null | awk -F'"' '$2 == "IOPlatformUUID" { print $4; exit }')" || uuid="" case "$uuid" in "" | *[!0-9A-Fa-f-]*) stirb "Could not read this Mac's hardware ID (ioreg)." "Please contact support." ;; esac MAC="$(printf 'grace-mac-v1:%s:%s' "$M" "$uuid" | shasum -a 256 | awk '{ print $1 }')" # Free day: one per Mac, across accounts — so not salted with the sign-in (still never the UUID itself). TEST_MAC="$(printf 'grace-test-v1:%s' "$uuid" | shasum -a 256 | awk '{ print $1 }')" uuid="" case "$MAC" in "" | *[!0-9a-f]*) stirb "Could not compute this Mac's ID." ;; esac [ "${#MAC}" -eq 64 ] || stirb "Could not compute this Mac's ID." case "$TEST_MAC" in "" | *[!0-9a-f]*) stirb "Could not compute this Mac's ID." ;; esac [ "${#TEST_MAC}" -eq 64 ] || stirb "Could not compute this Mac's ID." } # An ISO time from the server (UTC, e.g. 2026-10-02T14:03:11.000Z) in this Mac's local time. ortszeit() { local t s t="$(sauber "$1")" case "$t" in "" | *[!0-9TZ:.-]*) printf '%s' "$t"; return 0 ;; esac t="${t%Z}" t="${t%%.*}" if s="$(date -j -u -f '%Y-%m-%dT%H:%M:%S' "$t" '+%s' 2>/dev/null)"; then date -r "$s" '+%a %d %b %Y, %H:%M' 2>/dev/null || printf '%s UTC' "$t" else printf '%s UTC' "$t" fi } # ── 1. This Mac ────────────────────────────────────────────────────────────── claude_finden() { if command -v claude >/dev/null 2>&1; then CLAUDE_BIN="$(command -v claude)" return 0 fi local c for c in "$HOME/.local/bin/claude" "$HOME/.claude/local/claude" /opt/homebrew/bin/claude /usr/local/bin/claude; do if [ "$TEST" = 1 ]; then case "$c" in "$HOME"/*) ;; *) continue ;; esac; fi if [ -x "$c" ]; then CLAUDE_BIN="$c" return 0 fi done return 1 } vorabpruefung() { schritt "Checking this Mac" if [ "$(id -u)" = 0 ]; then stirb "Please do not run the installer as root or with sudo." "Run it as your normal user; it asks when it needs something." fi [ "$(uname -s)" = Darwin ] || stirb "Grace runs on macOS only." if [ "$(sysctl -n hw.optional.arm64 2>/dev/null || echo 0)" != 1 ]; then stirb "This Mac has an Intel processor. Grace needs a Mac with Apple silicon (M1 or newer)." fi ok "Mac with Apple silicon" local werkzeug for werkzeug in curl shasum tar plutil awk df stat mktemp ioreg; do command -v "$werkzeug" >/dev/null 2>&1 || stirb "The macOS tool '$werkzeug' is missing on this Mac." done [ -x "$SECURITY_BIN" ] || stirb "The macOS Keychain tool ($SECURITY_BIN) is missing." [ -x /usr/bin/osascript ] || stirb "The macOS tool 'osascript' is missing on this Mac." claude_finden || stirb "Claude Code is not installed (the command 'claude' was not found)." \ "Install it first: $CLAUDE_INSTALL_SEITE" \ "Then open a new Terminal window and run this command again." if ! fremd "$CLAUDE_BIN" plugin marketplace list --json /dev/null 2>&1; then stirb "This Claude Code cannot install plugins yet." "Update it with: claude update" "Then run this command again." fi ok "Claude Code ($CLAUDE_BIN)" } # ── 2. Sign in (device code, confirmed in the browser) ────────────────────── geraet_anmelden() { schritt "Signing in this Mac" local name rumpf code nutzer laeuft abstand link ende stand r geoeffnet=0 fehlversuche=0 zurueck antwort name="$(scutil --get ComputerName 2>/dev/null || true)" [ -n "$name" ] || name="$(hostname -s 2>/dev/null || true)" [ -n "$name" ] || name="Mac" name="$(printf '%s' "$name" | LC_ALL=C tr -d '\000-\037\177"\\<>' | head -c 120)" rumpf="$(printf '{"geraet":"%s","fassung":"%s"}' "$name" "$INSTALLER_FASSUNG")" post_json /api/geraet/start "$rumpf" || stirb "Could not reach $SEITE." "Check your internet connection, then run the command again." [ "$HTTP_CODE" = 200 ] || stirb "The sign-in service answered with an error (HTTP $HTTP_CODE)$(fehlertext)." "Please try again in a few minutes." code="$(jwert geraetecode "$HTTP_BODY")" nutzer="$(jwert nutzercode "$HTTP_BODY")" laeuft="$(jwert laeuftAbIn "$HTTP_BODY")" abstand="$(jwert abstand "$HTTP_BODY")" ist_merkmal "$code" || stirb "The sign-in service sent an unexpected answer." "Please try again in a few minutes." case "$nutzer" in [A-Z0-9][A-Z0-9][A-Z0-9][A-Z0-9]-[A-Z0-9][A-Z0-9][A-Z0-9][A-Z0-9]) ;; *) stirb "The sign-in service sent an unexpected answer." "Please try again in a few minutes." ;; esac case "$laeuft" in "" | *[!0-9]*) laeuft=600 ;; esac case "$abstand" in "" | *[!0-9]*) abstand=5 ;; esac [ "$laeuft" -le 3600 ] || laeuft=600 [ "$abstand" -ge 1 ] && [ "$abstand" -le 60 ] || abstand=5 link="$SEITE/geraet?code=$nutzer" sag "" sag " Confirm this Mac in your browser, signed in to your Grace account:" sag "" sag " ${FETT}$link${AUS}" sag " Code: ${FETT}$nutzer${AUS}" sag "" sag " Press Enter to open the page. (Or open it yourself — this window keeps waiting" sag " for up to $(((laeuft + 59) / 60)) min.) Only confirm a code that you see here." ende=$(($(date +%s) + laeuft)) while :; do if [ "$geoeffnet" = 0 ]; then r=0 IFS= read -r -t "$abstand" antwort <&3 || r=$? if [ "$r" = 0 ]; then open "$link" >/dev/null 2>&1 || warn "Could not open the browser. Please open the link above yourself." geoeffnet=1 sag " Waiting for you to confirm the code in the browser…" elif [ "$r" -le 128 ]; then geoeffnet=1 # no more input — just wait fi else sleep "$abstand" fi [ "$(date +%s)" -lt "$ende" ] || stirb "The code expired before it was confirmed." "Run the command again for a new code." rumpf="$(printf '{"geraetecode":"%s"}' "$code")" if ! post_json /api/geraet/abfrage "$rumpf"; then fehlversuche=$((fehlversuche + 1)) [ "$fehlversuche" -lt 6 ] || stirb "Lost the connection to $SEITE." "Check your internet connection, then run the command again." continue fi case "$HTTP_CODE" in 200) ;; 429) stirb "Too many checks for this code." "Run the command again for a new code." ;; *) fehlversuche=$((fehlversuche + 1)) [ "$fehlversuche" -lt 6 ] || stirb "The sign-in service answered with an error (HTTP $HTTP_CODE)$(fehlertext)." "Please try again in a few minutes." continue ;; esac fehlversuche=0 stand="$(jwert stand "$HTTP_BODY")" case "$stand" in wartet) ;; fertig) M="$(jwert merkmal "$HTTP_BODY")" ist_merkmal "$M" || stirb "The sign-in service sent an unexpected answer." "Please run the command again." break ;; abgelehnt) stirb "The sign-in was declined in the browser." "Nothing was changed. Run the command again if you want to sign in." ;; abgelaufen) stirb "The code expired or is not valid anymore." "Run the command again for a new code." ;; *) ;; esac done code="" kc_schreiben "$M" zurueck="$(kc_lesen)" if [ "$zurueck" != "$M" ]; then zurueck="" stirb "Could not save the sign-in in your Keychain." \ "If your login keychain is locked, unlock it in the Keychain Access app, then run the command again." \ "Your Mac is already confirmed; the next run will ask for a new code once." fi zurueck="" ok "This Mac is signed in (kept in your Keychain as \"$KC_DIENST\")." } # ── 3. Subscription ────────────────────────────────────────────────────────── abo_pruefen() { local neu_angemeldet=0 gratis_angeboten=0 rumpf aktiv status satz link node_min M="$(kc_lesen)" if [ -n "$M" ] && ! ist_merkmal "$M"; then M=""; fi while :; do if [ -z "$M" ]; then geraet_anmelden neu_angemeldet=1 fi mac_kennung schritt "Checking your Grace subscription" rumpf="$(printf '{"merkmal":"%s","mac":"%s","test_mac":"%s","fassung":"%s"}' "$M" "$MAC" "$TEST_MAC" "$INSTALLER_FASSUNG")" post_json /api/download "$rumpf" || stirb "Could not reach $SEITE." "Check your internet connection, then run the command again." rumpf="" case "$HTTP_CODE" in 200) ;; 400) if [ "$neu_angemeldet" = 0 ] && [ "$(jwert anmelden "$HTTP_BODY")" = true ]; then warn "The saved sign-in is not valid anymore — signing in again." M="" continue fi stirb "The Grace server did not accept this request$(fehlertext)." "Copy the command from $GRACE_SEITE_FEST/download and run it again." ;; 429) stirb "Too many requests$(fehlertext)." "Wait a few minutes, then run the command again." ;; 503) stirb "Grace downloads are not available right now$(fehlertext)." "Please try again later." ;; *) stirb "The Grace server answered with an error (HTTP $HTTP_CODE)$(fehlertext)." "Please try again later." ;; esac aktiv="$(jwert aktiv "$HTTP_BODY")" status="$(jwert status "$HTTP_BODY")" KONTO="$(sauber "$(jwert konto "$HTTP_BODY")")" case "$KONTO" in *[!A-Za-z0-9*@._-]*) KONTO="" ;; esac if [ "$aktiv" = true ]; then break; fi case "$status" in unbekannt | entzogen | geraet_abgelaufen | anderer_mac) if [ "$neu_angemeldet" = 0 ]; then if [ "$status" = anderer_mac ]; then warn "The saved sign-in belongs to another Mac — signing in this Mac separately." else warn "This Mac's saved sign-in is not valid anymore — signing in again." fi M="" continue fi ;; esac satz="$(sauber "$(jwert satz "$HTTP_BODY")")" link="$(sicherer_link "$(jwert link "$HTTP_BODY")")" [ -n "$satz" ] || satz="This Grace account has no active subscription, so nothing was downloaded." printf '\n %s%s%s\n' "$FETT" "$satz" "$AUS" if [ -n "$KONTO" ]; then printf ' Grace account: %s\n' "$KONTO"; fi printf '\n %s\n\n' "$link" # Signed in with the wrong account (another browser login, someone else's Mac)? if [ "$neu_angemeldet" = 0 ]; then case "$status" in kein | abgelaufen | gekuendigt | test_vorbei | gratistag_anfordern | gratistag_mac_schon | gratistag_anderer_mac | gratistag_nicht_bereit) if frage " Is that the wrong account? Sign this Mac in with a different Grace account?" N; then M="" continue fi ;; esac fi # Free day not started yet: one click on the page, then Return here — no new command needed. if [ "$status" = gratistag_anfordern ] && [ "$gratis_angeboten" -lt 2 ]; then gratis_angeboten=$((gratis_angeboten + 1)) sag " Or continue right here: press Return to open the page and start your free day" sag " there with one click. Then come back and press Return again — the installer" sag " continues without a new command. (Ctrl-C stops; this Mac stays signed in.)" eingabe_leeren if IFS= read -r _ <&3; then open "$link" >/dev/null 2>&1 || warn "Could not open the browser. Please open the link above yourself." sag " Waiting — press Return once your free day is started…" if IFS= read -r _ <&3; then continue fi fi printf '\n' fi sag " Nothing was downloaded or installed. This Mac stays signed in: once this" sag " is sorted out, just run the same command again." M="" exit 1 done PAKET_VERSION="$(jwert paket.version "$HTTP_BODY")" if [ "$(jwert download_pflicht "$HTTP_BODY")" = true ]; then DOWNLOAD_PFLICHT=1; else DOWNLOAD_PFLICHT=0; fi if [ "$status" = test ] || [ "$(jwert gratistag "$HTTP_BODY")" = true ]; then GRATISTAG=1 GRATISTAG_BIS="$(sauber "$(jwert bis "$HTTP_BODY")")" else GRATISTAG=0 GRATISTAG_BIS="" fi PAKET_GROESSE="$(jwert paket.groesse "$HTTP_BODY")" PAKET_SHA="$(jwert paket.sha256 "$HTTP_BODY")" node_min="$(jwert paket.node "$HTTP_BODY")" case "$PAKET_VERSION" in "" | *[!0-9A-Za-z.-]*) stirb "The Grace server sent an unexpected answer (version)." "Please try again later." ;; esac case "$PAKET_GROESSE" in "" | *[!0-9]*) stirb "The Grace server sent an unexpected answer (size)." "Please try again later." ;; esac case "$PAKET_SHA" in "" | *[!0-9a-f]*) stirb "The Grace server sent an unexpected answer (checksum)." "Please try again later." ;; esac [ "${#PAKET_SHA}" -eq 64 ] || stirb "The Grace server sent an unexpected answer (checksum)." "Please try again later." # The Node.js this Grace version needs (e.g. 22.13) — never below Grace's own floor. if ist_fassung "$node_min" && fassung_ab "$node_min" "$NODE_MIN"; then NODE_MIN="$node_min"; fi if [ "$GRATISTAG" = 1 ]; then if [ -n "$KONTO" ]; then ok "Free day ($KONTO) — Grace $PAKET_VERSION is available." else ok "Free day — Grace $PAKET_VERSION is available." fi if [ -z "$GRATISTAG_BIS" ]; then sag " Your free day ($GRATISTAG_STUNDEN hours) starts with this download. No payment details and no" sag " subscription: when it ends, Grace stops working and shows you how to subscribe." else sag " Your free day runs until $(ortszeit "$GRATISTAG_BIS")." fi elif [ -n "$KONTO" ]; then ok "Subscription active ($KONTO) — Grace $PAKET_VERSION is available." else ok "Subscription active — Grace $PAKET_VERSION is available." fi if [ "$DOWNLOAD_PFLICHT" = 1 ] && [ "$GRATISTAG" != 1 ]; then sag " Your subscription starts with this download: Grace works once it has been" sag " downloaded for this subscription, also where it is already installed." fi } # ── 4. Plan ────────────────────────────────────────────────────────────────── # Version numbers like 22.13 or 24.1.0: 1 to 3 parts of 1 to 3 digits. ist_fassung() { local rest="$1" t teile=0 case "$rest" in "" | *[!0-9.]* | .* | *. | *..*) return 1 ;; esac while :; do t="${rest%%.*}" [ "${#t}" -le 3 ] || return 1 teile=$((teile + 1)) case "$rest" in *.*) rest="${rest#*.}" ;; *) break ;; esac done [ "$teile" -le 3 ] } # fassung_teil → its n-th number (1-based), 0 when missing. fassung_teil() { local rest="$1" n="$2" t while [ "$n" -gt 1 ]; do case "$rest" in *.*) rest="${rest#*.}" ;; *) rest="" ;; esac n=$((n - 1)) done t="${rest%%.*}" case "$t" in "" | *[!0-9]*) t=0 ;; esac printf '%s' "$((10#$t))" } # fassung_ab → 0 when version a >= version b. fassung_ab() { local i=1 a b while [ "$i" -le 3 ]; do a="$(fassung_teil "$1" "$i")" b="$(fassung_teil "$2" "$i")" [ "$a" -gt "$b" ] && return 0 [ "$a" -lt "$b" ] && return 1 i=$((i + 1)) done return 0 } # Node.js on PATH is new enough: Grace's floor (22.13+, 23.4+ or 24+) and what this Grace version asks for. node_ok() { command -v node >/dev/null 2>&1 || return 1 local v haupt v="$(node -p process.versions.node 2>/dev/null || true)" ist_fassung "$v" || return 1 haupt="$(fassung_teil "$v" 1)" if [ "$haupt" = 22 ]; then fassung_ab "$v" 22.13 || return 1 elif [ "$haupt" = 23 ]; then fassung_ab "$v" 23.4 || return 1 else fassung_ab "$v" 24 || return 1 fi fassung_ab "$v" "$NODE_MIN" } # The Node.js version manager the node on PATH comes from, or nothing. A Homebrew node # would not help then: the manager's directory comes first on PATH in every new shell. node_verwalter() { local wo wo="$(command -v node 2>/dev/null || true)" case "$wo" in */.nvm/*) printf 'nvm' ;; */.volta/*) printf 'volta' ;; */fnm_multishells/* | */.fnm/* | */fnm/node-versions/*) printf 'fnm' ;; */.asdf/*) printf 'asdf' ;; */mise/installs/*) printf 'mise' ;; *) ;; esac } # The command that installs a new enough Node.js with that manager. node_verwalter_befehl() { local haupt haupt="$(fassung_teil "$NODE_MIN" 1)" [ "$haupt" -ge 24 ] || haupt=24 case "$1" in nvm) printf 'nvm install %s && nvm alias default %s' "$haupt" "$haupt" ;; volta) printf 'volta install node@%s' "$haupt" ;; fnm) printf 'fnm install %s && fnm default %s' "$haupt" "$haupt" ;; asdf) printf 'asdf install nodejs latest:%s (then make it your default version)' "$haupt" ;; mise) printf 'mise use -g node@%s' "$haupt" ;; *) printf 'brew install node' ;; esac } # Finds Homebrew; puts it on PATH for this run if it is installed but not on PATH. brew_finden() { command -v brew >/dev/null 2>&1 && return 0 [ "$TEST" = 1 ] && return 1 local b for b in /opt/homebrew/bin/brew /usr/local/bin/brew; do if [ -x "$b" ]; then eval "$("$b" shellenv)" BREW_NEU_IM_PFAD="$b" NEUES_FENSTER=1 return 0 fi done return 1 } frei_gb() { df -Pk "$HOME" 2>/dev/null | awk 'NR==2 { print int($4 / 1048576) }'; } plan_zeigen() { schritt "Plan" ZIEL="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/grace/marktplatz" case "$ZIEL" in *[[:space:]]*) stirb "The install folder contains a space: $ZIEL" \ "Claude Code starts Grace from this path and cannot handle spaces in it." \ "Set CLAUDE_CONFIG_DIR to a folder without spaces, or contact support." ;; /*/grace/marktplatz) ;; *) stirb "Unexpected install folder: $ZIEL" ;; esac INSTALLIERT="$(plutil -extract version raw -o - "$ZIEL/grace/.claude-plugin/plugin.json" 2>/dev/null || true)" brew_finden || true # Is "grace" already registered in Claude Code — from here, or from somewhere else? # Decided now, before anything is downloaded or changed. mp_suchen || stirb "Could not read Claude Code's plugin settings." "Update Claude Code (claude update), then run this command again." MP_AKTION="add" if [ -n "$MP_QUELLE" ]; then if [ "$MP_QUELLE" = directory ] && { [ "$MP_PFAD" = "$ZIEL" ] || [ "$(echter_pfad "$MP_PFAD")" = "$(echter_pfad "$ZIEL")" ]; }; then MP_AKTION="update" else warn "Claude Code already has a plugin source named \"grace\": $(sauber "$MP_QUELLE") $(sauber "$MP_PFAD")" sag " If you answer yes, Claude Code's \"grace\" then points to this install; plugins" sag " installed from the old source update from the new one. Nothing else changes." if ! frage " Point \"grace\" to this Grace install ($ZIEL) instead?" N; then stirb "Stopped so that nothing of yours is changed. Nothing was downloaded." \ "To switch, run this command again and answer yes to that question." fi fi fi local g_text n_text frei if [ -z "$INSTALLIERT" ]; then g_text="install $PAKET_VERSION" elif [ "$INSTALLIERT" = "$PAKET_VERSION" ] && [ "$DOWNLOAD_PFLICHT" = 1 ] && [ "$GRATISTAG" = 1 ]; then g_text="$INSTALLIERT (download again — your free day starts with the download)" elif [ "$INSTALLIERT" = "$PAKET_VERSION" ] && [ "$DOWNLOAD_PFLICHT" = 1 ]; then g_text="$INSTALLIERT (download again — your subscription starts with the download)" elif [ "$INSTALLIERT" = "$PAKET_VERSION" ]; then g_text="$INSTALLIERT (up to date)" else g_text="update $INSTALLIERT → $PAKET_VERSION" fi if node_ok; then n_text="$(node --version 2>/dev/null) ✓" elif [ -n "$(node_verwalter)" ]; then n_text="needs Node.js $NODE_MIN or newer — from $(node_verwalter), see below" else n_text="needs Node.js $NODE_MIN or newer — will ask to install" fi frei="$(frei_gb)" case "$frei" in "" | *[!0-9]*) frei="?" ;; esac printf ' %-15s %s\n' "Grace" "$g_text" printf ' %-15s %s\n' "Node.js" "$n_text" printf ' %-15s %s\n' "Install folder" "$ZIEL" printf ' %-15s %s\n' "Free disk" "$frei GB" sag "" frage "Continue?" Y || { sag "Stopped. Nothing was installed."; exit 0; } } # ── 5. Node.js ─────────────────────────────────────────────────────────────── zprofile_ergaenzen() { local brewbin="$1" datei zeile datei="$HOME/.zprofile" case "${SHELL:-/bin/zsh}" in */bash) datei="$HOME/.bash_profile" ;; esac if [ -f "$datei" ] && grep -Fq "$brewbin shellenv" "$datei"; then return 0; fi zeile="eval \"\$($brewbin shellenv)\"" sag " New Terminal windows (and Claude Code) only find Homebrew's programs if your" sag " shell loads Homebrew. That is one line in $datei:" leise "$zeile" if frage " Add this line to $datei?" Y; then printf '\n# Added by the Grace installer: make Homebrew programs (node) available\n%s\n' "$zeile" >>"$datei" ok "Added to $datei" else warn "Not added. Add it yourself, or Claude Code may not find node." fi } brew_sicherstellen() { brew_finden && return 0 sag " Homebrew (brew.sh, the common package manager for macOS) installs $1." frage " Install Homebrew now? Its installer may ask for your Mac password." Y || return 1 [ "$TEST" = 1 ] && stirb "(test mode) refusing to run the real Homebrew installer." NEUES_FENSTER=1 if ! fremd /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" <&3; then warn "The Homebrew installation did not finish." return 1 fi brew_finden || { warn "Homebrew was installed but cannot be found."; return 1; } return 0 } node_sicherstellen() { schritt "Node.js (runs Grace inside Claude Code)" local verwalter if node_ok; then NODE_OK=1 ok "Node.js $(node --version 2>/dev/null)" else verwalter="$(node_verwalter)" if command -v node >/dev/null 2>&1; then warn "Node.js $(node --version 2>/dev/null || echo '?') is too old. This Grace version needs $NODE_MIN or newer." else warn "Node.js is not installed." fi if [ -n "$verwalter" ]; then # A Homebrew node would sit behind the version manager on PATH and never be used. warn "Your node comes from $verwalter ($(command -v node)). Update it there:" leise "$(node_verwalter_befehl "$verwalter")" warn "Then open a new Terminal window and run this command again." else if brew_sicherstellen "Node.js"; then if frage " Install Node.js with Homebrew (brew install node)?" Y; then NEUES_FENSTER=1 fremd brew install node || warn "brew install node did not finish." hash -r 2>/dev/null || true fi fi if node_ok; then NODE_OK=1 ok "Node.js $(node --version 2>/dev/null)" else warn "Node.js $NODE_MIN or newer is still missing (found: $(command -v node 2>/dev/null || echo none))." warn "Install it from https://nodejs.org or with 'brew install node' — Grace cannot start without it." fi fi fi if [ "$NODE_OK" = 1 ]; then local arch arch="$(node -p process.arch 2>/dev/null || true)" if [ "$arch" != arm64 ]; then warn "This node is built for '$arch', not Apple silicon (arm64). Grace will be slower; the Homebrew node is arm64." fi fi if [ -n "$BREW_NEU_IM_PFAD" ]; then zprofile_ergaenzen "$BREW_NEU_IM_PFAD"; fi } # ── 6. Grace package ───────────────────────────────────────────────────────── paket_laden() { local datei="$1" rumpf code rumpf="$(printf '{"merkmal":"%s","mac":"%s","test_mac":"%s","version":"%s","fassung":"%s"}' "$M" "$MAC" "$TEST_MAC" "$PAKET_VERSION" "$INSTALLER_FASSUNG")" # -D: the response headers (x-grace-gratistag-bis = end of the free day, if one runs). if ! code="$(printf '%s' "$rumpf" | curl -sS --proto "$CURL_PROTO" --connect-timeout 15 --max-time 900 \ -X POST -H 'content-type: application/json' -A "$INSTALLER_FASSUNG" \ -D "$datei.kopf" --data-binary @- -o "$datei" -w '%{http_code}' "$SEITE/api/download/paket")"; then rumpf="" stirb "The download failed." "Check your internet connection, then run the command again." fi rumpf="" printf '%s' "$code" } paket_installieren() { schritt "Grace $PAKET_VERSION" # A new subscription starts with a download (DOWNLOAD_PFLICHT): then never skip it. if [ "$DOWNLOAD_PFLICHT" != 1 ] && [ "$INSTALLIERT" = "$PAKET_VERSION" ] && [ -f "$ZIEL/.claude-plugin/marketplace.json" ]; then ok "Grace $PAKET_VERSION is already installed — nothing to download." return 0 fi local datei="$TMPD/grace.tgz" code groesse sha eintrag neu name version satz link versuch=0 while :; do sag " Downloading Grace $PAKET_VERSION ($((PAKET_GROESSE / 1048576 + 1)) MB)…" rm -f "$datei" code="$(paket_laden "$datei")" [ "$code" = 409 ] || break # A new release went out between the check and the download: check again, once. [ "$versuch" = 0 ] || stirb "A new Grace version was published a moment ago." "Run the command again." versuch=1 abo_pruefen if [ "$DOWNLOAD_PFLICHT" != 1 ] && [ "$INSTALLIERT" = "$PAKET_VERSION" ]; then ok "Grace $PAKET_VERSION is already installed."; return 0; fi done if [ "$code" != 200 ]; then HTTP_BODY="$(head -c 4096 "$datei" 2>/dev/null || true)" case "$code" in 403) satz="$(sauber "$(jwert satz "$HTTP_BODY")")" link="$(sicherer_link "$(jwert link "$HTTP_BODY")")" stirb "${satz:-The subscription is not active.}" "$link" ;; 400) stirb "The Grace server did not accept this Mac's sign-in$(fehlertext)." "Run the command again." ;; 429) stirb "Too many downloads$(fehlertext)" ;; 503) stirb "Grace downloads are not available right now$(fehlertext)." "Please try again later." ;; *) stirb "The download failed (HTTP $code)$(fehlertext)." "Please try again later." ;; esac fi groesse="$(stat -f %z "$datei" 2>/dev/null || echo 0)" sha="$(shasum -a 256 "$datei" | awk '{ print $1 }')" if [ "$groesse" != "$PAKET_GROESSE" ] || [ "$sha" != "$PAKET_SHA" ]; then stirb "The downloaded file does not match its checksum (damaged or tampered with)." \ "Nothing was installed. Please run the command again." fi ok "Download verified (SHA-256 ${PAKET_SHA:0:16}…)" if [ "$GRATISTAG" = 1 ]; then local bis_kopf bis_kopf="$(awk 'tolower($1) == "x-grace-gratistag-bis:" { print $2; exit }' "$datei.kopf" 2>/dev/null | tr -d '\r')" bis_kopf="$(sauber "$bis_kopf")" case "$bis_kopf" in "" | *[!0-9TZ:.-]*) ;; *) GRATISTAG_BIS="$bis_kopf" ;; esac if [ -n "$GRATISTAG_BIS" ]; then ok "Your free day runs until $(ortszeit "$GRATISTAG_BIS")."; fi fi tar -tzf "$datei" >"$TMPD/inhalt" 2>/dev/null || stirb "The downloaded file cannot be unpacked." "Nothing was installed. Please run the command again." while IFS= read -r eintrag; do case "$eintrag" in /* | .. | ../* | */../* | */..) stirb "The package contains an unsafe path. Nothing was installed." ;; .claude-plugin | .claude-plugin/* | ./.claude-plugin | ./.claude-plugin/* | grace | grace/* | ./grace | ./grace/* | ./) ;; *) stirb "The package contains an unexpected file. Nothing was installed." ;; esac done <"$TMPD/inhalt" mkdir -p "${ZIEL%/*}" neu="$ZIEL.neu" rm -rf "$neu" mkdir "$neu" tar -xzf "$datei" -C "$neu" 2>/dev/null || { rm -rf "$neu"; stirb "The package could not be unpacked. Nothing was installed."; } name="$(plutil -extract name raw -o - "$neu/.claude-plugin/marketplace.json" 2>/dev/null || true)" version="$(plutil -extract version raw -o - "$neu/grace/.claude-plugin/plugin.json" 2>/dev/null || true)" if [ "$name" != grace ] || [ "$version" != "$PAKET_VERSION" ] || [ ! -f "$neu/grace/dist/start.mjs" ]; then rm -rf "$neu" stirb "The package is incomplete. Nothing was installed." "Please run the command again." fi # Swap: the current copy becomes marktplatz.vorher (one previous copy is kept). if [ -d "$ZIEL" ]; then rm -rf "$ZIEL.vorher" mv "$ZIEL" "$ZIEL.vorher" fi if ! mv "$neu" "$ZIEL"; then if [ -d "$ZIEL.vorher" ] && [ ! -e "$ZIEL" ]; then mv "$ZIEL.vorher" "$ZIEL"; fi stirb "Could not move Grace into $ZIEL." fi ok "Grace $PAKET_VERSION unpacked to $ZIEL" } # ── 7. Claude Code ─────────────────────────────────────────────────────────── echter_pfad() { (cd "$1" 2>/dev/null && pwd -P) || printf '%s' "$1"; } # Finds "grace" in Claude Code's JSON lists in ONE pass — `claude plugin list --json` has # one entry per plugin AND project (easily over a thousand). JavaScript for Automation is # part of every macOS; the lists never touch the device key. JSON_SUCHE='function run(a) { var t = $.NSString.stringWithContentsOfFileEncodingError(a[0], $.NSUTF8StringEncoding, null) if (t.isNil()) return "FEHLER" var l try { l = JSON.parse(t.js) } catch (e) { return "FEHLER" } if (!Array.isArray(l)) return "FEHLER" var r = function (w) { return String(w === undefined || w === null ? "" : w).replace(/[\t\r\n]/g, " ") } for (var i = 0; i < l.length; i++) { var x = l[i] || {} if (a[1] === "mp" && x.name === "grace") return ["DA", r(x.source || "unknown"), r(x.path || x.repo || x.url || "")].join("\t") if (a[1] === "plugin" && x.id === "grace@grace" && (x.scope || "user") === "user") return ["DA", r(x.enabled), r(x.installPath)].join("\t") } return "NEIN" }' # json_suche mp|plugin → "DAab", "NEIN" or "FEHLER" json_suche() { /usr/bin/osascript -l JavaScript -e "$JSON_SUCHE" "$1" "$2" 2>/dev/null || printf 'FEHLER'; } # MP_QUELLE/MP_PFAD of the marketplace named "grace", or empty. mp_suchen() { local datei="$TMPD/marketplaces.json" zeile tab tab="$(printf '\t')" MP_QUELLE="" MP_PFAD="" fremd "$CLAUDE_BIN" plugin marketplace list --json "$datei" 2>/dev/null || return 1 zeile="$(json_suche "$datei" mp)" case "$zeile" in DA"$tab"*) zeile="${zeile#DA"$tab"}" MP_QUELLE="${zeile%%"$tab"*}" MP_PFAD="${zeile#*"$tab"}" [ -n "$MP_QUELLE" ] || MP_QUELLE="unknown" ;; NEIN) ;; *) return 1 ;; esac return 0 } # PL_DA/PL_AN/PL_PFAD of the user-wide plugin grace@grace. plugin_stand() { local datei="$TMPD/plugins.json" zeile tab tab="$(printf '\t')" PL_DA=0 PL_AN="" PL_PFAD="" fremd "$CLAUDE_BIN" plugin list --json "$datei" 2>/dev/null || printf '[]' >"$datei" zeile="$(json_suche "$datei" plugin)" case "$zeile" in DA"$tab"*) zeile="${zeile#DA"$tab"}" PL_DA=1 PL_AN="${zeile%%"$tab"*}" PL_PFAD="${zeile#*"$tab"}" ;; *) ;; esac return 0 } claude_lauf() { local log="$TMPD/claude.log" if ! fremd "$CLAUDE_BIN" "$@" "$log" 2>&1; then sed 's/^/ /' "$log" >&2 stirb "Claude Code could not run: claude $*" "Fix the problem above, then run this command again." fi } registrieren() { schritt "Registering Grace with Claude Code" if [ "$MP_AKTION" = update ]; then claude_lauf plugin marketplace update grace else claude_lauf plugin marketplace add "$ZIEL" fi plugin_stand if [ "$PL_DA" = 1 ]; then claude_lauf plugin update grace@grace ok "Plugin grace@grace updated" else claude_lauf plugin install grace@grace ok "Plugin grace@grace installed" fi plugin_stand if [ "$PL_AN" = false ]; then warn "Grace is installed but switched off in Claude Code. Switch it on with: claude plugin enable grace@grace" fi } # Asks Grace itself whether it sees the sign-in (same Keychain item, same check as in # Claude Code). This also replaces Grace's cached answer — after a renewed subscription # an old "no subscription" would otherwise keep Grace locked for up to a day. lizenz_abgleichen() { schritt "Checking that Grace sees the sign-in" local ordner skript zeile ordner="$PL_PFAD" if [ -z "$ordner" ] || [ ! -f "$ordner/dist/grace-lizenz.mjs" ]; then ordner="$ZIEL/grace"; fi skript="$ordner/dist/grace-lizenz.mjs" if [ "$NODE_OK" != 1 ] || [ ! -f "$skript" ]; then # Grace cannot ask yet (no Node.js): drop its cached answer so it asks parsimic.com on its first start. rm -f "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/grace/lizenz.json" leise "Grace checks the subscription when it first starts in Claude Code." return 0 fi zeile="$({ fremd node "$skript" status /dev/null || true; } | head -n 1)" zeile="$(sauber "$zeile")" case "$zeile" in FREI*) if [ "$GRATISTAG" = 1 ]; then ok "Grace sees the sign-in and your free day."; else ok "Grace sees the sign-in and the subscription."; fi ;; OFFLINE*) warn "Grace could not reach $GRACE_SEITE_FEST just now. It checks again when Claude Code starts." ;; NICHT_ANGEMELDET*) warn "Grace cannot read the sign-in from your Keychain." warn "If your login keychain is locked, unlock it (Keychain Access), then run this command again." ;; "") warn "Grace's own check did not answer. In Claude Code, /grace:grace-einrichten shows the state." ;; *) warn "Grace's own check says:" leise "${zeile#* }" ;; esac } # ── 8. Your Claude projects (Grace 0.3.5: no /grace, no empty folder) ──────── # Grace looks at each project in the folder the user names: name, a short description (README / package.json / # pyproject.toml) and the language — never .env files, keys or code. The list stays on this Mac # (/grace/projektordner.json). With it, Grace tells a new project from work on an existing one and # builds new projects on its own in that folder, next to the other projects. On an update the known folder is # simply read again. (Website 01.10.2026: wording made clear — the new project goes INTO the projects folder.) projekte_einrichten() { local ordner skript datei alt vorschlag pfad aus ordner="$PL_PFAD" if [ -z "$ordner" ] || [ ! -f "$ordner/hooks/projekte.mjs" ]; then ordner="$ZIEL/grace"; fi skript="$ordner/hooks/projekte.mjs" if [ "$NODE_OK" != 1 ] || [ ! -f "$skript" ]; then return 0; fi schritt "Your Claude projects" datei="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/grace/projektordner.json" if [ -f "$datei" ]; then alt="$(jwert pfad "$(cat "$datei" 2>/dev/null)")" if [ -n "$alt" ] && [ -d "$alt" ]; then if aus="$(fremd node "$skript" einlesen "$alt" &1)"; then ok "$(sauber "$aus") ($alt)"; else warn "Could not read $alt again."; fi return 0 fi fi sag " Grace can look at your Claude projects: the name, a short description (README or" sag " package.json) and the language of each one — never .env files, keys or code. The list" sag " stays on this Mac. Then Grace knows whether a task is a new project or work on an" sag " existing one, and builds new projects on its own right there, next to your other projects." if ! frage " Look at your projects folder?" Y; then leise "Skipped. Later: /grace:grace-einrichten in Claude Code." return 0 fi vorschlag="$(sauber "$({ fremd node "$skript" vorschlag /dev/null || true; } | head -n 1)")" eingabe_leeren printf ' Where are your Claude projects?%s ' "${vorschlag:+ [$vorschlag]}" if ! IFS= read -r pfad <&3; then printf '\n'; pfad=""; fi pfad="$(sauber "$pfad")" [ -n "$pfad" ] || pfad="$vorschlag" case "$pfad" in "~" | "~/"*) pfad="$HOME${pfad#\~}" ;; esac if [ -z "$pfad" ] || [ ! -d "$pfad" ]; then warn "No such folder${pfad:+: $pfad}. Later: /grace:grace-einrichten in Claude Code." return 0 fi if aus="$(fremd node "$skript" einlesen "$pfad" &1)"; then ok "$(sauber "$aus")"; else warn "Could not read the projects: $(sauber "$aus")"; fi } # ── 9. Done ────────────────────────────────────────────────────────────────── abschluss() { schritt "Done" sag " Grace $PAKET_VERSION is installed." if [ "$NODE_OK" != 1 ]; then warn "Still needed: Node.js $NODE_MIN or newer ($(node_verwalter_befehl "$(node_verwalter)"))."; fi sag "" sag " Next:" if [ "$NEUES_FENSTER" = 1 ]; then sag " 1. Quit Claude Code completely. Then open a ${FETT}new${AUS} Terminal window (this one" sag " does not see the newly installed programs) and start Claude Code there." else sag " 1. Quit Claude Code completely and start it again, so it loads Grace." fi sag " 2. In Claude Code, run once: /grace:grace-einrichten" sag " 3. Then just type your task. New projects Grace builds on its own." sag "" if [ "$GRATISTAG" = 1 ]; then if [ -n "$GRATISTAG_BIS" ]; then sag " Free day: Grace works until $(ortszeit "$GRATISTAG_BIS")." else sag " Free day: Grace works for $GRATISTAG_STUNDEN hours from the download." fi sag " To keep it afterwards, subscribe: $SEITE/pricing" sag " Without a subscription Grace stops when the free day ends and removes itself" sag " $GRATISTAG_STUNDEN hours later at the earliest, at the latest the next time Claude Code starts" sag " with an internet connection. Your projects stay as they are." sag "" fi sag " To update Grace later, run the same command again." } aufraeumen() { M="" if [ -n "$TMPD" ] && [ -d "$TMPD" ]; then rm -rf "$TMPD"; fi } main() { case "${1:-}" in -h | --help) sed -n '2,24p' "$0" 2>/dev/null || sag "Grace installer — https://parsimic.com/download" return 0 ;; esac trap aufraeumen EXIT printf '%sGrace installer%s — %s/download\n' "$FETT" "$AUS" "$GRACE_SEITE_FEST" seite_festlegen terminal_oeffnen TMPD="$(mktemp -d "${TMPDIR:-/tmp}/grace-install.XXXXXX")" vorabpruefung abo_pruefen plan_zeigen node_sicherstellen paket_installieren registrieren lizenz_abgleichen projekte_einrichten abschluss M="" MAC="" } # Everything above only defines functions: a download that stops halfway runs nothing. main "$@"