#!/bin/sh
# AgentIDE's command, shipped inside the app bundle and copied into
# the shared workspace so the sandbox user has it over SSH too.
#
# Without a subcommand it is the editor shim: a shell pane finds it
# on its PATH, so `git rebase -i` and `git commit` there edit their
# files in the app's own editor and wait until they are saved and
# closed. A cancelled edit exits non-zero, which is how git is told
# to abort.
#
# `agentide new` starts a session the way the app does, for a phone
# over SSH. It takes no arguments: it asks for the repository, agent,
# effort, model and prompt in turn, each offering what the window
# last had chosen, so Enter four times and a sentence is a session.
# It then makes the worktree, writes the prompt file, creates the
# herdr workspace under the label the app recognises, launches the
# agent and attaches to it. The app needs telling nothing, since it
# derives every session from herdr and git.
set -eu

# Colour on the same terms Homebrew uses: a terminal that has not
# asked for plain output, or an explicit request. Everything the
# command says goes to stderr, so that is the stream judged.
colour_enabled() {
  [ -n "${AGENTIDE_COLOR:-}" ] && return 0
  [ -t 2 ] && [ -z "${NO_COLOR:-}" ]
}

tint() {
  if colour_enabled; then
    printf '\033[%sm%s\033[0m' "$1" "$2"
  else
    printf '%s' "$2"
  fi
}

bold() {
  tint 1 "$1"
}

# A step, as `==> Doing something`: blue arrow, bold text.
headline() {
  printf '%s %s\n' "$(tint 34 '==>')" "$(bold "$1")" >&2
}

warn() {
  printf '%s %s\n' "$(tint '1;33' 'Warning:')" "$1" >&2
}

fail() {
  printf '%s %s\n' "$(tint '1;31' 'Error:')" "$1" >&2
  exit "${2:-64}"
}

# Everything this command does, printed by --help and by anything it
# cannot make sense of.
usage() {
  cat >&2 <<USAGE
$(bold 'agentide') — AgentIDE from the command line

$(bold 'agentide') [-w|--wait] <file>...
    Show a file in the app's editor. Waits until the file is saved
    and closed only with --wait, which is what EDITOR, VISUAL and
    GIT_EDITOR want; without it the command returns at once.

$(bold 'agentide') <directory>
    Select that worktree or repository in the app. Only a checkout
    under \$SHARED_WORKSPACE/repositories or a worktree under
    \$SHARED_WORKSPACE/worktrees is accepted.

$(bold 'agentide new')
    Start a session: asks for the repository, agent, effort, model
    and prompt, each offering what was last chosen, then makes the
    worktree, launches the agent and attaches to it. Run as
    yourself it runs itself as the sandbox user, which owns the
    herdr server the sessions live in.
USAGE
  exit "${1:-64}"
}

# Where the app publishes what the window last had chosen, so the
# phone offers the same repositories, agents, models and efforts,
# with the same ones already selected.
defaults_file() {
  printf '%s' "${1}/agentide/session-defaults"
}

# One key's value out of that file, empty when it or the file is
# missing.
default_for() {
  [ -f "$2" ] || return 0
  sed -n "s/^$1=//p" "$2" | head -1
}

# Writes choices back into the defaults file, keeping every other
# line: the file is one memory shared with the app, which merges the
# same way, so a session started here is what both offer next.
remember() {
  file="$1"
  shift
  kept="$(cat "${file}" 2>/dev/null || true)"
  for pair in "$@"; do
    kept="$(printf '%s\n' "${kept}" | sed "/^${pair%%=*}=/d")"
  done
  mkdir -p "$(dirname "${file}")"
  {
    printf '%s\n' "${kept}" | sed '/^$/d'
    printf '%s\n' "$@"
  } | sort >"${file}.partial"
  mv "${file}.partial" "${file}"
}

