diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..dd34d55 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,22 @@ +name: Plugin Tests +on: + pull_request: + push: + branches: [main] + +jobs: + plugin-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run tests + # Auto-discovers and runs all test-*.sh scripts under any tests/ directory. + # To add a new test, just drop a test-*.sh file in a tests/ folder. + run: | + shopt -s globstar nullglob + failed=0 + for f in **/tests/test-*.sh; do + echo "--- $f ---" + bash "$f" || failed=1 + done + exit $failed diff --git a/README.md b/README.md new file mode 100644 index 0000000..b2a3f75 --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# Gemini CLI + Warp + +Official [Warp](https://warp.dev) terminal integration for [Gemini CLI](https://github.com/google-gemini/gemini-cli). + +## Features + +### 🔔 Native Notifications + +Get native Warp notifications when Gemini CLI: +- **Completes a task** — with a summary showing your prompt and Gemini's response +- **Needs your input** — when Gemini has been idle and is waiting for you +- **Requests permission** — when Gemini wants to run a tool and needs your approval + +Notifications appear in Warp's notification center and as system notifications, so you can context-switch while Gemini works and get alerted when attention is needed. + +### 📡 Session Status + +The extension keeps Warp informed of Gemini's current state by emitting structured events on every session transition: +- **Prompt submitted** — you sent a prompt, Gemini is working +- **Tool completed** — a tool call finished, Gemini is back to running + +This powers Warp's inline status indicators for Gemini CLI sessions. + +## Installation + +```bash +gemini extensions install +``` + +For local development: + +```bash +gemini extensions link ~/gemini-warp +``` + +> ⚠️ **Important**: After installing, **restart Gemini CLI** for the extension to activate. + +Once restarted, notifications will appear automatically. + +## Requirements + +- [Warp terminal](https://warp.dev) (macOS, Linux, or Windows) +- [Gemini CLI](https://github.com/google-gemini/gemini-cli) v0.26.0+ +- `jq` for JSON parsing (install via `brew install jq` or your package manager) + +## How It Works + +The extension communicates with Warp via OSC 777 escape sequences. Each hook script builds a structured JSON payload (via `build-payload.sh`) and sends it to `warp://cli-agent`, where Warp parses it to drive notifications and session UI. + +Payloads include a protocol version negotiated between the extension and Warp (`min(plugin_version, warp_version)`), the session ID, working directory, and event-specific fields. + +The extension registers five hooks: +- **SessionStart** — emits the extension version on startup +- **AfterAgent** — fires when Gemini finishes a turn, sends a task-complete notification with your prompt and Gemini's response +- **Notification** — handles both idle notifications and tool-permission requests (Gemini merges these into one event, dispatched by `notification_type`) +- **BeforeAgent** — fires when you submit a prompt, signaling the session is active again +- **AfterTool** — fires when a tool call completes, signaling the session is no longer blocked + +## Configuration + +Notifications work out of the box. To customize Warp's notification behavior (sounds, system notifications, etc.), see [Warp's notification settings](https://docs.warp.dev/features/notifications). + +## Uninstall + +```bash +gemini extensions uninstall warp +``` + +## Versioning + +The extension version in `gemini-extension.json` is checked by the Warp client to detect outdated installations. +When bumping the version here, also update `MINIMUM_PLUGIN_VERSION` in the Warp client. + +## License + +MIT License — see [LICENSE](LICENSE) for details. diff --git a/gemini-extension.json b/gemini-extension.json new file mode 100644 index 0000000..64aab96 --- /dev/null +++ b/gemini-extension.json @@ -0,0 +1,5 @@ +{ + "name": "gemini-warp", + "description": "Warp terminal integration for Gemini CLI - native notifications and session status", + "version": "1.0.0" +} diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..93d3833 --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,55 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup", + "hooks": [ + { + "type": "command", + "command": "${extensionPath}/scripts/on-session-start.sh" + } + ] + } + ], + "AfterAgent": [ + { + "hooks": [ + { + "type": "command", + "command": "${extensionPath}/scripts/on-stop.sh" + } + ] + } + ], + "Notification": [ + { + "hooks": [ + { + "type": "command", + "command": "${extensionPath}/scripts/on-notification.sh" + } + ] + } + ], + "BeforeAgent": [ + { + "hooks": [ + { + "type": "command", + "command": "${extensionPath}/scripts/on-prompt-submit.sh" + } + ] + } + ], + "AfterTool": [ + { + "hooks": [ + { + "type": "command", + "command": "${extensionPath}/scripts/on-post-tool-use.sh" + } + ] + } + ] + } +} diff --git a/scripts/build-payload.sh b/scripts/build-payload.sh new file mode 100755 index 0000000..75db2bb --- /dev/null +++ b/scripts/build-payload.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# Builds a structured JSON notification payload for warp://cli-agent. +# +# Usage: source this file, then call build_payload with event-specific fields. +# +# Example: +# source "$(dirname "${BASH_SOURCE[0]}")/build-payload.sh" +# BODY=$(build_payload "$INPUT" "stop" \ +# --arg query "$QUERY" \ +# --arg response "$RESPONSE" \ +# --arg transcript_path "$TRANSCRIPT_PATH") +# +# The function extracts common fields (session_id, cwd, project) from the +# hook's stdin JSON (passed as $1), then merges any extra jq args you pass. + +# The current protocol version this plugin knows how to produce. +PLUGIN_CURRENT_PROTOCOL_VERSION=1 + +# Negotiate the protocol version with Warp. +# Uses min(plugin_current, warp_declared), falling back to 1 if Warp doesn't advertise a version. +negotiate_protocol_version() { + local warp_version="${WARP_CLI_AGENT_PROTOCOL_VERSION:-1}" + if [ "$warp_version" -lt "$PLUGIN_CURRENT_PROTOCOL_VERSION" ] 2>/dev/null; then + echo "$warp_version" + else + echo "$PLUGIN_CURRENT_PROTOCOL_VERSION" + fi +} + +build_payload() { + local input="$1" + local event="$2" + shift 2 + + local protocol_version + protocol_version=$(negotiate_protocol_version) + + # Extract common fields from the hook input + local session_id cwd project + session_id=$(echo "$input" | jq -r '.session_id // empty' 2>/dev/null) + cwd=$(echo "$input" | jq -r '.cwd // empty' 2>/dev/null) + project="" + if [ -n "$cwd" ]; then + project=$(basename "$cwd") + fi + + # Build the payload: common fields + any extra args passed by the caller. + # Extra args should be jq flag pairs like: --arg key "value" or --argjson key '{"a":1}' + jq -nc \ + --argjson v "$protocol_version" \ + --arg agent "gemini" \ + --arg event "$event" \ + --arg session_id "$session_id" \ + --arg cwd "$cwd" \ + --arg project "$project" \ + "$@" \ + '{v:$v, agent:$agent, event:$event, session_id:$session_id, cwd:$cwd, project:$project} + $ARGS.named' +} diff --git a/scripts/on-notification.sh b/scripts/on-notification.sh new file mode 100755 index 0000000..25f03d4 --- /dev/null +++ b/scripts/on-notification.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# Hook script for Gemini CLI Notification event +# Handles both idle/input-needed notifications and ToolPermission (approval) notifications. +# (In Claude Code these are separate hooks: Notification + PermissionRequest. +# In Gemini CLI they are both sub-types of the Notification event.) + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/should-use-structured.sh" + +if ! should_use_structured; then + exit 0 +fi + +source "$SCRIPT_DIR/build-payload.sh" + +# Read hook input from stdin +INPUT=$(cat) + +NOTIF_TYPE=$(echo "$INPUT" | jq -r '.notification_type // "unknown"' 2>/dev/null) + +case "$NOTIF_TYPE" in + ToolPermission) + # Permission request — Gemini puts tool info in .details + TOOL_NAME=$(echo "$INPUT" | jq -r '.details.rootCommand // .details.toolDisplayName // .details.toolName // .details.title // "unknown"' 2>/dev/null) + TOOL_INPUT=$(echo "$INPUT" | jq -c '.details // {}' 2>/dev/null) + [ -z "$TOOL_INPUT" ] && TOOL_INPUT='{}' + + # Build a human-readable summary + TOOL_PREVIEW=$(echo "$INPUT" | jq -r '(.details | if .command then .command elif .filePath then .filePath elif .toolName then .toolName else (.title // "") end)' 2>/dev/null) + SUMMARY="Wants to run $TOOL_NAME" + if [ -n "$TOOL_PREVIEW" ] && [ "$TOOL_PREVIEW" != "$TOOL_NAME" ]; then + if [ ${#TOOL_PREVIEW} -gt 120 ]; then + TOOL_PREVIEW="${TOOL_PREVIEW:0:117}..." + fi + SUMMARY="$SUMMARY: $TOOL_PREVIEW" + fi + + BODY=$(build_payload "$INPUT" "permission_request" \ + --arg summary "$SUMMARY" \ + --arg tool_name "$TOOL_NAME" \ + --argjson tool_input "$TOOL_INPUT") + + "$SCRIPT_DIR/warp-notify.sh" "warp://cli-agent" "$BODY" + ;; + *) + # Generic notification (idle_prompt, etc.) + MSG=$(echo "$INPUT" | jq -r '.message // "Input needed"' 2>/dev/null) + [ -z "$MSG" ] && MSG="Input needed" + + BODY=$(build_payload "$INPUT" "$NOTIF_TYPE" \ + --arg summary "$MSG") + + "$SCRIPT_DIR/warp-notify.sh" "warp://cli-agent" "$BODY" + ;; +esac diff --git a/scripts/on-post-tool-use.sh b/scripts/on-post-tool-use.sh new file mode 100755 index 0000000..6c98603 --- /dev/null +++ b/scripts/on-post-tool-use.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Hook script for Gemini CLI AfterTool event (equivalent to Claude Code's PostToolUse) +# Sends a structured Warp notification after a tool call completes, +# transitioning the session status from Blocked back to Running. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/should-use-structured.sh" + +if ! should_use_structured; then + echo '{}' + exit 0 +fi + +source "$SCRIPT_DIR/build-payload.sh" + +# Read hook input from stdin +INPUT=$(cat) + +TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null) + +BODY=$(build_payload "$INPUT" "tool_complete" \ + --arg tool_name "$TOOL_NAME") + +"$SCRIPT_DIR/warp-notify.sh" "warp://cli-agent" "$BODY" + +# Output empty JSON so we don't interfere with the agent +echo '{}' diff --git a/scripts/on-prompt-submit.sh b/scripts/on-prompt-submit.sh new file mode 100755 index 0000000..5c67897 --- /dev/null +++ b/scripts/on-prompt-submit.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Hook script for Gemini CLI BeforeAgent event (equivalent to Claude Code's UserPromptSubmit) +# Sends a structured Warp notification when the user submits a prompt, +# transitioning the session status from idle/blocked back to running. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/should-use-structured.sh" + +if ! should_use_structured; then + echo '{}' + exit 0 +fi + +source "$SCRIPT_DIR/build-payload.sh" + +# Read hook input from stdin +INPUT=$(cat) + +# Extract the user's prompt +QUERY=$(echo "$INPUT" | jq -r '.prompt // empty' 2>/dev/null) +if [ -n "$QUERY" ] && [ ${#QUERY} -gt 200 ]; then + QUERY="${QUERY:0:197}..." +fi + +BODY=$(build_payload "$INPUT" "prompt_submit" \ + --arg query "$QUERY") + +"$SCRIPT_DIR/warp-notify.sh" "warp://cli-agent" "$BODY" + +# Output empty JSON so we don't interfere with the agent +echo '{}' diff --git a/scripts/on-session-start.sh b/scripts/on-session-start.sh new file mode 100755 index 0000000..66c2c25 --- /dev/null +++ b/scripts/on-session-start.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Hook script for Gemini CLI SessionStart event +# Shows welcome message, Warp detection status, and emits plugin version + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/should-use-structured.sh" + +if ! should_use_structured; then + exit 0 +fi + +if ! command -v jq &>/dev/null; then + cat << 'EOF' +{ + "systemMessage": "🚨 Warp notifications require jq! Install it with your system package manager (e.g. brew install jq, apt install jq) 🚨" +} +EOF + exit 0 +fi +source "$SCRIPT_DIR/build-payload.sh" + +# Read hook input from stdin +INPUT=$(cat) + +# Read plugin version from gemini-extension.json +PLUGIN_VERSION=$(jq -r '.version // "unknown"' "$SCRIPT_DIR/../gemini-extension.json" 2>/dev/null) + +# Emit structured notification with plugin version so Warp can track it +BODY=$(build_payload "$INPUT" "session_start" \ + --arg plugin_version "$PLUGIN_VERSION") +"$SCRIPT_DIR/warp-notify.sh" "warp://cli-agent" "$BODY" diff --git a/scripts/on-stop.sh b/scripts/on-stop.sh new file mode 100755 index 0000000..6802978 --- /dev/null +++ b/scripts/on-stop.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# Hook script for Gemini CLI AfterAgent event (equivalent to Claude Code's Stop) +# Sends a structured Warp notification when Gemini completes a task + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/should-use-structured.sh" + +if ! should_use_structured; then + exit 0 +fi + +source "$SCRIPT_DIR/build-payload.sh" + +# Read hook input from stdin +INPUT=$(cat) + +# Skip if a stop hook is already active (prevents double-notification on retries) +STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false' 2>/dev/null) +if [ "$STOP_HOOK_ACTIVE" = "true" ]; then + exit 0 +fi + +# Gemini's AfterAgent provides prompt and prompt_response directly — no transcript parsing needed. +QUERY=$(echo "$INPUT" | jq -r '.prompt // empty' 2>/dev/null) +RESPONSE=$(echo "$INPUT" | jq -r '.prompt_response // empty' 2>/dev/null) +TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null) + +# Truncate for notification display +if [ -n "$QUERY" ] && [ ${#QUERY} -gt 200 ]; then + QUERY="${QUERY:0:197}..." +fi +if [ -n "$RESPONSE" ] && [ ${#RESPONSE} -gt 200 ]; then + RESPONSE="${RESPONSE:0:197}..." +fi + +BODY=$(build_payload "$INPUT" "stop" \ + --arg query "$QUERY" \ + --arg response "$RESPONSE" \ + --arg transcript_path "$TRANSCRIPT_PATH") + +"$SCRIPT_DIR/warp-notify.sh" "warp://cli-agent" "$BODY" + +# Output empty JSON so we don't interfere with the agent +echo '{}' diff --git a/scripts/should-use-structured.sh b/scripts/should-use-structured.sh new file mode 100755 index 0000000..fca6502 --- /dev/null +++ b/scripts/should-use-structured.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# Determines whether the current Warp build supports structured CLI agent notifications. +# +# Usage: +# source "$SCRIPT_DIR/should-use-structured.sh" +# if should_use_structured; then +# # ... send structured notification +# else +# # ... legacy fallback or exit +# fi +# +# Returns 0 (true) when structured notifications are safe to use, 1 (false) otherwise. + +# Last known Warp release per channel that unconditionally set +# WARP_CLI_AGENT_PROTOCOL_VERSION without gating it behind the +# HOANotifications feature flag. These builds advertise protocol +# support but can't actually render structured notifications. +LAST_BROKEN_DEV="" +LAST_BROKEN_STABLE="v0.2026.03.25.08.24.stable_05" +LAST_BROKEN_PREVIEW="v0.2026.03.25.08.24.preview_05" + +should_use_structured() { + # No protocol version advertised → Warp doesn't know about structured notifications. + [ -z "${WARP_CLI_AGENT_PROTOCOL_VERSION:-}" ] && return 1 + + # No client version available → can't verify this build has the fix. + [ -z "${WARP_CLIENT_VERSION:-}" ] && return 1 + + # Check whether this version is at or before the last broken release for its channel. + local threshold="" + case "$WARP_CLIENT_VERSION" in + *dev*) threshold="$LAST_BROKEN_DEV" ;; + *stable*) threshold="$LAST_BROKEN_STABLE" ;; + *preview*) threshold="$LAST_BROKEN_PREVIEW" ;; + esac + + # If we matched a channel and the version is <= the broken threshold, fall back. + if [ -n "$threshold" ] && [[ ! "$WARP_CLIENT_VERSION" > "$threshold" ]]; then + return 1 + fi + + return 0 +} diff --git a/scripts/warp-notify.sh b/scripts/warp-notify.sh new file mode 100755 index 0000000..523f873 --- /dev/null +++ b/scripts/warp-notify.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Warp notification utility using OSC escape sequences +# Usage: warp-notify.sh <body> +# +# For structured Warp notifications, title should be "warp://cli-agent" +# and body should be a JSON string matching the cli-agent notification schema. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/should-use-structured.sh" + +# Only emit notifications when we've confirmed the Warp build can render them. +if ! should_use_structured; then + exit 0 +fi + +TITLE="${1:-Notification}" +BODY="${2:-}" + +# OSC 777 format: \033]777;notify;<title>;<body>\007 +# Write directly to /dev/tty to ensure it reaches the terminal +printf '\033]777;notify;%s;%s\007' "$TITLE" "$BODY" > /dev/tty 2>/dev/null || true diff --git a/tests/test-hooks.sh b/tests/test-hooks.sh new file mode 100644 index 0000000..002baf1 --- /dev/null +++ b/tests/test-hooks.sh @@ -0,0 +1,220 @@ +#!/bin/bash +# Tests for the Warp Gemini CLI plugin hook scripts. +# +# Validates that each hook script produces correctly structured JSON payloads +# by piping mock Gemini CLI hook input into the scripts and checking the output. +# +# Usage: ./tests/test-hooks.sh +# +# Since the hook scripts write OSC sequences to /dev/tty (not stdout), +# we test build-payload.sh directly — it's the shared JSON construction logic +# that all hook scripts use. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../scripts" && pwd)" +source "$SCRIPT_DIR/build-payload.sh" + +PASSED=0 +FAILED=0 + +# --- Test helpers --- + +assert_eq() { + local test_name="$1" + local expected="$2" + local actual="$3" + if [ "$expected" = "$actual" ]; then + echo " ✓ $test_name" + PASSED=$((PASSED + 1)) + else + echo " ✗ $test_name" + echo " expected: $expected" + echo " actual: $actual" + FAILED=$((FAILED + 1)) + fi +} + +assert_json_field() { + local test_name="$1" + local json="$2" + local field="$3" + local expected="$4" + local actual + actual=$(echo "$json" | jq -r "$field" 2>/dev/null) + assert_eq "$test_name" "$expected" "$actual" +} + +# --- Tests --- + +echo "=== build-payload.sh ===" + +echo "" +echo "--- Common fields ---" +PAYLOAD=$(build_payload '{"session_id":"sess-123","cwd":"/Users/alice/my-project"}' "stop") +assert_json_field "v is 1" "$PAYLOAD" ".v" "1" +assert_json_field "agent is gemini" "$PAYLOAD" ".agent" "gemini" +assert_json_field "event is stop" "$PAYLOAD" ".event" "stop" +assert_json_field "session_id extracted" "$PAYLOAD" ".session_id" "sess-123" +assert_json_field "cwd extracted" "$PAYLOAD" ".cwd" "/Users/alice/my-project" +assert_json_field "project is basename of cwd" "$PAYLOAD" ".project" "my-project" + +echo "" +echo "--- Common fields with missing data ---" +PAYLOAD=$(build_payload '{}' "stop") +assert_json_field "empty session_id" "$PAYLOAD" ".session_id" "" +assert_json_field "empty cwd" "$PAYLOAD" ".cwd" "" +assert_json_field "empty project" "$PAYLOAD" ".project" "" + +echo "" +echo "--- Extra args are merged ---" +PAYLOAD=$(build_payload '{"session_id":"s1","cwd":"/tmp/proj"}' "stop" \ + --arg query "hello" \ + --arg response "world") +assert_json_field "query merged" "$PAYLOAD" ".query" "hello" +assert_json_field "response merged" "$PAYLOAD" ".response" "world" +assert_json_field "common fields still present" "$PAYLOAD" ".session_id" "s1" + +echo "" +echo "--- Stop event ---" +PAYLOAD=$(build_payload '{"session_id":"s1","cwd":"/tmp/proj"}' "stop" \ + --arg query "write a haiku" \ + --arg response "Code flows like water, through silicon pathways bright" \ + --arg transcript_path "/tmp/transcript.jsonl") +assert_json_field "event is stop" "$PAYLOAD" ".event" "stop" +assert_json_field "query present" "$PAYLOAD" ".query" "write a haiku" +assert_json_field "response present" "$PAYLOAD" ".response" "Code flows like water, through silicon pathways bright" +assert_json_field "transcript_path present" "$PAYLOAD" ".transcript_path" "/tmp/transcript.jsonl" + +echo "" +echo "--- Permission request event ---" +PAYLOAD=$(build_payload '{"session_id":"s1","cwd":"/tmp/proj"}' "permission_request" \ + --arg summary "Wants to run Bash: rm -rf /tmp" \ + --arg tool_name "Bash" \ + --argjson tool_input '{"command":"rm -rf /tmp"}') +assert_json_field "event is permission_request" "$PAYLOAD" ".event" "permission_request" +assert_json_field "summary present" "$PAYLOAD" ".summary" "Wants to run Bash: rm -rf /tmp" +assert_json_field "tool_name present" "$PAYLOAD" ".tool_name" "Bash" +assert_json_field "tool_input.command present" "$PAYLOAD" ".tool_input.command" "rm -rf /tmp" + +echo "" +echo "--- Idle prompt event ---" +PAYLOAD=$(build_payload '{"session_id":"s1","cwd":"/tmp/proj","notification_type":"idle_prompt"}' "idle_prompt" \ + --arg summary "Gemini is waiting for your input") +assert_json_field "event is idle_prompt" "$PAYLOAD" ".event" "idle_prompt" +assert_json_field "summary present" "$PAYLOAD" ".summary" "Gemini is waiting for your input" + +echo "" +echo "--- JSON special characters in values ---" +PAYLOAD=$(build_payload '{"session_id":"s1","cwd":"/tmp/proj"}' "stop" \ + --arg query 'what does "hello world" mean?' \ + --arg response 'It means greeting. Use: printf("hello")') +assert_json_field "quotes in query preserved" "$PAYLOAD" ".query" 'what does "hello world" mean?' +assert_json_field "parens in response preserved" "$PAYLOAD" ".response" 'It means greeting. Use: printf("hello")' + +echo "" +echo "--- Protocol version negotiation ---" + +# Default: no env var set → falls back to plugin max (1) +unset WARP_CLI_AGENT_PROTOCOL_VERSION +PAYLOAD=$(build_payload '{"session_id":"s1","cwd":"/tmp"}' "stop") +assert_json_field "defaults to v1 when env var absent" "$PAYLOAD" ".v" "1" + +# Warp declares v1 → use 1 +export WARP_CLI_AGENT_PROTOCOL_VERSION=1 +PAYLOAD=$(build_payload '{"session_id":"s1","cwd":"/tmp"}' "stop") +assert_json_field "v1 when warp declares 1" "$PAYLOAD" ".v" "1" + +# Warp declares a higher version than the plugin knows → capped to plugin current +export WARP_CLI_AGENT_PROTOCOL_VERSION=99 +PAYLOAD=$(build_payload '{"session_id":"s1","cwd":"/tmp"}' "stop") +assert_json_field "capped to plugin current when warp is ahead" "$PAYLOAD" ".v" "1" + +# Warp declares a lower version than the plugin knows → use warp's version +PLUGIN_CURRENT_PROTOCOL_VERSION=5 +export WARP_CLI_AGENT_PROTOCOL_VERSION=3 +PAYLOAD=$(build_payload '{"session_id":"s1","cwd":"/tmp"}' "stop") +assert_json_field "uses warp version when plugin is ahead" "$PAYLOAD" ".v" "3" +PLUGIN_CURRENT_PROTOCOL_VERSION=1 + +# Clean up +unset WARP_CLI_AGENT_PROTOCOL_VERSION + +echo "" +echo "=== should-use-structured.sh ===" + +source "$SCRIPT_DIR/should-use-structured.sh" + +echo "" +echo "--- No protocol version → legacy ---" +unset WARP_CLI_AGENT_PROTOCOL_VERSION +unset WARP_CLIENT_VERSION +should_use_structured +assert_eq "no protocol version returns false" "1" "$?" + +echo "" +echo "--- Protocol set, no client version → legacy ---" +export WARP_CLI_AGENT_PROTOCOL_VERSION=1 +unset WARP_CLIENT_VERSION +should_use_structured +assert_eq "missing WARP_CLIENT_VERSION returns false" "1" "$?" + +echo "" +echo "--- Protocol set, dev version → always structured (dev was never broken) ---" +export WARP_CLI_AGENT_PROTOCOL_VERSION=1 +export WARP_CLIENT_VERSION="v0.2026.03.30.08.43.dev_00" +should_use_structured +assert_eq "dev version returns true" "0" "$?" + +echo "" +echo "--- Protocol set, broken stable version → legacy ---" +export WARP_CLIENT_VERSION="v0.2026.03.25.08.24.stable_05" +should_use_structured +assert_eq "exact broken stable version returns false" "1" "$?" + +echo "" +echo "--- Protocol set, newer stable version → structured ---" +export WARP_CLIENT_VERSION="v0.2026.04.01.08.00.stable_00" +should_use_structured +assert_eq "newer stable version returns true" "0" "$?" + +echo "" +echo "--- Protocol set, broken preview version → legacy ---" +export WARP_CLIENT_VERSION="v0.2026.03.25.08.24.preview_05" +should_use_structured +assert_eq "exact broken preview version returns false" "1" "$?" + +echo "" +echo "--- Protocol set, newer preview version → structured ---" +export WARP_CLIENT_VERSION="v0.2026.04.01.08.00.preview_00" +should_use_structured +assert_eq "newer preview version returns true" "0" "$?" + +# Clean up +unset WARP_CLI_AGENT_PROTOCOL_VERSION +unset WARP_CLIENT_VERSION + +# --- Routing tests --- +# These test the hook scripts as subprocesses to verify routing behavior. + +HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../scripts" && pwd)" + +echo "" +echo "=== Routing ===" + +echo "" +echo "--- Hooks exit silently without protocol version ---" + +for HOOK in on-prompt-submit.sh on-post-tool-use.sh on-stop.sh on-notification.sh; do + echo '{}' | bash "$HOOK_DIR/$HOOK" 2>/dev/null + assert_eq "$HOOK exits 0 without protocol version" "0" "$?" +done + +# --- Summary --- + +echo "" +echo "=== Results: $PASSED passed, $FAILED failed ===" + +if [ "$FAILED" -gt 0 ]; then + exit 1 +fi