#!/usr/bin/env bash
set -euo pipefail

# ---------------------------------------------------------------------------
# Set your preferred terminal here, or leave empty to auto-detect.
# One of: ghostty | iterm | alacritty | terminal
# (Also overridable from the environment: PREFERRED_TERM=iterm)
# ---------------------------------------------------------------------------
PREFERRED_TERM="${PREFERRED_TERM:-}"

LOG=/tmp/tuple-trigger-debug.log
{
    printf '\n=== %s call-transcription-started fired (sidekick-cursor) ===\n' "$(date -u +%FT%TZ)"
    printf 'cwd=%s pid=%s ppid=%s\n' "$(pwd)" "$$" "$PPID"
} >> "$LOG" 2>&1
trap 'printf "exit status=%s on line %s\n" "$?" "$LINENO" >> "$LOG"' EXIT
exec >>"$LOG" 2>&1

if [ -z "${TUPLE_TRIGGER_CALL_ARTIFACTS_DIRECTORY:-}" ]; then
    echo "sidekick-cursor: TUPLE_TRIGGER_CALL_ARTIFACTS_DIRECTORY was not set" >&2
    exit 1
fi

TRANSCRIPTION_DIR="${TUPLE_TRIGGER_CALL_ARTIFACTS_DIRECTORY}"
if [ ! -d "${TRANSCRIPTION_DIR}" ]; then
    echo "sidekick-cursor: transcription directory does not exist: ${TRANSCRIPTION_DIR}" >&2
    exit 1
fi

SESSION_DIR="$(basename "${TRANSCRIPTION_DIR}")"
PARENT_DIR="$(dirname "${TRANSCRIPTION_DIR}")"
PARENT_NAME="$(basename "${PARENT_DIR}")"
if [[ "${SESSION_DIR}" == *"@"* ]]; then
    CALL_ID="${SESSION_DIR##*@}"
elif [[ "${PARENT_NAME}" =~ ^[0-9A-Fa-f-]{8,}$ ]]; then
    CALL_ID="${PARENT_NAME}"
else
    CALL_ID="${SESSION_DIR}"
fi

PID_FILE="${TRANSCRIPTION_DIR}/sidekick-cursor.pid"
PROMPT_FILE="${TRANSCRIPTION_DIR}/sidekick-cursor-prompt.md"
LAUNCHER_FILE="${TRANSCRIPTION_DIR}/launch-sidekick-cursor.command"
TRIGGER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
WATCHER_SRC="${TRIGGER_DIR}/tuple-call-watcher.py"
WATCHER_DST="${TRANSCRIPTION_DIR}/tuple-call-watcher.py"

if [ -f "${PID_FILE}" ] && kill -0 "$(cat "${PID_FILE}" 2>/dev/null)" 2>/dev/null; then
    echo "sidekick-cursor: Cursor sidekick already running for ${TRANSCRIPTION_DIR}"
    exit 0
fi

# Ship the fixed, deterministic transcript watcher next to the live transcript
# files so the model runs it verbatim instead of authoring its own watch loop.
if [ -f "${WATCHER_SRC}" ]; then
    cp "${WATCHER_SRC}" "${WATCHER_DST}"
    chmod 0755 "${WATCHER_DST}"
else
    echo "sidekick-cursor: watcher not found at ${WATCHER_SRC}" >&2
    exit 1
fi

# Permissions, scoped — not blanket. Rather than launch Cursor with --force /
# --yolo (which auto-allows *every* command the agent runs), pre-authorize ONLY
# the bundled watcher so its catch-up + --exit-on-batch loop runs unattended, and
# leave every other command to the user's own Cursor approval settings. Cursor
# reads a project-scoped allowlist from `<cwd>/.cursor/cli.json`; the launcher's
# cwd is the transcripts root (PARENT_DIR), so the allowlist goes there. The
# `Shell(<base>)` rule matches on the command's first token, so
# `Shell(*/tuple-call-watcher.py)` permits `./<session-dir>/tuple-call-watcher.py
# ...` (every session dir, including mid-call restarts and later calls) and
# nothing else. Written only when the user has no .cursor/cli.json there already,
# so anyone managing their own Cursor permissions is never overwritten.
CURSOR_CONFIG_DIR="${PARENT_DIR}/.cursor"
CURSOR_CONFIG_FILE="${CURSOR_CONFIG_DIR}/cli.json"
if [ ! -e "${CURSOR_CONFIG_FILE}" ]; then
    mkdir -p "${CURSOR_CONFIG_DIR}"
    cat > "${CURSOR_CONFIG_FILE}" <<'JSON'
{
  "permissions": {
    "allow": ["Shell(*/tuple-call-watcher.py)"],
    "deny": []
  }
}
JSON
    echo "sidekick-cursor: wrote scoped watcher allowlist to ${CURSOR_CONFIG_FILE}"