# Asks a question with a numbered list, where Enter takes the
# default, a number takes that item and a name takes itself. Kept to
# two lines, since the reader is on a phone, and asked again when the
# answer is neither, so a mistyped number never starts the wrong
# agent.
ask_choice() {
  label="$1"
  default="$2"
  shift 2
  index=0
  line=""
  plain=""
  for option in "$@"; do
    index=$((index + 1))
    line="${line}$(tint '1;34' "${index}") ${option}  "
    plain="${plain}${index} ${option}  "
  done
  # One option per line when the packed row would wrap: on a phone
  # the wrapped row split numbers from names and read as nonsense.
  width="${COLUMNS:-$(tput cols 2>/dev/null || printf '80')}"
  if [ "${#plain}" -gt "${width}" ]; then
    line=""
    index=0
    for option in "$@"; do
      index=$((index + 1))
      printf '%s %s\n' "$(tint '1;34' "${index}")" "${option}" >&2
    done
  fi
  while true; do
    [ -z "${line}" ] || printf '%s\n' "${line%  }" >&2
    if [ -n "${default}" ]; then
      printf '%s [%s]: ' "$(bold "${label}")" "$(tint 32 "${default}")" >&2
    else
      # Nothing has been chosen before, so nothing is chosen for
      # them: the question waits until it is answered.
      printf '%s: ' "$(bold "${label}")" >&2
    fi
    if ! IFS= read -r answer; then
      printf '\n' >&2
      fail "nothing to read" 65
    fi
    # Enter takes what was chosen last time, when there was one.
    if [ -z "${answer}" ]; then
      if [ -n "${default}" ]; then
        printf '%s' "${default}"
        return 0
      fi
      warn "pick one"
      continue
    fi
    # A list the app has not published yet cannot judge an answer.
    if [ "$#" -eq 0 ]; then
      printf '%s' "${answer}"
      return 0
    fi
    index=0
    for option in "$@"; do
      index=$((index + 1))
      if [ "${answer}" = "${index}" ] || [ "${answer}" = "${option}" ]; then
        printf '%s' "${option}"
        return 0
      fi
    done
    warn "not one of those"

  done
}

# Asks for a line of text, again while it is empty.
ask_text() {
  while true; do
    printf '%s: ' "$(bold "$1")" >&2
    if ! IFS= read -r answer; then
      printf '\n' >&2
      fail "nothing to read" 65
    fi
    if [ -n "${answer}" ]; then
      printf '%s' "${answer}"
      return 0
    fi
  done
}

# The app's own slug: lowercase, anything else a dash, runs collapsed
# and the ends trimmed, never empty.
slug() {
  out="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]' |
    sed -e 's/[^a-z0-9-]/-/g' -e 's/--*/-/g' -e 's/^-//' -e 's/-$//')"
  [ -n "${out}" ] || out="unnamed"
  printf '%s' "${out}"
}

# What the app calls a branch it names from a prompt: the same slug
# over the prompt's first words, with underscores between them.
branch_from_prompt() {
  printf '%s' "$(slug "$(printf '%s' "$1" | cut -c1-40)")" | tr '-' '_'
}

# A new branch starts from the same default as the app. Git's own
# fetch timestamp includes fetches made from either surface.
create_worktree() {
  repository_path="$1"
  branch="$2"
  worktree="$3"
  fetch_head="$(git -C "${repository_path}" rev-parse --path-format=absolute --git-path FETCH_HEAD)"
  fetched_at=0
  # A failed fetch can leave a fresh but empty FETCH_HEAD.
  if [ -s "${fetch_head}" ]; then
    fetched_at="$(stat -f %m "${fetch_head}")"
  fi
  if [ "$(($(date +%s) - fetched_at))" -ge 3600 ]; then
    headline "Fetching repository remotes"
    git -C "${repository_path}" fetch --all --prune >&2
  fi
  base="$(git -C "${repository_path}" symbolic-ref --quiet --short refs/remotes/origin/HEAD)" || base=""
  # A default branch GitHub renamed leaves origin/HEAD naming a branch
  # the pruning fetch just removed: ask origin where its HEAD points
  # now, as the app does, rather than fail on a base that is gone.
  if [ -n "${base}" ] && ! git -C "${repository_path}" rev-parse --verify --quiet "${base}^{commit}" >/dev/null; then
    headline "Following origin's default branch, which has moved"
    git -C "${repository_path}" remote set-head origin --auto >&2 || true
    base="$(git -C "${repository_path}" symbolic-ref --quiet --short refs/remotes/origin/HEAD)" || base=""
  fi
  if [ -z "${base}" ]; then
    base=HEAD
    for candidate in main master; do
      if git -C "${repository_path}" show-ref --verify --quiet "refs/heads/${candidate}"; then
        base="${candidate}"
        break
      fi
    done
  fi
  headline "Creating ${worktree}"
  mkdir -p "$(dirname "${worktree}")"
  git -C "${repository_path}" worktree add --no-track -b "${branch}" "${worktree}" "${base}" >&2
}

