diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 8df5d11..a265fe6 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "name": "warp", "description": "Native Warp notifications when Claude completes tasks or needs input", "source": "./plugins/warp", - "version": "1.1.0", + "version": "2.0.0", "category": "productivity", "tags": ["notifications", "terminal", "warp"] } 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 index d96bf27..6bc7f70 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,18 @@ Official [Warp](https://warp.dev) terminal integration for [Claude Code](https:/ Get native Warp notifications when Claude Code: - **Completes a task** — with a summary showing your prompt and Claude's response -- **Needs your input** — when Claude requires approval or has a question +- **Needs your input** — when Claude has been idle and is waiting for you +- **Requests permission** — when Claude 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 Claude works and get alerted when attention is needed. -**Example notification:** -``` -"what's 1+1" → 2 -``` +### 📡 Session Status + +The plugin keeps Warp informed of Claude's current state by emitting structured events on every session transition: +- **Prompt submitted** — you sent a prompt, Claude is working +- **Tool completed** — a tool call finished, Claude is back to running + +This powers Warp's inline status indicators for Claude Code sessions. ## Installation @@ -27,9 +31,9 @@ Notifications appear in Warp's notification center and as system notifications, /plugin install warp@claude-code-warp ``` -> ⚠️ **Important**: After installing, **restart Claude Code** for notifications to activate. +> ⚠️ **Important**: After installing, **restart Claude Code or run /reload-plugins** for the plugin to activate. -Once restarted, you'll see a confirmation message and notifications will appear automatically when Claude completes tasks. +Once restarted, you'll see a confirmation message and notifications will appear automatically. ## Requirements @@ -39,27 +43,26 @@ Once restarted, you'll see a confirmation message and notifications will appear ## How It Works -This plugin uses Warp's [pluggable notifications](https://docs.warp.dev/features/notifications) feature via OSC escape sequences. When Claude Code triggers a hook event, the plugin: +The plugin 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. -1. Reads the session transcript to extract your original prompt and Claude's response -2. Formats a concise notification message -3. Sends an OSC 777 escape sequence to Warp, which displays a native notification +Payloads include a protocol version negotiated between the plugin and Warp (`min(plugin_version, warp_version)`), the session ID, working directory, and event-specific fields. -The plugin registers three hooks: -- **SessionStart** — shows a welcome message confirming the plugin is active -- **Stop** — fires when Claude finishes responding -- **Notification** — fires when Claude needs user input +The plugin registers six hooks: +- **SessionStart** — emits the plugin version and a welcome system message +- **Stop** — reads the transcript to extract your prompt and Claude's response, then sends a task-complete notification +- **Notification** (`idle_prompt`) — fires when Claude has been idle and needs your input +- **PermissionRequest** — fires when Claude wants to run a tool, includes the tool name and a preview of its input +- **UserPromptSubmit** — fires when you submit a prompt, signaling the session is active again +- **PostToolUse** — fires when a tool call completes, signaling the session is no longer blocked -## Configuration +### Legacy Support + +Older Warp clients that predate the structured notification protocol are still supported — they receive plain-text notifications for SessionStart, Stop, and Notification hooks. -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). -## Roadmap +## Configuration -Future Warp integrations planned: -- Warp AI context sharing -- Warp Drive integration for sharing Claude Code configurations -- Custom slash commands +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 @@ -68,9 +71,10 @@ Future Warp integrations planned: /plugin marketplace remove claude-code-warp ``` -## Contributing +## Versioning -Contributions welcome! Please open an issue or PR on [GitHub](https://github.com/warpdotdev/claude-code-warp). +The plugin version in `plugins/warp/.claude-plugin/plugin.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 diff --git a/plugins/warp/.claude-plugin/plugin.json b/plugins/warp/.claude-plugin/plugin.json index 97b8831..d7799cd 100644 --- a/plugins/warp/.claude-plugin/plugin.json +++ b/plugins/warp/.claude-plugin/plugin.json @@ -1,10 +1,11 @@ { "name": "warp", "description": "Warp terminal integration for Claude Code - native notifications, and more to come", - "version": "1.1.0", + "version": "2.0.0", "author": { "name": "Warp", "url": "https://warp.dev" }, - "homepage": "https://github.com/warpdotdev/claude-code-warp" + "homepage": "https://github.com/warpdotdev/claude-code-warp", + "strict": false } diff --git a/plugins/warp/hooks/hooks.json b/plugins/warp/hooks/hooks.json index 6e44d07..f2597ee 100644 --- a/plugins/warp/hooks/hooks.json +++ b/plugins/warp/hooks/hooks.json @@ -24,7 +24,7 @@ ], "Notification": [ { - "matcher": "*", + "matcher": "idle_prompt", "hooks": [ { "type": "command", @@ -32,6 +32,36 @@ } ] } + ], + "PermissionRequest": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/scripts/on-permission-request.sh" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/scripts/on-prompt-submit.sh" + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/scripts/on-post-tool-use.sh" + } + ] + } ] } } diff --git a/plugins/warp/scripts/build-payload.sh b/plugins/warp/scripts/build-payload.sh new file mode 100644 index 0000000..9ad610e --- /dev/null +++ b/plugins/warp/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 "claude" \ + --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/plugins/warp/scripts/legacy/on-notification.sh b/plugins/warp/scripts/legacy/on-notification.sh new file mode 100755 index 0000000..378923e --- /dev/null +++ b/plugins/warp/scripts/legacy/on-notification.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# Hook script for Claude Code Notification event +# Sends a Warp notification when Claude needs user input + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Read hook input from stdin +INPUT=$(cat) + +# Extract the notification message +MSG=$(echo "$INPUT" | jq -r '.message // "Input needed"' 2>/dev/null) +[ -z "$MSG" ] && MSG="Input needed" + +"$SCRIPT_DIR/warp-notify.sh" "Claude Code" "$MSG" diff --git a/plugins/warp/scripts/legacy/on-session-start.sh b/plugins/warp/scripts/legacy/on-session-start.sh new file mode 100755 index 0000000..4d5e7fb --- /dev/null +++ b/plugins/warp/scripts/legacy/on-session-start.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Hook script for Claude Code SessionStart event +# Shows welcome message and Warp detection status + +# Check if running in Warp terminal +if [ "$TERM_PROGRAM" = "WarpTerminal" ]; then + # Running in Warp - notifications will work + cat << 'EOF' +{ + "systemMessage": "🔔 Warp plugin active. You'll receive native Warp notifications when tasks complete or input is needed." +} +EOF +else + # Not running in Warp - suggest installing + cat << 'EOF' +{ + "systemMessage": "ℹ️ Warp plugin installed but you're not running in Warp terminal. Install Warp (https://warp.dev) to get native notifications when Claude completes tasks or needs input." +} +EOF +fi diff --git a/plugins/warp/scripts/legacy/on-stop.sh b/plugins/warp/scripts/legacy/on-stop.sh new file mode 100755 index 0000000..2a45dd9 --- /dev/null +++ b/plugins/warp/scripts/legacy/on-stop.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# Hook script for Claude Code Stop event +# Sends a Warp notification when Claude completes a task + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Read hook input from stdin +INPUT=$(cat) + +# Extract transcript path from the hook input +TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null) + +# Default message +MSG="Task completed" + +# Try to extract prompt and response from the transcript (JSONL format) +if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then + # Get the first user prompt + PROMPT=$(jq -rs ' + [.[] | select(.type == "user")] | first | .message.content // empty + ' "$TRANSCRIPT_PATH" 2>/dev/null) + + # Get the last assistant response + RESPONSE=$(jq -rs ' + [.[] | select(.type == "assistant" and .message.content)] | last | + [.message.content[] | select(.type == "text") | .text] | join(" ") + ' "$TRANSCRIPT_PATH" 2>/dev/null) + + if [ -n "$PROMPT" ] && [ -n "$RESPONSE" ]; then + # Truncate prompt to 50 chars + if [ ${#PROMPT} -gt 50 ]; then + PROMPT="${PROMPT:0:47}..." + fi + # Truncate response to 120 chars + if [ ${#RESPONSE} -gt 120 ]; then + RESPONSE="${RESPONSE:0:117}..." + fi + MSG="\"${PROMPT}\" → ${RESPONSE}" + elif [ -n "$RESPONSE" ]; then + # Fallback to just response if no prompt found + if [ ${#RESPONSE} -gt 175 ]; then + RESPONSE="${RESPONSE:0:172}..." + fi + MSG="$RESPONSE" + fi +fi + +"$SCRIPT_DIR/warp-notify.sh" "Claude Code" "$MSG" diff --git a/plugins/warp/scripts/legacy/warp-notify.sh b/plugins/warp/scripts/legacy/warp-notify.sh new file mode 100755 index 0000000..6ca0588 --- /dev/null +++ b/plugins/warp/scripts/legacy/warp-notify.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# Warp notification utility using OSC escape sequences +# Usage: warp-notify.sh <body> + +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/plugins/warp/scripts/on-notification.sh b/plugins/warp/scripts/on-notification.sh index 378923e..8518ac1 100755 --- a/plugins/warp/scripts/on-notification.sh +++ b/plugins/warp/scripts/on-notification.sh @@ -1,14 +1,27 @@ #!/bin/bash -# Hook script for Claude Code Notification event -# Sends a Warp notification when Claude needs user input +# Hook script for Claude Code Notification event (idle_prompt only) +# Sends a structured Warp notification when Claude has been idle SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/should-use-structured.sh" + +# Legacy fallback for old Warp versions +if ! should_use_structured; then + [ "$TERM_PROGRAM" = "WarpTerminal" ] && exec "$SCRIPT_DIR/legacy/on-notification.sh" + exit 0 +fi + +source "$SCRIPT_DIR/build-payload.sh" # Read hook input from stdin INPUT=$(cat) -# Extract the notification message +# Extract notification-specific fields +NOTIF_TYPE=$(echo "$INPUT" | jq -r '.notification_type // "unknown"' 2>/dev/null) MSG=$(echo "$INPUT" | jq -r '.message // "Input needed"' 2>/dev/null) [ -z "$MSG" ] && MSG="Input needed" -"$SCRIPT_DIR/warp-notify.sh" "Claude Code" "$MSG" +BODY=$(build_payload "$INPUT" "$NOTIF_TYPE" \ + --arg summary "$MSG") + +"$SCRIPT_DIR/warp-notify.sh" "warp://cli-agent" "$BODY" diff --git a/plugins/warp/scripts/on-permission-request.sh b/plugins/warp/scripts/on-permission-request.sh new file mode 100755 index 0000000..7d46ed2 --- /dev/null +++ b/plugins/warp/scripts/on-permission-request.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Hook script for Claude Code PermissionRequest event +# Sends a structured Warp notification when Claude needs permission to run a tool + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/should-use-structured.sh" + +# No legacy equivalent for this hook +if ! should_use_structured; then + exit 0 +fi + +source "$SCRIPT_DIR/build-payload.sh" + +# Read hook input from stdin +INPUT=$(cat) + +# Extract permission-request-specific fields +TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // "unknown"' 2>/dev/null) +TOOL_INPUT=$(echo "$INPUT" | jq -c '.tool_input // {}' 2>/dev/null) +# Fallback to empty object if jq failed or returned empty +[ -z "$TOOL_INPUT" ] && TOOL_INPUT='{}' + +# Build a human-readable summary +TOOL_PREVIEW=$(echo "$INPUT" | jq -r '(.tool_input | if .command then .command elif .file_path then .file_path else (tostring | .[0:80]) end) // ""' 2>/dev/null) +SUMMARY="Wants to run $TOOL_NAME" +if [ -n "$TOOL_PREVIEW" ]; 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" diff --git a/plugins/warp/scripts/on-post-tool-use.sh b/plugins/warp/scripts/on-post-tool-use.sh new file mode 100755 index 0000000..568e5b3 --- /dev/null +++ b/plugins/warp/scripts/on-post-tool-use.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Hook script for Claude Code PostToolUse event +# 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" + +# No legacy equivalent for this hook +if ! should_use_structured; then + 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" diff --git a/plugins/warp/scripts/on-prompt-submit.sh b/plugins/warp/scripts/on-prompt-submit.sh new file mode 100755 index 0000000..0a8a55e --- /dev/null +++ b/plugins/warp/scripts/on-prompt-submit.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# Hook script for Claude Code UserPromptSubmit event +# 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" + +# No legacy equivalent for this hook +if ! should_use_structured; then + 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" diff --git a/plugins/warp/scripts/on-session-start.sh b/plugins/warp/scripts/on-session-start.sh index 4d5e7fb..b907979 100755 --- a/plugins/warp/scripts/on-session-start.sh +++ b/plugins/warp/scripts/on-session-start.sh @@ -1,20 +1,32 @@ #!/bin/bash # Hook script for Claude Code SessionStart event -# Shows welcome message and Warp detection status +# Shows welcome message, Warp detection status, and emits plugin version -# Check if running in Warp terminal -if [ "$TERM_PROGRAM" = "WarpTerminal" ]; then - # Running in Warp - notifications will work - cat << 'EOF' -{ - "systemMessage": "🔔 Warp plugin active. You'll receive native Warp notifications when tasks complete or input is needed." -} -EOF -else - # Not running in Warp - suggest installing +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/should-use-structured.sh" + +# Legacy fallback for old Warp versions +if ! should_use_structured; then + exec "$SCRIPT_DIR/legacy/on-session-start.sh" +fi + +if ! command -v jq &>/dev/null; then cat << 'EOF' { - "systemMessage": "ℹ️ Warp plugin installed but you're not running in Warp terminal. Install Warp (https://warp.dev) to get native notifications when Claude completes tasks or needs input." + "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 plugin.json +PLUGIN_VERSION=$(jq -r '.version // "unknown"' "$SCRIPT_DIR/../.claude-plugin/plugin.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/plugins/warp/scripts/on-stop.sh b/plugins/warp/scripts/on-stop.sh index 2a45dd9..4163bb9 100755 --- a/plugins/warp/scripts/on-stop.sh +++ b/plugins/warp/scripts/on-stop.sh @@ -1,48 +1,73 @@ #!/bin/bash # Hook script for Claude Code Stop event -# Sends a Warp notification when Claude completes a task +# Sends a structured Warp notification when Claude completes a task SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/should-use-structured.sh" + +# Legacy fallback for old Warp versions +if ! should_use_structured; then + [ "$TERM_PROGRAM" = "WarpTerminal" ] && exec "$SCRIPT_DIR/legacy/on-stop.sh" + exit 0 +fi + +source "$SCRIPT_DIR/build-payload.sh" # Read hook input from stdin INPUT=$(cat) -# Extract transcript path from the hook input -TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null) - -# Default message -MSG="Task completed" +# Skip if a stop hook is already active (prevents double-notification) +STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false' 2>/dev/null) +if [ "$STOP_HOOK_ACTIVE" = "true" ]; then + exit 0 +fi -# Try to extract prompt and response from the transcript (JSONL format) +# Extract the last user prompt and assistant response from the transcript. +# Small delay to allow Claude Code to flush the current turn to the transcript file. +# The Stop hook fires before the transcript is fully written. +TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null) +sleep 0.3 +QUERY="" +RESPONSE="" if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then - # Get the first user prompt - PROMPT=$(jq -rs ' - [.[] | select(.type == "user")] | first | .message.content // empty + # Get the last human prompt from the transcript. + # "user" type messages include both human prompts and tool-result messages. + # Human prompts have content that is either a plain string or an array + # containing {type:"text"} blocks. Tool-result messages have content arrays + # containing only {type:"tool_result"} blocks. We filter to messages that + # have at least one "text" block (or are a plain string). + QUERY=$(jq -rs ' + [ + .[] | select(.type == "user") | + if .message.content | type == "string" then . + elif [.message.content[] | select(.type == "text")] | length > 0 then . + else empty + end + ] | last | + if .message.content | type == "array" + then [.message.content[] | select(.type == "text") | .text] | join(" ") + else .message.content // empty + end ' "$TRANSCRIPT_PATH" 2>/dev/null) - + # Get the last assistant response RESPONSE=$(jq -rs ' [.[] | select(.type == "assistant" and .message.content)] | last | [.message.content[] | select(.type == "text") | .text] | join(" ") ' "$TRANSCRIPT_PATH" 2>/dev/null) - - if [ -n "$PROMPT" ] && [ -n "$RESPONSE" ]; then - # Truncate prompt to 50 chars - if [ ${#PROMPT} -gt 50 ]; then - PROMPT="${PROMPT:0:47}..." - fi - # Truncate response to 120 chars - if [ ${#RESPONSE} -gt 120 ]; then - RESPONSE="${RESPONSE:0:117}..." - fi - MSG="\"${PROMPT}\" → ${RESPONSE}" - elif [ -n "$RESPONSE" ]; then - # Fallback to just response if no prompt found - if [ ${#RESPONSE} -gt 175 ]; then - RESPONSE="${RESPONSE:0:172}..." - fi - MSG="$RESPONSE" + + # 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 fi -"$SCRIPT_DIR/warp-notify.sh" "Claude Code" "$MSG" +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" diff --git a/plugins/warp/scripts/should-use-structured.sh b/plugins/warp/scripts/should-use-structured.sh new file mode 100644 index 0000000..13360e0 --- /dev/null +++ b/plugins/warp/scripts/should-use-structured.sh @@ -0,0 +1,44 @@ +#!/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. + # (This catches the broken prod release before this was set, but after WARP_CLI_AGENT_PROTOCOL_VERSION was set without a flag check.) + [ -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/plugins/warp/scripts/warp-notify.sh b/plugins/warp/scripts/warp-notify.sh index 6ca0588..523f873 100755 --- a/plugins/warp/scripts/warp-notify.sh +++ b/plugins/warp/scripts/warp-notify.sh @@ -1,6 +1,17 @@ #!/bin/bash # Warp notification utility using OSC escape sequences # Usage: warp-notify.sh <title> <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:-}" diff --git a/plugins/warp/tests/test-hooks.sh b/plugins/warp/tests/test-hooks.sh new file mode 100755 index 0000000..220d3ae --- /dev/null +++ b/plugins/warp/tests/test-hooks.sh @@ -0,0 +1,240 @@ +#!/bin/bash +# Tests for the Warp Claude Code plugin hook scripts. +# +# Validates that each hook script produces correctly structured JSON payloads +# by piping mock Claude Code 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 claude" "$PAYLOAD" ".agent" "claude" +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 "Memory is safe, the borrow checker stands guard" \ + --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" "Memory is safe, the borrow checker stands guard" +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 "Claude is waiting for your input") +assert_json_field "event is idle_prompt" "$PAYLOAD" ".event" "idle_prompt" +assert_json_field "summary present" "$PAYLOAD" ".summary" "Claude 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 +# (not testable with PLUGIN_MAX=1 since there's no v0, but we verify the min logic +# by temporarily overriding the variable) +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/../scripts/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. +# We override /dev/tty writes since they'd fail in CI. + +HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../scripts" && pwd)" + +echo "" +echo "=== Routing ===" + +echo "" +echo "--- SessionStart routing ---" + +# Legacy Warp (TERM_PROGRAM=WarpTerminal, no protocol version) +OUTPUT=$(TERM_PROGRAM=WarpTerminal bash "$HOOK_DIR/on-session-start.sh" < /dev/null 2>/dev/null) +SYS_MSG=$(echo "$OUTPUT" | jq -r '.systemMessage // empty' 2>/dev/null) +assert_eq "legacy Warp shows active message" \ + "🔔 Warp plugin active. You'll receive native Warp notifications when tasks complete or input is needed." \ + "$SYS_MSG" + +# Not Warp (neither env var set) +OUTPUT=$(TERM_PROGRAM=other bash "$HOOK_DIR/on-session-start.sh" < /dev/null 2>/dev/null) +SYS_MSG=$(echo "$OUTPUT" | jq -r '.systemMessage // empty' 2>/dev/null) +assert_eq "non-Warp shows install message" \ + "ℹ️ Warp plugin installed but you're not running in Warp terminal. Install Warp (https://warp.dev) to get native notifications when Claude completes tasks or needs input." \ + "$SYS_MSG" + +echo "" +echo "--- Modern-only hooks exit silently without protocol version ---" + +for HOOK in on-permission-request.sh on-prompt-submit.sh on-post-tool-use.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