#!/bin/bash
# Smalt installer: curl -fsSL https://getsmalt.com/install.sh | bash
#
# Installs or updates Smalt in /Applications, after checking the download is signed by Smalt's
# developer (team D6K6GHF43V) and notarized by Apple, then connects every assistant app it finds:
# Claude Code (MCP server, the /smalt and /smalt-live-assistant skills, and the hook in
# ~/.claude/hooks/smalt-hook.sh, registered in ~/.claude/settings.json after a backup) and Claude
# Desktop (its config, backed up first). Asks nothing.
#
#   ... | bash -s -- --uninstall   disconnects the assistant apps, removes the skills and the hook (the
#                                  app itself goes to the Trash by hand)
#   SMALT_DRY_RUN=1                prints what it would do, changes nothing
#   SMALT_AGENT=1                  run by an agent (getsmalt.com's "copy the prompt"): no question asked
#
# Everything runs inside main(), so a download cut short never runs half a script.
set -euo pipefail

SITE="https://getsmalt.com"
# where the person came from, filled in by getsmalt.com when the install line carried it (?src=hn)
SRC=""
TEAM="D6K6GHF43V"
BUNDLE_ID="app.smalt.mac"
SKILL="smalt-live-assistant"   # meetings: answers from your notes
SKILL_FILES=(SKILL.md jev-question.py)
VOICE_SKILL="smalt"            # /smalt: talk with Claude Code
# one copy for both builds (Smalt and Smalt Dev), the path scripts/dev-install.sh uses too
HOOK="$HOME/.claude/hooks/smalt-hook.sh"
DESKTOP_CONFIG="$HOME/Library/Application Support/Claude/claude_desktop_config.json"

ok()   { printf '  \033[32m✓\033[0m %s\n' "$*"; }
skip() { printf '  \033[2m– %s\033[0m\n' "$*"; }
fail() { printf '  \033[31m✗\033[0m %s\n' "$*" >&2; exit 1; }
run()  { if [ -n "${SMALT_DRY_RUN:-}" ]; then printf '    \033[2mwould run: %s\033[0m\n' "$*" >&2; else "$@"; fi; }

# The app's folder: /Applications when it's there or writable, else ~/Applications.
app_dir() {
  if [ -d "/Applications/Smalt.app" ] || [ -w /Applications ]; then echo /Applications; else echo "$HOME/Applications"; fi
}

version_of() { defaults read "$1/Contents/Info" CFBundleShortVersionString 2>/dev/null || true; }
running() { pgrep -xq Smalt; }

find_claude() {
  command -v claude 2>/dev/null && return
  for c in "$HOME/.claude/local/claude" "$HOME/.local/bin/claude" /opt/homebrew/bin/claude /usr/local/bin/claude; do
    [ -x "$c" ] && { echo "$c"; return; }
  done
  return 1
}

# Adds or removes mcpServers.smalt in Claude Desktop's JSON config with JavaScript for Automation,
# which every Mac has (python3 may be only a stub that asks to install developer tools).
desktop_config() { # add <binary> | remove
  local js; js="$(mktemp -t smalt).js"
  cat > "$js" <<'JS'
ObjC.import('Foundation');
function run(argv) {
  const [path, action, bin] = argv;
  let cfg = {};
  const text = $.NSString.stringWithContentsOfFileEncodingError(path, $.NSUTF8StringEncoding, null);
  if (!text.isNil()) {
    try { cfg = JSON.parse(ObjC.unwrap(text) || '{}'); } catch (e) { return 'invalid'; }
  }
  cfg.mcpServers = cfg.mcpServers || {};
  if (action === 'remove') delete cfg.mcpServers.smalt;
  else cfg.mcpServers.smalt = { command: bin, args: ['--mcp'] };
  $(JSON.stringify(cfg, null, 2) + '\n').writeToFileAtomicallyEncodingError(path, true, $.NSUTF8StringEncoding, null);
  return 'ok';
}
JS
  local result; result=$(osascript -l JavaScript "$js" "$DESKTOP_CONFIG" "$@" 2>&1 || true)
  rm -f "$js"
  echo "$result"
}