# The sessions live in a herdr server the sandbox user owns, and
# only that user can reach its socket. Run as the host user, the
# command therefore runs itself as the sandbox user, through the
# same sudo, env and sandbox-exec shape the app uses, so a terminal
# on the Mac needs no setup of its own.
reexec_in_sandbox() {
  host="${USER}"
  exec sudo --login --set-home --user="sandvault-${host}" \
    /usr/bin/env -i \
    "HOME=/Users/sandvault-${host}" \
    "USER=sandvault-${host}" \
    SHELL=/bin/zsh \
    "TERM=${TERM:-xterm-256color}" \
    COLORTERM=truecolor \
    LANG=en_US.UTF-8 \
    "SHARED_WORKSPACE=/Users/Shared/sv-${host}" \
    "HERDR_SESSION=${HERDR_SESSION:-agentide}" \
    "AGENTIDE_COLOR=${AGENTIDE_COLOR:-1}" \
    PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin \
    GIT_CONFIG_COUNT=1 \
    GIT_CONFIG_KEY_0=safe.directory \
    "GIT_CONFIG_VALUE_0=/Users/Shared/sv-${host}/*" \
    /usr/bin/sandbox-exec -f "/var/sandvault/sandbox-sandvault-${host}.sb" \
    /bin/zsh -c "exec ${0} new"
}

agentide_new() {
  # Already the sandbox user when its home is this account's own,
  # and a dry run never reaches the server, so it stays where it is:
  # tests run as neither user and have no sandbox to enter.
  case "${HOME}" in
    /Users/sandvault-*) ;;
    *)
      if [ -z "${AGENTIDE_DRY_RUN:-}" ]; then
        [ -f "/var/sandvault/sandbox-sandvault-${USER}.sb" ] ||
          fail "no sandvault sandbox for ${USER}; sessions run as its user" 69
        reexec_in_sandbox
      fi
      ;;
  esac

  # The app's session, unless something else is asked for; the
  # server it names is the one the app starts and attaches to.
  export HERDR_SESSION="${HERDR_SESSION:-agentide}"

  # The sandbox user's launcher exports this; a plain SSH login does
  # not, so the host user's name is read off this account's own.
  shared="${SHARED_WORKSPACE:-/Users/Shared/sv-$(printf '%s' "${USER}" | sed 's/^sandvault-//')}"
  defaults="$(defaults_file "${shared}")"

  repositories="$(default_for repositories "${defaults}")"
  if [ -z "${repositories}" ] && [ -d "${shared}/repositories" ]; then
    for entry in "${shared}"/repositories/*/; do
      [ -d "${entry}" ] || continue
      repositories="${repositories}$(basename "${entry}") "
    done
  fi

  # shellcheck disable=SC2086
  repository="$(ask_choice Repository "$(default_for repository "${defaults}")" ${repositories})"
  [ -n "${repository}" ] || fail "no repository to choose from in ${shared}/repositories" 66

  agent="$(ask_choice Agent "$(default_for agent "${defaults}")" claude codex)"
  [ -n "${agent}" ] || agent=claude

  efforts="$(default_for "${agent}-efforts" "${defaults}")"
  models="$(default_for "${agent}-models" "${defaults}")"
  # shellcheck disable=SC2086
  effort="$(ask_choice Effort "$(default_for "${agent}-effort" "${defaults}")" ${efforts})"
  # shellcheck disable=SC2086
  model="$(ask_choice Model "$(default_for "${agent}-model" "${defaults}")" ${models})"

  prompt="$(ask_text Prompt)"

  repository_path="${shared}/repositories/${repository}"
  branch="$(branch_from_prompt "${prompt}")"

  # Branch names are unique, as the app makes them: an existing one
  # counts up rather than failing or joining someone else's work.
  if [ -d "${repository_path}/.git" ] || [ -f "${repository_path}/.git" ]; then
    attempt=2
    while git -C "${repository_path}" rev-parse --verify --quiet \
      "refs/heads/${branch}" >/dev/null 2>&1; do
      branch="$(printf '%s' "${branch}" | sed 's/_[0-9]*$//')_${attempt}"
      attempt=$((attempt + 1))
    done
  elif [ -z "${AGENTIDE_DRY_RUN:-}" ]; then
    fail "no repository at ${repository_path}" 66
  fi

  label="agentide--$(slug "${repository}")--$(slug "${branch}")--${agent}"
  worktree="${shared}/worktrees/${repository}/$(printf '%s' "${branch}" | tr '/' '-')"
  prompt_file="${shared}/agentide/prompts/${label}.md"

  # The launch flags the app would pass for the chosen model and
  # effort; Codex spells its effort as a config override.
  arguments=""
  [ -z "${model}" ] || arguments="--model ${model}"
  if [ -n "${effort}" ]; then
    case "${agent}" in
      codex) arguments="${arguments} -c model_reasoning_effort=${effort}" ;;
      *) arguments="${arguments} --effort ${effort}" ;;
    esac
  fi
  arguments="$(printf '%s' "${arguments}" | sed -e 's/^ *//' -e 's/ *$//')"
  command="${agent}"
  [ -z "${arguments}" ] || command="${command} ${arguments}"
  command="${command} \"\$(cat '${prompt_file}')\""

  if [ -n "${AGENTIDE_DRY_RUN:-}" ]; then
    remember "${defaults}" "repository=${repository}" "agent=${agent}" \
      "${agent}-model=${model}" "${agent}-effort=${effort}"
    printf 'branch=%s\nlabel=%s\nworktree=%s\nprompt=%s\ncommand=%s\n' \
      "${branch}" "${label}" "${worktree}" "${prompt_file}" "${command}"
    return 0
  fi

  # The app's session, made the app's way: worktree, prompt file,
  # then a labelled herdr workspace whose shell runs the agent under
  # a temporary directory of its own, which Codex's execution host
  # needs to survive its handshake.
  create_worktree "${repository_path}" "${branch}" "${worktree}"
  mkdir -p "$(dirname "${prompt_file}")"
  printf '%s\n' "${prompt}" >"${prompt_file}"

  headline "Starting ${agent} in ${label}"
  created="$(herdr workspace create --cwd "${worktree}" --label "${label}" \
    --env "AGENTIDE_SESSION=${label}" --env "INITIAL_DIR=${worktree}" --no-focus)"
  pane="$(printf '%s' "${created}" |
    grep -o '"pane_id":"[^"]*"' | head -1 | cut -d '"' -f4)"
  [ -n "${pane}" ] || fail "herdr did not name a pane for ${label}" 70
  herdr pane run "${pane}" "export TMPDIR=\"\$(mktemp -d)\"; ${command}"

  workspace="$(printf '%s' "${created}" |
    grep -o '"workspace_id":"[^"]*"' | head -1 | cut -d '"' -f4)"
  [ -z "${workspace}" ] || herdr workspace focus "${workspace}" >/dev/null 2>&1 || true
  # Already inside a herdr pane (a phone attached through
  # script/attach, say): the workspace is focused and that is the
  # attach; a nested client is refused by herdr and pointless anyway.
  if [ -n "${HERDR_PANE_ID:-}" ]; then
    headline "Switched to ${label}"
    return 0
  fi
  exec herdr
}

