#!/bin/bash # ac-inscribe — bind an authenticated session to a flashable AC OS image # # Pipelined flash flow: # ac-os pull — download + verify kernel artifacts # ac-inscribe — gather + validate credentials, write inscription.json # ac-os flash — read inscription.json + write USB # # Usage: # ac-inscribe # interactive: validate, prompt, write # ac-inscribe --yes # non-interactive, no confirmation prompt # ac-inscribe --anon # anonymous USB (no session, no tokens) # ac-inscribe --out # override output (default: /tmp/ac-os-pull/inscription.json) # ac-inscribe --no-claude # skip baking local Claude session # ac-inscribe --inspect # print summary of an existing inscription # ac-inscribe --for-handle # admin: inscribe for another user # ac-inscribe --mood # override boot splash subtitle (else uses the handle's AC mood) # ac-inscribe --city # preset first-boot greeting city until the device geolocates (e.g. "Ridgewood") # ac-inscribe --mono # boot with the system-wide mono audio fold on (config "mono": true) # ac-inscribe --volume # baseline output volume 0-400 (config "volume"); e.g. 60 for a loud speaker path # # Exit codes: # 0 inscription written and valid # 1 generic failure (auth, network, IO) # 2 user declined the validation prompt # # This script never writes to USB. ac-os flash consumes the artifact and # is the only path that touches block devices. # # --for-handle : when an admin (per backend hasAdmin) flashes a # USB on behalf of another user, this passes ?handle= to the # claude-token API to pull that handle's claude/github tokens. The # resulting inscription has empty access_token (admins cannot sign for # other users), so on-device tape uploads will be silently skipped # until the recipient runs ac-login on the device once. The local # Claude session bake is also skipped — that's the operator's, not # the recipient's. set -u SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" LIB="${SCRIPT_DIR}/scripts/inscribe-lib.sh" [ -f "${LIB}" ] || { echo "ac-inscribe: missing ${LIB}" >&2; exit 1; } # shellcheck disable=SC1090 source "${LIB}" # ─── parse flags ────────────────────────────────────────────────── ANON=0 YES=0 NO_CLAUDE=0 OUT="/tmp/ac-os-pull/inscription.json" INSPECT="" FOR_HANDLE="" MOOD_OVERRIDE="" MOOD_OVERRIDE_SET=0 CITY_OVERRIDE="" CITY_OVERRIDE_SET=0 ACI_CITY="" # config "city"; only set via --city (no account fetch), so default empty under `set -u` ACI_MONO="" # config "mono": "1" via --mono → device boots with the audio fold on ACI_VOLUME="" # config "volume": percent via --volume → baseline software gain at boot while [ $# -gt 0 ]; do case "$1" in --anon) ANON=1 ;; --yes|-y) YES=1 ;; --no-claude) NO_CLAUDE=1 ;; --out) OUT="$2"; shift ;; --inspect) INSPECT="$2"; shift ;; --for-handle) FOR_HANDLE="${2#@}"; shift ;; --mood) MOOD_OVERRIDE="$2"; MOOD_OVERRIDE_SET=1; shift ;; --city) CITY_OVERRIDE="$2"; CITY_OVERRIDE_SET=1; shift ;; --mono) ACI_MONO=1 ;; --volume) ACI_VOLUME="$2"; shift ;; --help|-h) sed -n '2,28p' "$0" | sed 's/^# *//' exit 0 ;; *) aci_die "unknown flag: $1 (try --help)" ;; esac shift done # ─── inspect mode — read an existing inscription, print summary, exit ─ if [ -n "${INSPECT}" ]; then [ -f "${INSPECT}" ] || aci_die "no inscription at ${INSPECT}" printf '%s[ac-inscribe]%s inspecting %s%s%s\n\n' \ "${ACI_C_CYAN}" "${ACI_C_RESET}" \ "${ACI_C_BOLD}" "${INSPECT}" "${ACI_C_RESET}" node -e " const d = JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8')); const c = d.usbConfig || {}; const yn = (v) => v ? 'yes' : 'no'; process.stdout.write( \` version : \${d.version || '?'}\n\` + \` createdAt : \${d.createdAt || '?'}\n\` + \` createdBy : \${d.createdBy || '?'}\n\` + \` handle : \${c.handle || '?'}\n\` + \` email : \${c.email || '-'}\n\` + \` ac-token : \${yn(c.token)}\n\` + \` claudeToken: \${yn(c.claudeToken)}\n\` + \` githubPat : \${yn(c.githubPat)}\n\` + \` claudeCreds: \${yn(c.claudeCreds)}\n\` + \` claudeState: \${yn(c.claudeState)}\n\` ); " "${INSPECT}" exit 0 fi # ─── anonymous shortcut ─────────────────────────────────────────── if [ "${ANON}" -eq 1 ]; then aci_step 1 2 "Building anonymous inscription" aci_ok "no token, no session, no claude/github creds" aci_ok "USB will boot into notepat as anonymous" aci_step 2 2 "Writing ${OUT}" mkdir -p "$(dirname "${OUT}")" || aci_die "could not create output dir" aci_build_anon_inscription_json > "${OUT}" || aci_die "write failed" chmod 600 "${OUT}" 2>/dev/null || true aci_ok "wrote $(wc -c < "${OUT}" | tr -d ' ') bytes (mode 0600)" printf '\n%s[ac-inscribe]%s ready. run: %sac-os flash%s\n' \ "${ACI_C_CYAN}" "${ACI_C_RESET}" "${ACI_C_BOLD}" "${ACI_C_RESET}" exit 0 fi # ─── for-handle (admin-on-behalf) path ──────────────────────────── # Admin inscribes for another user. Pulls the target's claudeToken + # githubPat from the backend; access_token stays empty (admins cannot # sign for other users — the device's tape uploader requires the # recipient's own access_token, so the recipient runs ac-login once # on the device after first boot to populate it). if [ -n "${FOR_HANDLE}" ]; then aci_step 1 4 "Admin auth — operator must be logged in" if aci_require_login; then aci_ok "operator: ${ACI_C_BOLD}@${ACI_HANDLE}${ACI_C_RESET}" aci_ok "operator sub: ${ACI_SUB}" OPERATOR_HANDLE="${ACI_HANDLE}" else aci_die "operator auth failed" fi aci_step 2 4 "Pulling tokens for ${ACI_C_BOLD}@${FOR_HANDLE}${ACI_C_RESET} (admin path)" if aci_fetch_handle_tokens "${FOR_HANDLE}"; then if [ -z "${ACI_HANDLE_API}" ] || [ -z "${ACI_TARGET_SUB}" ]; then aci_die "@${FOR_HANDLE} not found, or not authorized to pull (need admin)" fi aci_ok "target handle: @${ACI_HANDLE_API}" aci_ok "target sub: ${ACI_TARGET_SUB}" [ -n "${ACI_CLAUDE_TOKEN}" ] && aci_ok "claudeToken: present (${#ACI_CLAUDE_TOKEN} bytes)" || aci_warn "claudeToken: none stored for @${FOR_HANDLE}" [ -n "${ACI_GITHUB_PAT}" ] && aci_ok "githubPat: present (${#ACI_GITHUB_PAT} bytes)" || aci_warn "githubPat: none stored for @${FOR_HANDLE}" # Override identity so the bundle is for the target, not the operator. ACI_HANDLE="${ACI_HANDLE_API}" ACI_SUB="${ACI_TARGET_SUB}" ACI_EMAIL="" # not stored in @handles; recipient ac-logins on device ACI_ACCESS_TOKEN="" # admin cannot sign for the target ACI_CLAUDE_CREDS="" # operator's local Claude session is irrelevant here ACI_CLAUDE_STATE="" else aci_die "claude-token API failed (network? not admin? @${FOR_HANDLE} doesn't exist?)" fi # Boot personalization for the target — handle-colors + initial mood. # No auth required for these endpoints (mood read is public, colors # too). C boot renderer reads these from config.json on next boot. aci_fetch_handle_colors "${ACI_HANDLE}" aci_fetch_mood "${ACI_HANDLE}" if [ "${MOOD_OVERRIDE_SET}" -eq 1 ]; then ACI_MOOD="${MOOD_OVERRIDE}" aci_ok "mood override: ${ACI_C_BOLD}${ACI_MOOD:-(cleared)}${ACI_C_RESET}" fi if [ "${CITY_OVERRIDE_SET}" -eq 1 ]; then ACI_CITY="${CITY_OVERRIDE}" aci_ok "city override: ${ACI_C_BOLD}${ACI_CITY:-(cleared)}${ACI_C_RESET}" fi aci_step 3 4 "Assembling inscription bundle for @${ACI_HANDLE}" BUNDLE=$(aci_build_inscription_json) || aci_die "bundle assembly failed" BUNDLE_BYTES=$(printf '%s' "${BUNDLE}" | wc -c | tr -d ' ') aci_ok "version 1 envelope, ${BUNDLE_BYTES} bytes" aci_ok "fields: handle, sub, email(empty), token(empty)$([ -n "${ACI_CLAUDE_TOKEN}" ] && printf ', claudeToken')$([ -n "${ACI_GITHUB_PAT}" ] && printf ', githubPat')$([ -n "${ACI_HANDLE_COLORS_JSON}" ] && printf ', colors')$([ -n "${ACI_MOOD}" ] && printf ', mood')$([ -n "${ACI_CITY}" ] && printf ', city')" [ -n "${ACI_HANDLE_COLORS_JSON}" ] && aci_ok "handle-colors: ${ACI_C_BOLD}per-character palette baked${ACI_C_RESET}" || aci_info "handle-colors: none stored — boot uses theme color" [ -n "${ACI_MOOD}" ] && aci_ok "mood: ${ACI_C_BOLD}${ACI_MOOD}${ACI_C_RESET}" || aci_info "mood: none yet — boot subtitle uses default" [ -n "${ACI_CITY}" ] && aci_ok "city: ${ACI_C_BOLD}${ACI_CITY}${ACI_C_RESET} (greeting until geolocated)" || aci_info "city: none — first-boot greeting uses default" aci_warn "access_token is empty — recipient runs ac-login on device once for tape uploads" if [ "${YES}" -eq 0 ] && [ -t 0 ]; then printf '\n%sproceed? this writes credentials for @%s to %s%s%s [y/N]: ' \ "${ACI_C_YELLOW}" "${ACI_HANDLE}" "${ACI_C_BOLD}" "${OUT}" "${ACI_C_RESET}" read -r CONFIRM case "${CONFIRM}" in y|Y|yes|YES) ;; *) printf '%s[ac-inscribe]%s declined\n' "${ACI_C_CYAN}" "${ACI_C_RESET}"; exit 2 ;; esac fi aci_step 4 4 "Writing ${OUT}" mkdir -p "$(dirname "${OUT}")" || aci_die "could not create output dir" printf '%s' "${BUNDLE}" > "${OUT}" || aci_die "write failed" chmod 600 "${OUT}" 2>/dev/null || true aci_ok "wrote ${BUNDLE_BYTES} bytes (mode 0600)" printf '\n%s[ac-inscribe]%s %sinscribed%s @%s on behalf of admin @%s — run: %sac-os flash%s\n' \ "${ACI_C_CYAN}" "${ACI_C_RESET}" \ "${ACI_C_GREEN}" "${ACI_C_RESET}" \ "${ACI_HANDLE}" \ "${OPERATOR_HANDLE:-???}" \ "${ACI_C_BOLD}" "${ACI_C_RESET}" exit 0 fi # ─── authenticated path ─────────────────────────────────────────── # Step 1 — auth token aci_step 1 5 "Validating ~/.ac-token" if aci_require_login; then aci_ok "token present and parseable" aci_ok "handle: ${ACI_C_BOLD}@${ACI_HANDLE}${ACI_C_RESET}" aci_ok "email: ${ACI_EMAIL:-(none)}" aci_ok "sub: ${ACI_SUB}" [ -n "${ACI_ACCESS_TOKEN}" ] || aci_warn "access_token is empty" else aci_die "auth check failed" fi # Step 2 — handle-token API aci_step 2 5 "Fetching handle tokens (claude-token API)" if aci_fetch_handle_tokens; then aci_ok "API responded" if [ -n "${ACI_HANDLE_API}" ] && [ "${ACI_HANDLE_API}" != "${ACI_HANDLE}" ]; then aci_warn "API handle (@${ACI_HANDLE_API}) ≠ token handle (@${ACI_HANDLE}) — using token handle" fi [ -n "${ACI_CLAUDE_TOKEN}" ] && aci_ok "claudeToken: present" || aci_warn "claudeToken: empty" [ -n "${ACI_GITHUB_PAT}" ] && aci_ok "githubPat: present" || aci_warn "githubPat: empty" else aci_warn "handle-token API unavailable — bundle will lack claude/github tokens" ACI_CLAUDE_TOKEN="" ACI_GITHUB_PAT="" fi # Step 3 — local Claude session aci_step 3 5 "Local Claude session" if [ "${NO_CLAUDE}" -eq 1 ]; then aci_info "skipped (--no-claude)" ACI_CLAUDE_CREDS="" ACI_CLAUDE_STATE="" else aci_collect_local_claude if [ -n "${ACI_CLAUDE_STATE}" ]; then aci_ok "~/.claude.json found, email matches — will bake" if [ -n "${ACI_CLAUDE_CREDS}" ]; then aci_ok "~/.claude/.credentials.json bundled" else aci_warn "~/.claude/.credentials.json missing — state without creds" fi elif [ -f "${HOME}/.claude.json" ]; then if [ -n "${ACI_CLAUDE_LOCAL_EMAIL:-}" ]; then aci_warn "skipped — local claude email (${ACI_CLAUDE_LOCAL_EMAIL}) ≠ AC email (${ACI_EMAIL})" else aci_warn "skipped — could not read local claude email" fi else aci_info "no ~/.claude.json found — nothing to bake" fi fi # Boot personalization — handle-colors + most recent mood. Public APIs; # no auth needed. C boot renderer reads them from config.json. aci_fetch_handle_colors "${ACI_HANDLE}" aci_fetch_mood "${ACI_HANDLE}" if [ "${MOOD_OVERRIDE_SET}" -eq 1 ]; then ACI_MOOD="${MOOD_OVERRIDE}" aci_ok "mood override: ${ACI_C_BOLD}${ACI_MOOD:-(cleared)}${ACI_C_RESET}" fi if [ "${CITY_OVERRIDE_SET}" -eq 1 ]; then ACI_CITY="${CITY_OVERRIDE}" aci_ok "city override: ${ACI_C_BOLD}${ACI_CITY:-(cleared)}${ACI_C_RESET}" fi # Step 4 — assemble bundle aci_step 4 5 "Assembling inscription bundle" BUNDLE=$(aci_build_inscription_json) || aci_die "bundle assembly failed" BUNDLE_BYTES=$(printf '%s' "${BUNDLE}" | wc -c | tr -d ' ') aci_ok "version 1 envelope, ${BUNDLE_BYTES} bytes" aci_ok "fields: handle, sub, email, token$([ -n "${ACI_CLAUDE_TOKEN}" ] && printf ', claudeToken')$([ -n "${ACI_GITHUB_PAT}" ] && printf ', githubPat')$([ -n "${ACI_CLAUDE_CREDS}" ] && printf ', claudeCreds')$([ -n "${ACI_CLAUDE_STATE}" ] && printf ', claudeState')$([ -n "${ACI_HANDLE_COLORS_JSON}" ] && printf ', colors')$([ -n "${ACI_MOOD}" ] && printf ', mood')$([ -n "${ACI_CITY}" ] && printf ', city')" [ -n "${ACI_HANDLE_COLORS_JSON}" ] && aci_ok "handle-colors: ${ACI_C_BOLD}per-character palette baked${ACI_C_RESET}" || aci_info "handle-colors: none stored — boot uses theme color" [ -n "${ACI_MOOD}" ] && aci_ok "mood: ${ACI_C_BOLD}${ACI_MOOD}${ACI_C_RESET}" || aci_info "mood: none yet — boot subtitle uses default" # Confirmation gate (interactive only) if [ "${YES}" -eq 0 ] && [ -t 0 ]; then printf '\n%sproceed? this writes credentials to %s%s%s [y/N]: ' \ "${ACI_C_YELLOW}" "${ACI_C_BOLD}" "${OUT}" "${ACI_C_RESET}" read -r CONFIRM case "${CONFIRM}" in y|Y|yes|YES) ;; *) printf '%s[ac-inscribe]%s declined\n' "${ACI_C_CYAN}" "${ACI_C_RESET}"; exit 2 ;; esac fi # Step 5 — write artifact aci_step 5 5 "Writing ${OUT}" mkdir -p "$(dirname "${OUT}")" || aci_die "could not create output dir" printf '%s' "${BUNDLE}" > "${OUT}" || aci_die "write failed" chmod 600 "${OUT}" 2>/dev/null || true aci_ok "wrote ${BUNDLE_BYTES} bytes (mode 0600)" aci_ok "$(stat -f '%Sp %Su:%Sg' "${OUT}" 2>/dev/null || stat -c '%a %U:%G' "${OUT}" 2>/dev/null || echo permissions checked)" printf '\n%s[ac-inscribe]%s %sinscribed%s @%s — run: %sac-os flash%s\n' \ "${ACI_C_CYAN}" "${ACI_C_RESET}" \ "${ACI_C_GREEN}" "${ACI_C_RESET}" \ "${ACI_HANDLE}" \ "${ACI_C_BOLD}" "${ACI_C_RESET}"