install_app() {
  local dir; dir=$(app_dir)
  local app="$dir/Smalt.app"
  local installed latest
  installed=$(version_of "$app")
  latest=$(curl -fsSL "$SITE/appcast.xml" 2>/dev/null | sed -n 's:.*<sparkle\:shortVersionString>\([^<]*\)<.*:\1:p' | head -1 || true)
  # up to date, or newer than the site's (a build from source): never downgrade
  if [ -n "$installed" ] && [ -n "$latest" ] && [ "$(printf '%s\n%s\n' "$installed" "$latest" | sort -V | tail -1)" = "$installed" ]; then
    ok "Smalt $installed is up to date ($app)"
    APP="$app"; return
  fi

  local tmp; tmp=$(mktemp -d -t smalt)
  trap 'hdiutil detach -quiet "$tmp/mnt" 2>/dev/null || true; rm -rf "$tmp"' EXIT
  printf '  Downloading Smalt%s…\n' "${latest:+ $latest}"
  curl -fsSL "$SITE/download/latest" -o "$tmp/Smalt.dmg" || fail "Download failed. Try again, or get it from $SITE"
  hdiutil attach -quiet -nobrowse -readonly -mountpoint "$tmp/mnt" "$tmp/Smalt.dmg" || fail "Couldn't open the download"
  local new="$tmp/mnt/Smalt.app"
  [ -d "$new" ] || fail "The download has no Smalt.app"

  # A file fetched with curl isn't quarantined, so Gatekeeper won't look at it on first launch: check here.
  codesign --verify --deep --strict "$new" 2>/dev/null || fail "The download's signature is broken; nothing was installed"
  codesign -dv "$new" 2>&1 | grep -qx "TeamIdentifier=$TEAM" || fail "The download isn't signed by Smalt's developer; nothing was installed"
  spctl --assess --type execute "$new" 2>/dev/null || fail "Apple hasn't notarized the download; nothing was installed"
  local version; version=$(version_of "$new")

  # Replacing the app under a running Smalt makes macOS drop its Accessibility permission: quit it first.
  local was_running=""
  if running; then
    was_running=1
    run osascript -e "quit app id \"$BUNDLE_ID\""
    for _ in $(seq 1 50); do running || break; sleep 0.1; done
    running && [ -z "${SMALT_DRY_RUN:-}" ] && fail "Smalt didn't quit; quit it and run this again"
  fi
  run mkdir -p "$dir"
  run rm -rf "$app"
  run ditto "$new" "$app"
  if [ -n "$installed" ]; then ok "Smalt updated from $installed to $version"; else ok "Smalt $version installed in $dir"; fi
  APP="$app"
  RELAUNCH=${was_running:+1}
}

connect_claude_code() {
  local claude; claude=$(find_claude) || { skip "Claude Code not found"; return; }
  run "$claude" mcp remove --scope user smalt >/dev/null 2>&1 || true
  run "$claude" mcp add --scope user smalt -- "$APP/Contents/MacOS/Smalt" --mcp >/dev/null
  local skills="$HOME/.claude/skills/$SKILL"
  run mkdir -p "$skills" "$HOME/.claude/skills/$VOICE_SKILL"
  for f in "${SKILL_FILES[@]}"; do run curl -fsSL "$SITE/docs/files/$f" -o "$skills/$f"; done
  run curl -fsSL "$SITE/docs/files/$VOICE_SKILL/SKILL.md" -o "$HOME/.claude/skills/$VOICE_SKILL/SKILL.md"
  ok "Claude Code connected, with the /$VOICE_SKILL and /$SKILL skills"
  CONNECTED=1
  install_hook
}

# The hook shows Claude's steps on the pill, hands Claude what the user said while it worked, and keeps
# a voice turn from ending without listening. It edits ~/.claude/settings.json itself (one merge for
# both installers), after a backup; without jq it prints what to add.
install_hook() {
  if [ -n "${SMALT_DRY_RUN:-}" ]; then
    printf '    \033[2mwould install %s and register it in ~/.claude/settings.json\033[0m\n' "$HOOK"
    return
  fi
  local tmp; tmp=$(mktemp -t smalt-hook) || return 0
  if ! curl -fsSL "$SITE/docs/files/smalt-hook.sh" -o "$tmp" || ! bash -n "$tmp" 2>/dev/null; then
    rm -f "$tmp"; skip "Couldn't download the Claude Code hook; run this again later"; return 0
  fi
  mkdir -p "$(dirname "$HOOK")"
  chmod 755 "$tmp" && mv -f "$tmp" "$HOOK"
  if bash "$HOOK" --register | sed 's/^/    /'; then ok "Claude Code hook installed ($HOOK)"
  else skip "Claude Code hook copied to $HOOK, but not registered (see above)"
  fi
  return 0
}

connect_claude_desktop() {
  [ -d "$(dirname "$DESKTOP_CONFIG")" ] || { skip "Claude Desktop not found"; return; }
  [ -f "$DESKTOP_CONFIG" ] && run cp "$DESKTOP_CONFIG" "$DESKTOP_CONFIG.bak-smalt"
  if [ -n "${SMALT_DRY_RUN:-}" ]; then printf '    \033[2mwould add mcpServers.smalt to %s\033[0m\n' "$DESKTOP_CONFIG"
  elif [ "$(desktop_config add "$APP/Contents/MacOS/Smalt")" != ok ]; then skip "Claude Desktop's config isn't valid JSON; left it alone"; return
  fi
  if pgrep -xq Claude; then ok "Claude Desktop connected (quit and reopen it to load Smalt)"; else ok "Claude Desktop connected"; fi
  CONNECTED=1
}