if [ "${1:-}" = "new" ]; then
  agentide_new
  exit 0
fi

# The app names its own spool, so a development build never answers
# the installed app's shells; anything else means the installed one.
spool="${AGENTIDE_EDITS:-${HOME}/.agentide/edits}"

# The app bundle this command shipped in: bin sits at
# Contents/Resources/bin, so three directories up is the app. The
# copy in the shared workspace (for SSH) lives outside any bundle
# and stays empty, which skips the foregrounding below.
app_bundle="$(cd "$(dirname "$0")/../../.." 2>/dev/null && pwd -P || true)"
case "${app_bundle}" in
  *.app) ;;
  *) app_bundle="" ;;
esac

# How long to wait for the app to take a request, as a count of
# polls: long enough to cover a busy app, short enough that a shell
# without one says so rather than hanging.
claim_tries=50
poll_seconds=0.2

wait_for_answer=no
while [ "$#" -gt 0 ]; do
  case "$1" in
    # Only an editor waits: EDITOR, VISUAL and GIT_EDITOR are set
    # with the flag, and anything else wants its shell back.
    -w | --wait)
      wait_for_answer=yes
      shift
      ;;
    -h | --help) usage 0 ;;
    --)
      shift
      break
      ;;
    -*)
      warn "unknown option: $1"
      usage
      ;;
    *) break ;;
  esac
done

[ "$#" -gt 0 ] || usage

mkdir -p "${spool}"

# The physical directory, since that is how git names a worktree and
# how the app matches one.
cwd="$(pwd -P)"