else
    echo "sidekick-cursor: ${CURSOR_CONFIG_FILE} already exists; leaving the user's Cursor permissions untouched"
fi

cat > "${PROMPT_FILE}.tmpl" <<'PROMPT'
You are a real-time companion on a live Tuple pair-programming call, running inside the Cursor CLI agent. Your working directory is the Tuple transcripts root, which holds one directory per transcription session, each named `<timestamp>@<call-id>`. The active session for *this* call is `./__SESSIONDIR__/`, holding `events.jsonl` and `transcriptions.jsonl` (one JSON record per line). Transcription can stop and restart mid-call; each restart creates a new session directory at the root whose name ends `@__CALLID__`, so this call's full session set is every directory matching `./*@__CALLID__/` — plan for it to grow. Other root directories are prior calls — available if the user asks about earlier discussions, but the active transcription is your focus.

You interact with one human: the user at this terminal. Other call participants don't see your output. You follow this call by running Tuple's bundled watcher script (see Watcher); file reads are available too, for looking something up or revisiting a past call. You have no event-driven wake mechanism — each watcher run blocks until the next batch, prints it, then exits and hands control back to you; you handle the batch and **run it again**. The call keeps going between runs, so don't end your turn (until `call_ended`), and don't daemonize, detach, or background the watcher.

## Catch up first

Your **first** run catches up on everything said before you joined. Run it once with `--catchup`:

    ./__SESSIONDIR__/tuple-call-watcher.py --catchup --offsets cursor

