Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .windsurf/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"hooks": {
"pre_run_command": [
{
"command": "bin/run-hook.sh 1password-validate-mounted-env-files",
"show_output": true
}
]
}
}
17 changes: 12 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ This repository provides 1Password agent hooks that run inside supported IDEs an

Configuration is agent-specific and may use config files or editor settings. Scope depends on the agent:

- **Project-specific**: e.g. `.cursor/hooks.json` or `.github/hooks/hooks.json` in the project root (applies only to that project)
- **Project-specific**: e.g. `.cursor/hooks.json`, `.github/hooks/hooks.json`, or `.windsurf/hooks.json` in the project root (applies only to that project)

Other levels (user-specific or global) may be supported by some agents. See each agent’s documentation for details. The table below in **Supported Agents** references documentation.

Expand All @@ -20,13 +20,14 @@ Use the `--agent` value when running the install script:
|-------|-----------------|------|
| **Cursor** | `cursor` | [Cursor Hooks](https://cursor.com/docs/agent/hooks) |
| **GitHub Copilot** | `github-copilot` | [Custom agents configuration](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/use-hooks) |
| **Windsurf** | `windsurf` | [Cascade Hooks](https://docs.windsurf.com/windsurf/cascade/hooks) |


## Available Hooks

| Hook | Installation |
|------|--------------|
| [`1password-validate-mounted-env-files`](./hooks/1password-validate-mounted-env-files/README.md) — validates mounted `.env` files from 1Password Environments | <ul><li><strong>Cursor:</strong> <a href="https://cursor.com/marketplace/1password">1Password plugin</a> (e.g. <code>/add-plugin 1password</code>); or <a href="#installation">Installation</a> with <code>install.sh</code> (<code>--agent cursor</code>).</li><li><strong>GitHub Copilot:</strong> <a href="#installation">Installation</a> with <code>install.sh</code> (<code>--agent github-copilot</code>).</li></ul> |
| [`1password-validate-mounted-env-files`](./hooks/1password-validate-mounted-env-files/README.md) — validates mounted `.env` files from 1Password Environments | <ul><li><strong>Cursor:</strong> <a href="https://cursor.com/marketplace/1password">1Password plugin</a> (e.g. <code>/add-plugin 1password</code>); or <a href="#installation">Installation</a> with <code>install.sh</code> (<code>--agent cursor</code>).</li><li><strong>GitHub Copilot:</strong> <a href="#installation">Installation</a> with <code>install.sh</code> (<code>--agent github-copilot</code>).</li><li><strong>Windsurf:</strong> <a href="#installation">Installation</a> with <code>install.sh</code> (<code>--agent windsurf</code>).</li></ul> |

## Installation

Expand Down Expand Up @@ -54,9 +55,12 @@ Create a portable bundle in the current directory (no config file). Move the fol

# GitHub Copilot: creates github-copilot-1password-hooks-bundle/ in cwd
./install.sh --agent github-copilot

# Windsurf: creates windsurf-1password-hooks-bundle/ in cwd
./install.sh --agent windsurf
```

Then move that folder into the project’s directory for your agent (e.g. .cursor/ or .github/)."
Then move that folder into the project’s directory for your agent (e.g. `.cursor/`, `.github/`, or `.windsurf/`).

⚠️ When you use Bundle, the script does not create a config file(`hooks.json`). You'll need to add or update manually. See the [**Config File**](#config-file) section below.

Expand All @@ -76,6 +80,9 @@ Install the bundle into a target directory (e.g. a project repo). The script cre

# GitHub Copilot: installs into repo/.github/github-copilot-1password-hooks-bundle and repo/.github/hooks/hooks.json
./install.sh --agent github-copilot --target-dir /path/to/your/repo

# Windsurf: installs into repo/.windsurf/windsurf-1password-hooks-bundle and repo/.windsurf/hooks.json
./install.sh --agent windsurf --target-dir /path/to/your/repo
```

If the install directory already exists, the script will ask before overwriting. Type `y` to continue or `n` to cancel.
Expand All @@ -88,12 +95,12 @@ For **Bundle**, the script does not create a config file. When you use **Bundle

**What to do:**

- **Bundle** — The script didn’t create a config file. Create it at your agent’s path (e.g. `.cursor/hooks.json` or `.github/hooks/hooks.json`), then add hook entries as in the examples below.
- **Bundle** — The script didn’t create a config file. Create it at your agent’s path (e.g. `.cursor/hooks.json`, `.github/hooks/hooks.json`, or `.windsurf/hooks.json`), then add hook entries as in the examples below.
- **Bundle and Move** — The script did not create the config because it already existed at the target directory. Open it at the path the script printed and add or update hook entries as below.

**Steps (both):**

1. Open (or create) the config file at your agent’s path (e.g `.cursor/hooks.json` or `.github/hooks/hooks.json`).
1. Open (or create) the config file at your agent’s path (e.g. `.cursor/hooks.json`, `.github/hooks/hooks.json`, or `.windsurf/hooks.json`).
2. Add or update hook entries so they run `<bundle-name>/bin/run-hook.sh <hook-name>` for the events you want. The path is relative to the config file’s directory.

**Example of config file** - `.cursor/hooks.json`:
Expand Down
20 changes: 13 additions & 7 deletions adapters/_lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,11 @@ source "${_ADAPTERS_DIR}/../lib/json.sh"
#
# Signals per client (from official docs):
# Cursor: CURSOR_VERSION env var; `cursor_version` payload field
# GitHub Copilot: `hook_event_name` payload field (same key as Cursor —
# detected by process of elimination after Cursor is
# ruled out)
# Windsurf: `agent_action_name` payload field (Cascade hooks)
# GitHub Copilot: `hook_event_name` payload field (after Cursor and
# Windsurf are ruled out)
#
# Future clients (not yet implemented):
# Windsurf: `agent_action_name` payload field (unique)
# Claude Code: CLAUDE_PROJECT_DIR env var (after ruling out Cursor);
# `permission_mode` payload field (unique)
#
Expand All @@ -39,9 +38,16 @@ detect_client() {
return 0
fi

# 2. GitHub Copilot (VS Code) — shares `hook_event_name` with Cursor.
# By this point Cursor is already ruled out, so the presence of
# hook_event_name means Copilot.
# 2. Windsurf (Cascade) — every hook payload includes `agent_action_name`.
# Checked before Copilot so we do not confuse
# Cascade with other clients that may add `hook_event_name` later.
if json_has_key "$raw_payload" "agent_action_name"; then
echo "windsurf"
return 0
fi

# 3. GitHub Copilot (VS Code) — shares `hook_event_name` with Cursor.
# By this point Cursor and Windsurf are already ruled out.
if json_has_key "$raw_payload" "hook_event_name"; then
echo "github-copilot"
return 0
Expand Down
62 changes: 62 additions & 0 deletions adapters/windsurf.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Windsurf Cascade adapter (pre_run_command and other Cascade hook events).
#
# Windsurf input (pre_run_command):
# {"agent_action_name": "pre_run_command",
# "tool_info": {"command_line": "...", "cwd": "/path"}, ...}
#
# Windsurf output (pre-hooks):
# Allow: exit 0
# Deny: message on stderr, exit 2 (blocking error)

[[ -n "${_ADAPTER_WINDSURF_LOADED:-}" ]] && return 0
_ADAPTER_WINDSURF_LOADED=1

_ADAPTER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${_ADAPTER_DIR}/_lib.sh"

normalize_input() {
local raw_payload="$1"

local cwd command workspace_roots workspace_roots_json agent_action
cwd=$(extract_json_string "$raw_payload" "cwd")
command=$(extract_json_string "$raw_payload" "command_line")
agent_action=$(extract_json_string "$raw_payload" "agent_action_name")

# Cascade does not send workspace_roots - use cwd as the single root.
workspace_roots=$(parse_json_workspace_roots "$raw_payload")
if [[ -z "$workspace_roots" ]] && [[ -n "$cwd" ]]; then
workspace_roots="$cwd"
fi
workspace_roots_json=$(paths_to_json_array "$workspace_roots")

# Map shell-related events to the same canonical event as other adapters.
local canonical_event="before_shell_execution"
if [[ "$agent_action" != "pre_run_command" ]]; then
canonical_event=$(printf '%s' "$agent_action" | tr '-' '_')
fi

build_canonical_input \
"windsurf" \
"$canonical_event" \
"command" \
"$workspace_roots_json" \
"$cwd" \
"$command" \
"" \
"$raw_payload"
}

emit_output() {
local canonical_output="$1"

local decision message
decision=$(get_decision "$canonical_output")
message=$(get_message "$canonical_output")

if [[ "$decision" == "deny" ]]; then
echo "$message" >&2
return 2
fi

return 0
}
10 changes: 10 additions & 0 deletions install-client-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,15 @@
"install_dir": ".github/github-copilot-1password-hooks-bundle",
"config_path": ".github/hooks/hooks.json"
}
},
"windsurf": {
"adapters": ["_lib.sh", "windsurf.sh", "generic.sh"],
"hook_events": {
"pre_run_command": ["1password-validate-mounted-env-files"]
},
"project": {
"install_dir": ".windsurf/windsurf-1password-hooks-bundle",
"config_path": ".windsurf/hooks.json"
}
}
}
12 changes: 6 additions & 6 deletions install.sh
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
#!/usr/bin/env bash
#
# Install agent hooks for Cursor or GitHub Copilot.
# Install agent hooks for Cursor, GitHub Copilot, or Windsurf.
# Always creates a bundle (hook files). With --target-dir, installs that bundle into DIR and creates hooks.json from template if missing (never overwrites existing hooks.json).
# Run from this repo.
#
# Usage: ./install.sh --agent cursor|github-copilot [--target-dir DIR]
# Usage: ./install.sh --agent cursor|github-copilot|windsurf [--target-dir DIR]
#
set -euo pipefail

