Skip to content

Commit 9ec3a33

Browse files
authored
feat: add GitHub Copilot CLI backend (#42)
1 parent 54a4dd5 commit 9ec3a33

20 files changed

Lines changed: 1213 additions & 17 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,8 @@
77
# OpenCode local plugins (installed by :CodePreviewInstallOpenCodeHooks)
88
.opencode/
99

10+
# Copilot CLI local hooks (installed by :CodePreviewInstallCopilotCliHooks)
11+
.github/hooks/code-preview.json
12+
1013
# Test dependencies (plenary.nvim, installed by tests/run_lua.sh)
1114
deps/

README.md

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,20 @@
22

33
A Neovim plugin that shows a **diff preview before your AI coding agent applies any file change** — letting you review exactly what's changing before accepting.
44

5-
Supports [Claude Code](https://docs.anthropic.com/en/docs/claude-code) and [OpenCode](https://opencode.ai) as backends.
5+
Supports [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), and [GitHub Copilot CLI](https://github.com/github/copilot-cli) as backends.
66

77
---
88

99
## Demo
1010

1111
### Claude Code
12-
![Claude Code demo](docs/claude-preview-demo.gif)
12+
![Claude Code demo](docs/code-preview-claudecode.gif)
1313

1414
### OpenCode
15-
![OpenCode demo](docs/claude-preview-opencode.gif)
15+
![OpenCode demo](docs/code-preview-opencode.gif)
16+
17+
### GitHub Copilot CLI
18+
![GitHub Copilot CLI demo](docs/code-preview-copilot.gif)
1619

1720
---
1821

@@ -24,6 +27,7 @@ Supports [Claude Code](https://docs.anthropic.com/en/docs/claude-code) and [Open
2427
- [Quick Start](#quick-start)
2528
- [Claude Code](#claude-code)
2629
- [OpenCode](#opencode)
30+
- [GitHub Copilot CLI](#github-copilot-cli)
2731
- [How it works](#how-it-works)
2832
- [Configuration](#configuration)
2933
- [Commands](#commands)
@@ -55,6 +59,10 @@ Supports [Claude Code](https://docs.anthropic.com/en/docs/claude-code) and [Open
5559
**For OpenCode backend:**
5660
- [OpenCode](https://opencode.ai) >= 1.3.0
5761

62+
**For GitHub Copilot CLI backend:**
63+
- [GitHub Copilot CLI](https://github.com/github/copilot-cli) (generally available since Feb 2026)
64+
- [jq](https://jqlang.github.io/jq/) — for hook payload translation
65+
5866
---
5967

6068
## Installation
@@ -110,6 +118,18 @@ require("code-preview").setup()
110118
7. Accept/reject in OpenCode; the diff closes automatically on accept
111119
8. If rejected, press `<leader>dq` to close the diff manually
112120

121+
### GitHub Copilot CLI
122+
123+
1. Install the plugin and call `setup()`
124+
2. Open a project in Neovim
125+
3. Run `:CodePreviewInstallCopilotCliHooks` — writes `.github/hooks/code-preview.json`
126+
4. Start Copilot CLI in the project directory
127+
5. Ask Copilot to edit a file — a diff opens automatically in Neovim
128+
6. Accept/reject in the CLI; the diff closes automatically on accept
129+
7. If rejected, press `<leader>dq` to close the diff manually
130+
131+
> **Note:** Copilot CLI does not fire post-tool hooks on rejection, so rejected diffs remain open until you dismiss them (same as Claude Code).
132+
113133
---
114134

115135
## How it works
@@ -132,7 +152,9 @@ AI Agent (terminal) Neovim
132152

133153
**OpenCode** uses a TypeScript plugin (`tool.execute.before`/`tool.execute.after`) loaded from `.opencode/plugins/`.
134154

135-
Both backends communicate with Neovim via RPC (`nvim --server <socket> --remote-send`).
155+
**GitHub Copilot CLI** uses shell-based hooks (`preToolUse`/`postToolUse`) configured in `.github/hooks/code-preview.json`. The adapter translates Copilot's tool vocabulary (`apply_patch`, `edit`, `create`, `bash`) into the same normalized format used by the other backends.
156+
157+
All backends communicate with Neovim via RPC (`nvim --server <socket> --remote-send`).
136158

137159
---
138160

@@ -184,6 +206,8 @@ require("code-preview").setup({
184206
| `:CodePreviewUninstallClaudeCodeHooks` | Remove Claude Code hooks (leaves other hooks intact) |
185207
| `:CodePreviewInstallOpenCodeHooks` | Install OpenCode plugin to `.opencode/plugins/` |
186208
| `:CodePreviewUninstallOpenCodeHooks` | Remove OpenCode plugin |
209+
| `:CodePreviewInstallCopilotCliHooks` | Install Copilot CLI hooks to `.github/hooks/code-preview.json` |
210+
| `:CodePreviewUninstallCopilotCliHooks` | Remove Copilot CLI hooks |
187211
| `:CodePreviewCloseDiff` | Manually close the diff (use after rejecting a change) |
188212
| `:CodePreviewStatus` | Show socket path, hook status, and dependency check |
189213
| `:CodePreviewToggleVisibleOnly` | Toggle visible_only — show diffs only for open buffers |
@@ -230,7 +254,7 @@ require("code-preview").setup({
230254

231255
If you use [neo-tree.nvim](https://github.com/nvim-neo-tree/neo-tree.nvim), code-preview will automatically decorate your file tree with visual indicators when changes are proposed. No extra configuration is required — it works out of the box.
232256

233-
![neo-tree integration demo](docs/claude-preview-neotree-integration.gif)
257+
![neo-tree integration demo](docs/code-preview-neotree-integration.gif)
234258

235259
### What you get
236260

@@ -301,10 +325,13 @@ code-preview.nvim/
301325
│ ├── claudecode/ Claude Code adapter
302326
│ │ ├── code-preview-diff.sh PreToolUse hook entry point
303327
│ │ └── code-close-diff.sh PostToolUse hook entry point
304-
│ └── opencode/ OpenCode adapter
305-
│ ├── index.ts tool.execute.before/after hooks
306-
│ ├── package.json
307-
│ └── tsconfig.json
328+
│ ├── opencode/ OpenCode adapter
329+
│ │ ├── index.ts tool.execute.before/after hooks
330+
│ │ ├── package.json
331+
│ │ └── tsconfig.json
332+
│ └── copilot/ GitHub Copilot CLI adapter
333+
│ ├── code-preview-diff.sh preToolUse hook — translates Copilot JSON → core
334+
│ └── code-close-diff.sh postToolUse hook — same for close
308335
```
309336

310337
---
@@ -357,6 +384,12 @@ vim.api.nvim_create_autocmd({ "FocusGained", "BufEnter", "CursorHold" }, {
357384
- Ensure `"permission": { "edit": "ask" }` is set in `~/.config/opencode/opencode.json`
358385
- Restart OpenCode
359386

387+
**Copilot CLI hooks not firing**
388+
- Run `:CodePreviewInstallCopilotCliHooks` in the project root
389+
- Verify `.github/hooks/code-preview.json` exists
390+
- Ensure `jq` is in PATH
391+
- Restart Copilot CLI (hooks are loaded at session start)
392+
360393
**Diff doesn't close after rejecting**
361394
- Press `<leader>dq` or run `:CodePreviewCloseDiff` — the post hook only fires on accept
362395

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
#!/usr/bin/env bash
2+
# code-close-diff.sh — PostToolUse hook adapter for GitHub Copilot CLI.
3+
#
4+
# Mirrors the translation in code-preview-diff.sh and delegates to
5+
# bin/core-post-tool.sh. Only the fields core-post-tool.sh reads are
6+
# populated (tool_name, cwd, file_path or patch_text).
7+
8+
set -uo pipefail
9+
10+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
11+
BIN_DIR="$SCRIPT_DIR/../../bin"
12+
export CODE_PREVIEW_BACKEND="copilot"
13+
14+
INPUT="$(cat)"
15+
16+
TOOL="$(printf '%s' "$INPUT" | jq -r '.toolName // ""')"
17+
CWD="$(printf '%s' "$INPUT" | jq -r '.cwd // ""')"
18+
19+
case "$TOOL" in
20+
""|view|glob|grep|ls|report_intent) exit 0 ;;
21+
esac
22+
23+
# Logging — gated on `debug = true` in setup().
24+
log() { :; }
25+
# shellcheck source=/dev/null
26+
source "$BIN_DIR/nvim-socket.sh" "$CWD" 2>/dev/null || true
27+
# shellcheck source=/dev/null
28+
source "$BIN_DIR/nvim-send.sh" 2>/dev/null || true
29+
if [[ -n "${NVIM_SOCKET:-}" ]]; then
30+
_CTX=$(nvim --server "$NVIM_SOCKET" --remote-expr "luaeval(\"vim.json.encode({debug=require('code-preview.log').is_enabled(),log_file=require('code-preview.log').get_log_path() or ''})\")" 2>/dev/null || echo '{}')
31+
_DBG=$(echo "$_CTX" | jq -r '.debug // false' 2>/dev/null)
32+
_LOG=$(echo "$_CTX" | jq -r '.log_file // ""' 2>/dev/null)
33+
if [[ "$_DBG" == "true" && -n "$_LOG" ]]; then
34+
log() { printf '[%s] [INFO] copilot/post: %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$_LOG"; }
35+
fi
36+
fi
37+
38+
log "tool=$TOOL"
39+
40+
RAW_ARGS="$(printf '%s' "$INPUT" | jq -r '.toolArgs // "" | if type == "string" then . else tojson end')"
41+
42+
# Bind the key as data via --arg, not interpolated into the jq program.
43+
# Supports single-key lookup only (no dotted paths) — all current callers
44+
# pass a single field like `.path`, `.command`, etc.
45+
arg() { printf '%s' "$RAW_ARGS" | jq -r --arg k "${1#.}" '.[$k] // ""'; }
46+
47+
resolve_path() {
48+
local p="$1"
49+
if [[ -z "$p" ]]; then printf ''; return; fi
50+
if [[ "$p" != /* ]]; then printf '%s/%s' "$CWD" "$p"; else printf '%s' "$p"; fi
51+
}
52+
53+
case "$TOOL" in
54+
apply_patch)
55+
NORMALIZED="$(jq -n --arg cwd "$CWD" --arg patch "$RAW_ARGS" \
56+
'{tool_name:"ApplyPatch", cwd:$cwd, tool_input:{patch_text:$patch}}')"
57+
;;
58+
59+
edit|str_replace)
60+
FP="$(resolve_path "$(arg .path)")"
61+
NORMALIZED="$(jq -n --arg cwd "$CWD" --arg fp "$FP" \
62+
'{tool_name:"Edit", cwd:$cwd, tool_input:{file_path:$fp}}')"
63+
;;
64+
65+
create|write)
66+
FP="$(resolve_path "$(arg .path)")"
67+
NORMALIZED="$(jq -n --arg cwd "$CWD" --arg fp "$FP" \
68+
'{tool_name:"Write", cwd:$cwd, tool_input:{file_path:$fp}}')"
69+
;;
70+
71+
bash)
72+
CMD="$(arg .command)"
73+
NORMALIZED="$(jq -n --arg cwd "$CWD" --arg cmd "$CMD" \
74+
'{tool_name:"Bash", cwd:$cwd, tool_input:{command:$cmd}}')"
75+
;;
76+
77+
*)
78+
log "unhandled tool=$TOOL — exiting"
79+
exit 0
80+
;;
81+
esac
82+
83+
log "translated tool=$TOOL → closing"
84+
85+
printf '%s' "$NORMALIZED" | "$BIN_DIR/core-post-tool.sh"
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
#!/usr/bin/env bash
2+
# code-preview-diff.sh — PreToolUse hook adapter for GitHub Copilot CLI.
3+
#
4+
# Translates Copilot's hook payload (stdin JSON with toolName/toolArgs) into
5+
# the normalized {tool_name, cwd, tool_input} format consumed by
6+
# bin/core-pre-tool.sh, then delegates to it.
7+
#
8+
# Field mapping:
9+
# apply_patch → ApplyPatch (toolArgs is raw patch text)
10+
# edit/str_replace → Edit ({path, old_str, new_str})
11+
# create/write → Write ({path, file_text | content})
12+
# bash → Bash ({command, description})
13+
# view/glob/... → ignored
14+
#
15+
# Note: toolArgs is a JSON-encoded string in preToolUse and an object in
16+
# postToolUse; we normalize both to a string so downstream parsing is uniform.
17+
18+
set -uo pipefail
19+
20+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
21+
BIN_DIR="$SCRIPT_DIR/../../bin"
22+
export CODE_PREVIEW_BACKEND="copilot"
23+
24+
INPUT="$(cat)"
25+
26+
TOOL="$(printf '%s' "$INPUT" | jq -r '.toolName // ""')"
27+
CWD="$(printf '%s' "$INPUT" | jq -r '.cwd // ""')"
28+
29+
# Noise tools never produce a preview — bail out before the expensive
30+
# socket/log-setup RPC so the log stays clean.
31+
case "$TOOL" in
32+
""|view|glob|grep|ls|report_intent) exit 0 ;;
33+
esac
34+
35+
# Logging — mirrors core-pre-tool.sh. Gated on `debug = true` in setup().
36+
log() { :; }
37+
# shellcheck source=/dev/null
38+
source "$BIN_DIR/nvim-socket.sh" "$CWD" 2>/dev/null || true
39+
# shellcheck source=/dev/null
40+
source "$BIN_DIR/nvim-send.sh" 2>/dev/null || true
41+
_NVIM_SERVERNAME=""
42+
_NVIM_CWD=""
43+
if [[ -n "${NVIM_SOCKET:-}" ]]; then
44+
_CTX=$(nvim --server "$NVIM_SOCKET" --remote-expr "luaeval(\"vim.json.encode({debug=require('code-preview.log').is_enabled(),log_file=require('code-preview.log').get_log_path() or '',servername=vim.v.servername,cwd=vim.fn.getcwd()})\")" 2>/dev/null || echo '{}')
45+
_DBG=$(echo "$_CTX" | jq -r '.debug // false' 2>/dev/null)
46+
_LOG=$(echo "$_CTX" | jq -r '.log_file // ""' 2>/dev/null)
47+
_NVIM_SERVERNAME=$(echo "$_CTX" | jq -r '.servername // ""' 2>/dev/null)
48+
_NVIM_CWD=$(echo "$_CTX" | jq -r '.cwd // ""' 2>/dev/null)
49+
if [[ "$_DBG" == "true" && -n "$_LOG" ]]; then
50+
log() { printf '[%s] [INFO] copilot/pre: %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$_LOG"; }
51+
fi
52+
fi
53+
54+
log "tool=$TOOL servername=${_NVIM_SERVERNAME:-<none>} nvim_cwd=${_NVIM_CWD:-<none>} hook_cwd=$CWD"
55+
56+
# Normalize toolArgs to a raw string. For JSON-object tools this becomes the
57+
# stringified JSON; for apply_patch it's the raw patch text.
58+
RAW_ARGS="$(printf '%s' "$INPUT" | jq -r '.toolArgs // "" | if type == "string" then . else tojson end')"
59+
60+
# Bind the key as data via --arg, not interpolated into the jq program.
61+
# Supports single-key lookup only (no dotted paths) — all current callers
62+
# pass a single field like `.path`, `.command`, etc.
63+
arg() { printf '%s' "$RAW_ARGS" | jq -r --arg k "${1#.}" '.[$k] // ""'; }
64+
65+
resolve_path() {
66+
local p="$1"
67+
if [[ -z "$p" ]]; then printf ''; return; fi
68+
if [[ "$p" != /* ]]; then printf '%s/%s' "$CWD" "$p"; else printf '%s' "$p"; fi
69+
}
70+
71+
case "$TOOL" in
72+
apply_patch)
73+
NORMALIZED="$(jq -n --arg cwd "$CWD" --arg patch "$RAW_ARGS" \
74+
'{tool_name:"ApplyPatch", cwd:$cwd, tool_input:{patch_text:$patch}}')"
75+
;;
76+
77+
edit|str_replace)
78+
FP="$(resolve_path "$(arg .path)")"
79+
NORMALIZED="$(jq -n \
80+
--arg cwd "$CWD" \
81+
--arg fp "$FP" \
82+
--arg os "$(arg .old_str)" \
83+
--arg ns "$(arg .new_str)" \
84+
'{tool_name:"Edit", cwd:$cwd,
85+
tool_input:{file_path:$fp, old_string:$os, new_string:$ns, replace_all:false}}')"
86+
;;
87+
88+
create|write)
89+
FP="$(resolve_path "$(arg .path)")"
90+
# Copilot's create uses file_text; fall back to content for other models.
91+
CONTENT="$(printf '%s' "$RAW_ARGS" | jq -r '.file_text // .content // ""')"
92+
NORMALIZED="$(jq -n --arg cwd "$CWD" --arg fp "$FP" --arg c "$CONTENT" \
93+
'{tool_name:"Write", cwd:$cwd, tool_input:{file_path:$fp, content:$c}}')"
94+
;;
95+
96+
bash)
97+
CMD="$(arg .command)"
98+
NORMALIZED="$(jq -n --arg cwd "$CWD" --arg cmd "$CMD" \
99+
'{tool_name:"Bash", cwd:$cwd, tool_input:{command:$cmd}}')"
100+
;;
101+
102+
*)
103+
log "unhandled tool=$TOOL — exiting"
104+
exit 0
105+
;;
106+
esac
107+
108+
# Guard against malformed payloads (missing toolArgs fields). Sending an
109+
# empty path or command downstream produces a broken/empty diff; a clean
110+
# skip is preferable. apply_patch is already resilient — apply-patch.lua
111+
# parses zero files from an empty patch and exits cleanly.
112+
case "$TOOL" in
113+
edit|str_replace|create|write)
114+
if [[ -z "$FP" ]]; then
115+
log "empty file path for tool=$TOOL — skipping"
116+
exit 0
117+
fi
118+
;;
119+
bash)
120+
if [[ -z "$CMD" ]]; then
121+
log "empty command for bash — skipping"
122+
exit 0
123+
fi
124+
;;
125+
esac
126+
127+
log "translated tool=$TOOL$(printf '%s' "$NORMALIZED" | jq -c '{tool_name, file: .tool_input.file_path // "", has_patch: (.tool_input.patch_text != null)}' 2>/dev/null || echo 'parse-error')"
128+
129+
printf '%s' "$NORMALIZED" | "$BIN_DIR/core-pre-tool.sh"

bin/apply-patch.lua

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,18 @@ for line in (patch_text .. "\n"):gmatch("([^\n]*)\n") do
7676
table.insert(current_file.hunks, current_file.current_hunk)
7777
elseif line == "*** End Patch" or line == "*** Begin Patch" then
7878
current_file = nil
79-
elseif current_file and current_file.current_hunk then
80-
table.insert(current_file.current_hunk.lines, line)
79+
elseif current_file then
80+
-- `*** Add File:` in the GPT patch format has no `@@` marker — content
81+
-- lines follow directly. Lazy-create a hunk on the first content line
82+
-- so those lines aren't dropped, without leaving an empty leading hunk
83+
-- when `@@` *is* present.
84+
if not current_file.current_hunk and current_action == "add" then
85+
current_file.current_hunk = { lines = {} }
86+
table.insert(current_file.hunks, current_file.current_hunk)
87+
end
88+
if current_file.current_hunk then
89+
table.insert(current_file.current_hunk.lines, line)
90+
end
8191
end
8292
end
8393

bin/core-post-tool.sh

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
# "tool_input": { "file_path": "...", ... } }
1010
#
1111
# Environment:
12-
# CODE_PREVIEW_BACKEND — "claudecode" or "opencode" (currently unused, reserved)
12+
# CODE_PREVIEW_BACKEND — "claudecode" | "opencode" | "copilot". Not read
13+
# by this script; kept set by adapters for symmetry
14+
# with core-pre-tool.sh, which does gate on it.
1315

1416
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
1517

@@ -56,8 +58,8 @@ if [[ "$TOOL_NAME" == "ApplyPatch" ]]; then
5658
[[ "$fpath" == "/dev/null" ]] && continue
5759
echo "$fpath"
5860
done
59-
echo "$1" | grep -E '^\*\*\* (Update|Add) File:' | while IFS= read -r line; do
60-
echo "$line" | sed -E 's/^\*\*\* (Update|Add) File:[[:space:]]*//' | sed 's/[[:space:]]*$//'
61+
echo "$1" | grep -E '^\*\*\* (Update|Add|Delete) File:' | while IFS= read -r line; do
62+
echo "$line" | sed -E 's/^\*\*\* (Update|Add|Delete) File:[[:space:]]*//' | sed 's/[[:space:]]*$//'
6163
done
6264
}
6365

0 commit comments

Comments
 (0)