for file in "$@"; do
  case "${file}" in
    /*) path="${file}" ;;
    *) path="${cwd}/${file}" ;;
  esac
  kind=open
  [ "${wait_for_answer}" = no ] || kind=edit
  request_cwd="${cwd}"
  # A symlinked file resolves to its target: the editor saves
  # atomically, which would otherwise replace the link itself with
  # a plain file. A path that does not exist yet stays as typed.
  if [ ! -d "${path}" ] && [ -e "${path}" ]; then
    path="$(realpath "${path}" 2>/dev/null || printf '%s' "${path}")"
  fi
  if [ -d "${path}" ]; then
    # A directory is a place to go to, and only a checkout or a
    # worktree of the shared workspace is one the app can show.
    shared="${SHARED_WORKSPACE:-/Users/Shared/sv-$(printf '%s' "${USER}" | sed 's/^sandvault-//')}"
    # Both sides physical, since a temporary directory or a home on
    # a symlink would otherwise never match its own workspace.
    shared="$(cd "${shared}" 2>/dev/null && pwd -P || printf '%s' "${shared}")"
    path="$(cd "${path}" && pwd -P)"
    # A checkout is one name under `repositories`, a worktree two
    # under `worktrees`, and those are the rows the app can select.
    # Anywhere inside one counts as it: `agentide .` deep in a tree
    # is how you get back to the window, so the path walks up until
    # it is a row or has left the workspace.
    row=""
    candidate="${path}"
    listed_file="${shared}/agentide/host-directories"
    while [ "${candidate}" != "/" ]; do
      # A directory of your own, as the app lists them: a row like
      # any other, so `agentide .` anywhere inside one selects it.
      if [ -f "${listed_file}" ]; then
        while IFS= read -r listed; do
          [ -n "${listed}" ] || continue
          physical="$(cd "${listed}" 2>/dev/null && pwd -P || printf '%s' "${listed}")"
          if [ "${candidate}" = "${listed}" ] || [ "${candidate}" = "${physical}" ]; then
            row="${candidate}"
            break
          fi
        done <"${listed_file}"
      fi
      [ -z "${row}" ] || break
      case "${candidate}" in
        "${shared}/"*) rest="${candidate#"${shared}/"}" ;;
        *) rest="" ;;
      esac
      case "${rest}" in
        repositories/* | worktrees/*/*)
          # A checkout is one name under `repositories`, a worktree
          # two under `worktrees`; anything deeper walks up to it.
          case "${rest}" in
            repositories/*/* | worktrees/*/*/*) ;;
            *)
              row="${candidate}"
              break
              ;;
          esac
          ;;
        "")
          # Outside the workspace: only a listed directory above can
          # make this a row.
          ;;
        *) break ;;
      esac
      candidate="$(dirname "${candidate}")"
    done
    [ -n "${row}" ] || fail "not inside a repository, worktree or listed directory: ${path}" 66
    path="${row}"
    kind=select
    request_cwd="${path}"
  fi
  id="$(/usr/bin/uuidgen)"
  request="${spool}/${id}.request"
  trap 'rm -f "${request}" "${spool}/${id}.open" "${spool}/${id}.done"; exit 130' HUP INT TERM
  printf '{"path":"%s","workingDirectory":"%s","processIdentifier":%s,"kind":"%s"}\n' \
    "$(printf '%s' "${path}" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')" \
    "$(printf '%s' "${request_cwd}" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')" \
    "$$" "${kind}" >"${request}.partial"
  mv "${request}.partial" "${request}"

  # Only an editor waits for the app; everything else has said what
  # it wanted and is done.
  [ "${kind}" = edit ] || continue

  # The app claims a request as soon as it shows the file, so an
  # unclaimed one means it is not running.
  tries=0
  while [ ! -e "${spool}/${id}.open" ] && [ ! -e "${spool}/${id}.done" ]; do
    if [ "${tries}" -ge "${claim_tries}" ]; then
      rm -f "${request}"
      fail "AgentIDE did not answer; is it running?" 69
    fi
    tries=$((tries + 1))
    sleep "${poll_seconds}"
  done
  # The file is on screen: bring the app forward. The terminal's own
  # child may ask that of the system where the app asking for itself
  # is refused by cooperative activation.
  [ -z "${app_bundle}" ] || open "${app_bundle}" 2>/dev/null || true
  while [ ! -e "${spool}/${id}.done" ]; do
    sleep "${poll_seconds}"
  done

  status="$(cat "${spool}/${id}.done")"
  rm -f "${request}" "${spool}/${id}.open" "${spool}/${id}.done"
  trap - HUP INT TERM
  [ "${status}" = 0 ] || exit "${status}"
done