Expand All @@ -17,9 +17,9 @@ fi
CONFIG_PATH="${REPO_ROOT}/${CONFIG_NAME}"

usage() {
echo "Usage: $0 --agent cursor|github-copilot [--target-dir DIR]"
echo "Usage: $0 --agent cursor|github-copilot|windsurf [--target-dir DIR]"
echo ""
echo " --agent (required) Agent (cursor or github-copilot)."
echo " --agent (required) Agent (cursor, github-copilot, or windsurf)."
echo " --target-dir If set: install bundle into DIR (creates hooks.json from template if missing). If unset: create bundle in current directory only (no hooks.json)."
echo ""
exit 1
Expand Down Expand Up @@ -125,7 +125,7 @@ TARGET_DIR=""
require_value() {
local opt="$1"
if [[ $# -lt 2 || -z "$2" || "$2" == -* ]]; then
echo "Error: $opt requires a value (e.g. for --agent: cursor, github-copilot)" >&2
echo "Error: $opt requires a value (e.g. for --agent: cursor, github-copilot, windsurf)" >&2
exit 1
fi
}
Expand All @@ -149,7 +149,7 @@ done

# Ensure an agent is specified
if [[ -z "$AGENT" ]]; then
echo "Error: --agent is required (cursor or github-copilot)" >&2
echo "Error: --agent is required (cursor, github-copilot, or windsurf)" >&2
usage
fi

Expand Down
17 changes: 17 additions & 0 deletions tests/adapters/detect_client.bats
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,23 @@ setup() {
[[ "$output" == "github-copilot" ]]
}

# ========== Windsurf (Cascade) ==========

@test "detect_client returns windsurf when payload has agent_action_name" {
run detect_client '{"agent_action_name":"pre_run_command","tool_info":{"command_line":"ls","cwd":"/tmp"}}'
[[ "$output" == "windsurf" ]]
}

@test "detect_client prefers cursor over agent_action_name when cursor_version present" {
run detect_client '{"cursor_version":"1.0.0","agent_action_name":"pre_run_command","tool_info":{"command_line":"ls","cwd":"/tmp"}}'
[[ "$output" == "cursor" ]]
}

@test "detect_client prefers windsurf over github-copilot when both hook_event_name and agent_action_name present" {
run detect_client '{"agent_action_name":"pre_run_command","hook_event_name":"PreToolUse","tool_info":{"command_line":"ls","cwd":"/tmp"}}'
[[ "$output" == "windsurf" ]]
}

# ========== Unknown / fallback ==========

@test "detect_client returns unknown for empty object" {
Expand Down
68 changes: 68 additions & 0 deletions tests/adapters/windsurf.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#!/usr/bin/env bats

load "../test_helper"

setup() {
unset _ADAPTER_WINDSURF_LOADED _ADAPTERS_LIB_LOADED _LIB_JSON_LOADED _LIB_LOGGING_LOADED
source "${PROJECT_ROOT}/adapters/windsurf.sh"
}

WINDSURF_PRE_RUN='{"agent_action_name":"pre_run_command","tool_info":{"command_line":"npm run build","cwd":"/Users/alice/project"}}'

# ========== normalize_input ==========

@test "normalize_input produces canonical JSON with client windsurf" {
local result
result=$(normalize_input "$WINDSURF_PRE_RUN")
local client
client=$(extract_json_string "$result" "client")
[[ "$client" == "windsurf" ]]
}

@test "normalize_input maps pre_run_command to before_shell_execution" {
local result
result=$(normalize_input "$WINDSURF_PRE_RUN")
local event
event=$(extract_json_string "$result" "event")
[[ "$event" == "before_shell_execution" ]]
}

@test "normalize_input extracts command from command_line" {
local result
result=$(normalize_input "$WINDSURF_PRE_RUN")
local cmd
cmd=$(extract_json_string "$result" "command")
[[ "$cmd" == "npm run build" ]]
}

@test "normalize_input extracts cwd from tool_info" {
local result
result=$(normalize_input "$WINDSURF_PRE_RUN")
local cwd
cwd=$(extract_json_string "$result" "cwd")
[[ "$cwd" == "/Users/alice/project" ]]
}

@test "normalize_input uses cwd as workspace root when workspace_roots absent" {
local result
result=$(normalize_input "$WINDSURF_PRE_RUN")
local roots
roots=$(parse_json_workspace_roots "$result")
[[ "$roots" == "/Users/alice/project" ]]
}

# ========== emit_output ==========

@test "emit_output exits 0 on allow" {
local canonical='{"decision":"allow","message":""}'
run emit_output "$canonical"
[[ "$status" -eq 0 ]]
[[ -z "$output" ]]
}

@test "emit_output exits 2 and prints message to stderr on deny" {
local canonical='{"decision":"deny","message":"env file missing"}'
run emit_output "$canonical"
[[ "$status" -eq 2 ]]
[[ "$output" == "env file missing" ]]
}
39 changes: 39 additions & 0 deletions tests/install/install.bats
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,38 @@ EOF
[[ "$(cat "${T}/.github/hooks/hooks.json")" == '{"version":1,"hooks":{"PreToolUse":[]}}' ]]
}

# ---- Windsurf: install paths ----

@test "windsurf: --target-dir creates .windsurf/windsurf-1password-hooks-bundle and expected files" {
run bash "${INSTALL_SCRIPT}" --agent windsurf --target-dir "${T}"
[[ $status -eq 0 ]]
[[ -f "${T}/.windsurf/windsurf-1password-hooks-bundle/bin/run-hook.sh" ]]
[[ -d "${T}/.windsurf/windsurf-1password-hooks-bundle/lib" ]]
[[ -f "${T}/.windsurf/windsurf-1password-hooks-bundle/adapters/_lib.sh" ]]
[[ -f "${T}/.windsurf/windsurf-1password-hooks-bundle/adapters/windsurf.sh" ]]
[[ -f "${T}/.windsurf/windsurf-1password-hooks-bundle/adapters/generic.sh" ]]
[[ ! -f "${T}/.windsurf/windsurf-1password-hooks-bundle/adapters/cursor.sh" ]]
[[ -f "${T}/.windsurf/windsurf-1password-hooks-bundle/hooks/1password-validate-mounted-env-files/hook.sh" ]]
[[ -f "${T}/.windsurf/hooks.json" ]]
[[ "$output" == *"Done. Hook(s) installed"* ]]
}

@test "windsurf: hooks.json command path is rewritten to bundle-relative path" {
run bash "${INSTALL_SCRIPT}" --agent windsurf --target-dir "${T}"
[[ $status -eq 0 ]]
run grep -Fq '.windsurf/windsurf-1password-hooks-bundle/bin/run-hook.sh' "${T}/.windsurf/hooks.json"
[[ $status -eq 0 ]]
}

@test "windsurf: does not overwrite existing hooks.json" {
mkdir -p "${T}/.windsurf"
echo '{"hooks":{"pre_run_command":[]}}' > "${T}/.windsurf/hooks.json"
run bash "${INSTALL_SCRIPT}" --agent windsurf --target-dir "${T}"
[[ $status -eq 0 ]]
[[ "$output" == *"Config already exists at"* ]]
[[ "$(cat "${T}/.windsurf/hooks.json")" == '{"hooks":{"pre_run_command":[]}}' ]]
}

# ---- Smoke: installed run-hook.sh is runnable ----

@test "cursor: installed run-hook.sh runs (smoke)" {
Expand All @@ -159,3 +191,10 @@ EOF
run bash -c "echo '{}' | ${T}/.github/github-copilot-1password-hooks-bundle/bin/run-hook.sh 1password-validate-mounted-env-files"
[[ $status -eq 0 ]]
}

@test "windsurf: installed run-hook.sh runs (smoke)" {
run bash "${INSTALL_SCRIPT}" --agent windsurf --target-dir "${T}"
[[ $status -eq 0 ]]
run bash -c "echo '{}' | ${T}/.windsurf/windsurf-1password-hooks-bundle/bin/run-hook.sh 1password-validate-mounted-env-files"
[[ $status -eq 0 ]]
}