# Connecting Claude is the user's go-ahead for the assistant (off until then: it lets connected apps
# read the live transcript and, in a voice session, hear the microphone). Claude Code thinks and gets
# the live transcript, unless the user already chose Smalt's own assistant.
turn_on_assistant() {
  [ -n "${CONNECTED:-}" ] || return 0
  local on legacy mode shares changed=""
  on=$(defaults read "$BUNDLE_ID" assistant 2>/dev/null || echo 0)
  legacy=$(defaults read "$BUNDLE_ID" liveAssistant 2>/dev/null || echo 0)  # live answers, before the one Assistant switch
  mode=$(defaults read "$BUNDLE_ID" assistantMode 2>/dev/null || echo connectedApp)
  shares=$(defaults read "$BUNDLE_ID" sharesMeetings 2>/dev/null || echo 0)
  if [ "$on" != 1 ]; then run defaults write "$BUNDLE_ID" assistant -bool true; changed=1; fi
  if [ "$mode" = builtIn ] && { [ "$on" = 1 ] || [ "$legacy" = 1 ]; }; then
    skip "Assistant left on Smalt thinking, as you set it"
  else
    [ "$mode" = connectedApp ] || { run defaults write "$BUNDLE_ID" assistantMode connectedApp; changed=1; }
    [ "$shares" = 1 ] || { run defaults write "$BUNDLE_ID" sharesMeetings -bool true; changed=1; }
  fi
  [ -n "$changed" ] || return 0
  ok "Assistant on: run /smalt in Claude Code to talk"
  running && RELAUNCH=1
  return 0
}

uninstall() {
  echo "Disconnecting Smalt"
  if claude=$(find_claude); then
    run "$claude" mcp remove --scope user smalt >/dev/null 2>&1 || true
    run rm -rf "$HOME/.claude/skills/$SKILL" "$HOME/.claude/skills/$VOICE_SKILL"
    ok "Claude Code disconnected"
  fi
  if [ -f "$HOOK" ]; then
    if [ -n "${SMALT_DRY_RUN:-}" ]; then printf '    \033[2mwould remove %s from ~/.claude/settings.json\033[0m\n' "$HOOK"
    else bash "$HOOK" --unregister | sed 's/^/    /' || true
    fi
    run rm -f "$HOOK"
    ok "Claude Code hook removed"
  fi
  if [ -f "$DESKTOP_CONFIG" ]; then
    run cp "$DESKTOP_CONFIG" "$DESKTOP_CONFIG.bak-smalt"
    [ -n "${SMALT_DRY_RUN:-}" ] || desktop_config remove >/dev/null
    ok "Claude Desktop disconnected"
  fi
  echo "  To remove the app too, quit Smalt and drag it from Applications to the Trash."
}

# The updates list, the one question the installer asks, and only when a person is at the terminal and
# no agent runs it (or SMALT_EMAIL is set): Enter skips. The same list as the box on getsmalt.com (POST /beta).
join_beta() {
  local email="${SMALT_EMAIL:-}"
  if [ -z "$email" ] && [ -z "${SMALT_DRY_RUN:-}" ] && [ -z "${SMALT_AGENT:-}" ] && { : </dev/tty; } 2>/dev/null; then
    printf '  Email for updates and the odd feedback question (Enter to skip): '
    read -r email </dev/tty || email=""
  fi
  [ -n "$email" ] || return 0
  case "$email" in *[[:space:]\"\\]*|*@*@*) skip "That doesn't look like an email; skipped"; return 0 ;; *@*.*) ;; *) skip "That doesn't look like an email; skipped"; return 0 ;; esac
  if curl -fsS -o /dev/null -X POST "$SITE/beta" -H "origin: $SITE" -H 'content-type: application/json' \
    -d "{\"email\":\"$email\",\"page\":\"install.sh\"${SRC:+,\"from\":\"$SRC\"}}" 2>/dev/null; then
    ok "You're on the updates list"
  else
    skip "Couldn't reach the updates list; join any time at $SITE"
  fi
}

main() {
  [ "$(uname -s)" = Darwin ] || fail "Smalt is a Mac app"
  [ "$(uname -m)" = arm64 ] || fail "Smalt needs a Mac with Apple Silicon"
  if [ "${1:-}" = --uninstall ]; then uninstall; return; fi
  local os; os=$(sw_vers -productVersion)
  [ "$(printf '%s\n14.4\n' "$os" | sort -V | head -1)" = 14.4 ] || fail "Smalt needs macOS 14.4 or later (this Mac has $os)"

  echo "Installing Smalt${SMALT_DRY_RUN:+ (dry run: nothing changes)}"
  APP="" CONNECTED="" RELAUNCH=""
  install_app
  connect_claude_code
  connect_claude_desktop
  command -v codex >/dev/null 2>&1 && skip "Codex: coming soon ($SITE/codex/)"
  turn_on_assistant

  if [ -n "$RELAUNCH" ] && running; then
    run osascript -e "quit app id \"$BUNDLE_ID\""
    for _ in $(seq 1 50); do running || break; sleep 0.1; done
  fi
  running || run open "$APP"
  join_beta
  echo "Done. Smalt is in your menu bar; it walks you through the microphone and Accessibility permissions."
}

main "$@"