It prints the whole backlog as tagged lines (format under Watcher). Respond with a brief catch-up summary **only** (decisions so far, who's here, open threads). Do **not** dot-line the backlog — dumping the historical records back out is noise. Dot-line logging starts with the live records from the next run.

## File schemas

| File                   | Fields                            | Notes                                                                                                                              |
| ---------------------- | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `events.jsonl`         | `category, message, time, user?`  | Categories: `recording_started`, `recording_stopped`, `user_joined`, `user_left`, `user_audio_started`, `user_audio_stopped`, `call_ended`. |
| `transcriptions.jsonl` | `start, end, text, user_id`       | Resolve `user_id` to a name via `user_joined` events.                                                                              |

If a field looks unfamiliar, read a few lines to confirm shape first.

## Watcher

Tuple ships a fixed watcher script — run it verbatim; do **not** author your own loop. After the catch-up, follow the call live by repeatedly running it in `--exit-on-batch` mode:

    ./__SESSIONDIR__/tuple-call-watcher.py --exit-on-batch --offsets cursor

It follows every session directory of this call, including mid-call restarts. Each run **blocks until the next records arrive, gathers a brief batch (a ~1s pause, or up to ~15s of continuous speech), prints it, and exits**. It resumes exactly where the previous run (or the catch-up) stopped — its own `--offsets cursor` file — so just handle the output and **run the same command again**. It prints records as tagged lines:

    T|<session-dir>|<json-record>   a transcriptions.jsonl record
    E|<session-dir>|<json-record>   an events.jsonl record

The watcher blocks for as long as the call is quiet, and that is expected — let it block; never wrap it in a timeout, never poll it in a tight loop, and never background it. When it returns, handle the batch and run it again.

## Handling each batch

On each live batch, log every record from transcriptions and interesting records from events (especially user joined/left, but NOT user started/stopped sending audio) as a dot-line:

    · HH:MM:SS <speaker_or_event>: <text-or-category>

Stay silent when there's nothing to report: if a run ever returns no records — you interrupted it while it was waiting, or it was cut short — don't announce that you're still watching or that nothing flushed. Say nothing and run the watcher again. Only produce output to log records, answer a wake, or write a summary.

If a transcript record addresses you by name (see Wake words), respond conversationally to that turn — the other records in the batch still get their dot-line. On `recording_stopped`, add a checkpoint summary. On `call_ended`, write a final summary, after which you may stop re-running the watcher and end your turn. Summaries are bulleted decisions, action items (with owner when named), and open threads. No preamble.

## Wake words

A line addresses you when "Cursor" appears as a vocative — at the start of an utterance ("Cursor, ..."), after a greeting ("hey Cursor"), or as a direct question ("Cursor, can you ..."). Incidental mentions like "Cursor is great", "move the cursor", or "put your cursor there" aren't wakes — "cursor" is an everyday editor word, so when in doubt treat it as routine and just dot-line it; a missed wake is cheaper than a false one. Respond to the addressed turn only; other records in the same batch still get their dot-line.

## Terminal input

The user may type to you directly at this terminal; their message reaches you through Cursor (not the watcher), between watcher runs. Reply, then go back to running the watcher. Never end your turn to wait for input; the call continues whether or not the user is typing.

To start: catch up first (`--catchup` + a brief summary), then loop the `--exit-on-batch` watcher and keep it going; do not end your turn until `call_ended` (or the user tells you to stop).

## Following later calls (only if asked)

Your focus is this call, but the transcripts root holds every call. If the user asks you to keep going past it — to follow them into their next call — watch the root for a new `<timestamp>@<call-id>` directory whose call-id differs from this one's (`__CALLID__`). When one appears, catch up on it and loop it exactly as you did here, passing its call-id as the last argument:

    ./__SESSIONDIR__/tuple-call-watcher.py --catchup --offsets cursor <new-call-id>      # catch up
    ./__SESSIONDIR__/tuple-call-watcher.py --exit-on-batch --offsets cursor <new-call-id>  # then loop live

The script lives in this call's dir but follows whatever call-id you pass; its offset files live in the target call's dirs, so reusing `--offsets cursor` across calls is fine. Don't do this unless the user asks.
PROMPT
# bash 3.2 (macOS default) mangles heredocs inside $()/`` so the prompt is
# written straight to a file, then placeholders are substituted with sed.
sed -e "s|__SESSIONDIR__|${SESSION_DIR}|g" -e "s|__CALLID__|${CALL_ID}|g" "${PROMPT_FILE}.tmpl" > "${PROMPT_FILE}"
rm -f "${PROMPT_FILE}.tmpl"

cat > "${LAUNCHER_FILE}" <<'SCRIPT'
#!/bin/zsh -li
printf '\e]0;Tuple Sidekick - Cursor\a'
clear
echo 'Starting Cursor sidekick…'
echo

SCRIPT_DIR="${0:A:h}"
PID_FILE="${SCRIPT_DIR}/sidekick-cursor.pid"
PROMPT_FILE="${SCRIPT_DIR}/sidekick-cursor-prompt.md"

echo $$ > "${PID_FILE}"
# cwd = the transcripts root (parent of this call's session dir) so the watcher
# path `./<session-dir>/...` and the "follow later calls" glob both resolve.
cd "${SCRIPT_DIR}/.." || exit 1

if ! command -v cursor-agent >/dev/null 2>&1; then
    echo "The Cursor CLI agent (cursor-agent) was not found on your interactive shell PATH."
    echo "Install it from https://cursor.com/cli and make sure 'cursor-agent' works in a new"
    echo "terminal (run it once to sign in), then run the trigger again."
    echo
    read '?Press return to close.'
    exit 127
fi
if ! command -v python3 >/dev/null 2>&1; then
    echo "The Tuple watcher needs python3 (install Xcode Command Line Tools: xcode-select --install)."
    echo
    read '?Press return to close.'
    exit 127
fi

# Permissions are handled by the scoped allowlist the trigger wrote to
# <transcripts-root>/.cursor/cli.json, which pre-authorizes only the bundled
# watcher so its loop runs unattended; every other command falls back to your own
# Cursor approval settings. We deliberately do NOT pass --force/--yolo, so the
# sidekick can't run arbitrary commands without your say-so. On first launch
# Cursor asks once whether you trust this directory — accept it to proceed.
#
# Cursor runs shell commands synchronously and blocking, so the watcher's
# --exit-on-batch contract works as-is: each run blocks until a batch, prints it,
# exits, and Cursor hands the turn back. The prompt is passed as the initial
# message; with no -p flag Cursor stays interactive so you can type to it.
#
# Optional passthrough knobs (export in your shell profile; all off by default):
#   SIDEKICK_CURSOR_PLUGIN_DIRS    newline- or colon-separated Cursor plugin
#                                  directories whose approved MCP servers should be
#                                  available to the sidekick. The cursor-agent CLI
#                                  reads MCP servers only from .cursor/mcp.json
#                                  (this workspace) and ~/.cursor/mcp.json — it
#                                  does NOT load the servers that IDE plugins
#                                  install. So for each dir we harvest its
#                                  mcpServers (from .cursor-plugin/plugin.json,
#                                  plugin.json, or .mcp.json) and merge them into
#                                  this workspace's .cursor/mcp.json, leaving your
#                                  plugins as the source of truth for what's
#                                  approved. Existing entries are kept.
#   SIDEKICK_CURSOR_APPROVE_MCPS=1 add --approve-mcps so those servers start
#                                  without an approval prompt stalling the
#                                  hands-free loop.
#   SIDEKICK_CURSOR_EXTRA_ARGS     any other cursor-agent flags, shell-quoted
#                                  (e.g. '--model gpt-5.2').
CURSOR_FLAGS=()
if [ -n "${SIDEKICK_CURSOR_PLUGIN_DIRS:-}" ]; then
    # Accept newline- or colon-separated dirs: normalize newlines to colons, then
    # split once on colons (empties dropped; spaces inside a path preserved).
    _plugin_raw=${SIDEKICK_CURSOR_PLUGIN_DIRS//$'\n'/:}
    _plugin_dirs=(${(s.:.)_plugin_raw})
    if [ ${#_plugin_dirs} -gt 0 ]; then
        # cwd is the transcripts root, so .cursor/mcp.json is this session's
        # workspace config. Harvest is best-effort: any failure just leaves the
        # sidekick running without the merged servers rather than blocking launch.
        python3 - .cursor/mcp.json "${_plugin_dirs[@]}" <<'PY' || true
import json, os, sys
out_path, *dirs = sys.argv[1:]
merged = {}
if os.path.exists(out_path):
    try:
        merged = (json.load(open(out_path)) or {}).get("mcpServers", {}) or {}
    except Exception:
        merged = {}
for d in dirs:
    if not d:
        continue
    for cand in (".cursor-plugin/plugin.json", "plugin.json", ".mcp.json"):
        p = os.path.join(d, cand)
        if not os.path.exists(p):
            continue
        try:
            data = json.load(open(p)) or {}
        except Exception:
            break
        servers = data.get("mcpServers") if isinstance(data.get("mcpServers"), dict) \
            else (data if cand == ".mcp.json" and isinstance(data, dict) else {})
        for name, cfg in (servers or {}).items():
            merged.setdefault(name, cfg)
        break
    else:
        sys.stderr.write("sidekick-cursor: no plugin manifest with mcpServers in %s\n" % d)
os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
with open(out_path, "w") as fh:
    json.dump({"mcpServers": merged}, fh, indent=2)
sys.stderr.write("sidekick-cursor: %d MCP server(s) available via %s\n"
                 % (len(merged), os.path.abspath(out_path)))
PY
    fi
fi
if [ "${SIDEKICK_CURSOR_APPROVE_MCPS:-}" = "1" ]; then
    CURSOR_FLAGS+=(--approve-mcps)
fi
if [ -n "${SIDEKICK_CURSOR_EXTRA_ARGS:-}" ]; then
    CURSOR_FLAGS+=(${(z)SIDEKICK_CURSOR_EXTRA_ARGS})
fi
exec cursor-agent "${CURSOR_FLAGS[@]}" "$(cat "${PROMPT_FILE}")"
SCRIPT
chmod 0755 "${LAUNCHER_FILE}"

# --- launch_in_terminal: open the .command launcher in the user's terminal ---
# Uses LaunchServices (`open`) only — no direct binary exec and no AppleScript,
# so it triggers no macOS accessibility prompt and no stray empty windows.
# With PREFERRED_TERM empty, the launcher opens in your default handler for
# .command files (change it in Finder: right-click a .command > Open With >
# your terminal > Change All). Set PREFERRED_TERM to force one for this trigger.
# Ghostty, iTerm, and Terminal run an opened .command directly; Alacritty has no
# document handler, so it is launched with `open -na ... --args -e`.
launch_in_terminal() {
    local file="$1"
    case "${PREFERRED_TERM:-}" in
    "") open "$file" ;;
    ghostty) open -a "Ghostty" "$file" 2>/dev/null || open "$file" ;;
    iterm) open -a "iTerm" "$file" 2>/dev/null || open "$file" ;;
    terminal) open -a "Terminal" "$file" 2>/dev/null || open "$file" ;;
    alacritty) open -na "Alacritty" --args -e "$file" 2>/dev/null || open "$file" ;;
    *) echo "launch_in_terminal: unknown PREFERRED_TERM='${PREFERRED_TERM}'; using default handler" >&2; open "$file" ;;
    esac
}

if [ "${SIDEKICK_CURSOR_DRY_RUN:-}" = "1" ]; then
    echo "sidekick-cursor: dry run generated ${LAUNCHER_FILE}"
    exit 0
fi

launch_in_terminal "${LAUNCHER_FILE}"
