Skip to content
Merged
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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install shellcheck
run: sudo apt-get install -y shellcheck
- name: Install test tools
run: sudo apt-get install -y shellcheck tmux
- run: make check

macos-test:
Expand Down
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
# Changelog

## Unreleased

### Fixed
- Restore Linux CI by explicitly quoting empty `CDPATH` assignments.
- Preserve the order of multiple text blocks within an assistant message and
skip malformed message fields without losing the rest of the answer.
- Retry missing or disappearing transcripts during settling, retaining text
already read and falling back to the Stop payload at the deadline.
- Give overlapping hook writes separate atomic temporary files; store answers
with owner-only permissions.
- Quote hook and tmux commands for repository paths containing shell syntax,
including when upgrading existing installations.
- Keep repeated installs idempotent when `set-clipboard` is off; preserve
settings permissions and unrelated hooks on install and uninstall.
- Remove tmux bindings even if tmux has been removed, avoid creating config
during uninstall, and replace the `kill-server` advice with `unbind-key y`.

### Added
- Regression tests for extraction races and installer behavior in disposable
homes, including a real tmux configuration/parser check on Linux CI.

## 0.2.0 — 2026-09-14

### Changed
Expand Down
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ lint:
echo "lint: shellcheck not installed, skipping shell lint"; \
fi
@if python3 -m pyflakes --version >/dev/null 2>&1; then \
python3 -m pyflakes hooks/extract.py tests/test_extract.py; \
python3 -m pyflakes hooks/extract.py tests/*.py; \
else \
echo "lint: pyflakes not installed, falling back to py_compile"; \
python3 -m py_compile hooks/extract.py; \
python3 -m py_compile hooks/extract.py tests/*.py; \
fi

check: lint test
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,10 @@ make lint # shellcheck, when installed
The tests build synthetic transcripts and drive the hook the way Claude Code
does (payload on stdin, `TMUX_PANE` in the environment), including the case
where the transcript is still behind when the hook fires. CI runs them on
Linux and macOS. Changes are listed in [CHANGELOG.md](CHANGELOG.md).
Linux and macOS. Installer tests use disposable homes and preserve the real
user's configuration. When tmux is installed, they also verify the generated
binding with an isolated tmux server; Linux CI includes this check.
Changes are listed in [CHANGELOG.md](CHANGELOG.md).

## License

Expand Down
5 changes: 3 additions & 2 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,9 @@ One JSON object per line. Measured shape:

Four things this dictates:

1. **One content block per assistant line.** An answer is spread across many
lines, so all of them must be collected and re-joined in order.
1. **One assistant message per line.** An answer can span many lines and a
message can hold several text blocks. Collect messages in chronological
order while preserving the order of the blocks within each message.
2. **Tool results are `user` entries.** Naively stopping at the first `user`
entry truncates every answer that used a tool. The boundary test is "a
`user` entry containing at least one non-`tool_result` block".
Expand Down
54 changes: 38 additions & 16 deletions hooks/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import json
import os
import sys
import tempfile
import time

VERSION = "0.2.0"
Expand Down Expand Up @@ -55,7 +56,8 @@ def iter_lines_reverse(path, chunk_size=CHUNK):


def blocks_of(entry):
content = (entry.get("message") or {}).get("content")
message = entry.get("message")
content = message.get("content") if isinstance(message, dict) else None
if isinstance(content, list):
return [b for b in content if isinstance(b, dict)]
if isinstance(content, str):
Expand Down Expand Up @@ -92,12 +94,17 @@ def extract_answer(lines):

kind = entry.get("type")
if kind == "assistant":
message_parts = []
for block in blocks_of(entry):
# thinking and tool_use are not the answer.
if block.get("type") == "text":
text = (block.get("text") or "").strip()
if block.get("type") == "text" and isinstance(block.get("text"), str):
text = block["text"].strip()
if text:
parts.append(text)
message_parts.append(text)
# Only entries run backwards; blocks within an entry are already
# chronological and must stay together when parts is reversed.
if message_parts:
parts.append("\n\n".join(message_parts))
elif kind == "user":
if is_human_turn(entry):
break
Expand Down Expand Up @@ -147,9 +154,8 @@ def caught_up(answer, last_message):
The hook payload carries that block as `last_assistant_message`; when the
extracted answer ends with it, nothing more is coming.
"""
if not last_message:
return False
return squash(answer).endswith(squash(last_message))
final_text = squash(last_message or "")
return bool(final_text) and squash(answer).endswith(final_text)


def settle(path, last_message, max_wait=None):
Expand All @@ -165,6 +171,7 @@ def settle(path, last_message, max_wait=None):
"""
if max_wait is None:
max_wait = SETTLE_MAX
last_message = (last_message or "").strip()
deadline = time.monotonic() + max_wait
answer = ""
size = None
Expand All @@ -175,9 +182,17 @@ def settle(path, last_message, max_wait=None):
except OSError:
current = -1
if current != size:
size = current
quiet = 0
answer = extract_answer(iter_lines_reverse(path))
try:
candidate = extract_answer(iter_lines_reverse(path)) if current >= 0 else answer
except OSError:
# The file may disappear between stat and open. Retry even
# if it comes back with the same size, retaining any text
# already read so complete() can still add the final block.
size = None
else:
size = current
answer = candidate
if caught_up(answer, last_message):
return answer
else:
Expand Down Expand Up @@ -219,12 +234,19 @@ def log_error(exc):


def write_answer(text, dest):
os.makedirs(os.path.dirname(dest), exist_ok=True)
tmp = dest + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
fh.write(text)
# Atomic: a keypress landing mid-write must not read a half-written file.
os.replace(tmp, dest)
directory = os.path.dirname(dest)
os.makedirs(directory, mode=0o700, exist_ok=True)
# Overlapping hooks need separate temporary files. mkstemp also keeps
# answers private (0600) regardless of the user's umask.
fd, tmp = tempfile.mkstemp(prefix=os.path.basename(dest) + ".", suffix=".tmp", dir=directory)
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(text)
# A keypress landing mid-write must not read a half-written file.
os.replace(tmp, dest)
finally:
if os.path.exists(tmp):
os.remove(tmp)


def prune(keep_days=KEEP_DAYS, now=None):
Expand Down Expand Up @@ -278,7 +300,7 @@ def main():
transcript = payload.get("transcript_path")
last_message = payload.get("last_assistant_message")

if not transcript or not os.path.exists(transcript):
if not transcript:
# The payload still carries the final block; better than nothing.
answer = complete("", last_message)
else:
Expand Down
106 changes: 80 additions & 26 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

set -eu

REPO=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
REPO=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
SETTINGS="$HOME/.claude/settings.json"
TMUX_CONF="$HOME/.tmux.conf"
MARK_BEGIN="# >>> cc-yank >>>"
Expand All @@ -24,30 +24,37 @@ command -v python3 >/dev/null 2>&1 || {
echo "cc-yank: python3 is required (stdlib only, no packages)." >&2
exit 1
}
chmod +x "$REPO/hooks/extract.py" "$REPO/bin/cc-yank"
if [ "$uninstall" -eq 0 ]; then
chmod +x "$REPO/hooks/extract.py" "$REPO/bin/cc-yank"
fi

# ---------------------------------------------------- ~/.claude/settings.json
# Merged with python3 rather than sed: this file holds the user's whole
# configuration and a botched edit costs them more than this tool is worth.
echo "settings.json"
if [ ! -f "$SETTINGS" ]; then
mkdir -p "$(dirname "$SETTINGS")"
printf '{}\n' > "$SETTINGS"
say "created $SETTINGS"
fi

python3 - "$SETTINGS" "$REPO/hooks/extract.py" "$uninstall" <<'PY'
import json, os, shutil, sys, time
import json, os, shlex, shutil, sys, time

path, hook, uninstall = sys.argv[1], sys.argv[2], sys.argv[3] == "1"
command = '"%s"' % hook
command = shlex.quote(hook)
# Recognize commands written by older installers as well.
commands = {command, hook, '"%s"' % hook}

if not os.path.isfile(path):
if uninstall:
print(" no cc-yank hook registered")
sys.exit(0)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as fh:
fh.write("{}\n")
print(" created %s" % path)

with open(path) as fh:
settings = json.load(fh)

stop = settings.setdefault("hooks", {}).setdefault("Stop", [])
present = any(
h.get("command") in (command, hook)
h.get("command") in commands
for entry in stop for h in entry.get("hooks", [])
)

Expand All @@ -57,15 +64,23 @@ if uninstall:
sys.exit(0)
for entry in stop:
entry["hooks"] = [
h for h in entry.get("hooks", []) if h.get("command") not in (command, hook)
h for h in entry.get("hooks", []) if h.get("command") not in commands
]
settings["hooks"]["Stop"] = [e for e in stop if e.get("hooks")]
if not settings["hooks"]["Stop"]:
del settings["hooks"]["Stop"]
action = "removed Stop hook"
elif present:
print(" Stop hook already registered")
sys.exit(0)
changed = False
for entry in stop:
for h in entry.get("hooks", []):
if h.get("command") in commands and h["command"] != command:
h["command"] = command
changed = True
if not changed:
print(" Stop hook already registered")
sys.exit(0)
action = "updated Stop hook quoting"
else:
stop.append({"hooks": [{"type": "command", "command": command}]})
action = "registered Stop hook"
Expand All @@ -82,16 +97,14 @@ tmp = path + ".tmp"
with open(tmp, "w") as fh:
json.dump(settings, fh, indent=2, ensure_ascii=False)
fh.write("\n")
shutil.copymode(path, tmp)
os.replace(tmp, path)
print(" %s (backup: %s)" % (action, os.path.basename(backup)))
PY

# ------------------------------------------------------------- ~/.tmux.conf
echo "tmux"
if ! command -v tmux >/dev/null 2>&1; then
say "tmux not installed — the keybinding is unavailable."
say "cc-yank still works as a CLI: $REPO/bin/cc-yank"
elif [ "$uninstall" -eq 1 ]; then
if [ "$uninstall" -eq 1 ]; then
if [ -f "$TMUX_CONF" ] && grep -qF "$MARK_BEGIN" "$TMUX_CONF"; then
tmp=$(mktemp)
sed "/^$MARK_BEGIN\$/,/^$MARK_END\$/d" "$TMUX_CONF" > "$tmp"
Expand All @@ -100,34 +113,75 @@ elif [ "$uninstall" -eq 1 ]; then
else
say "no cc-yank binding found"
fi
elif [ -f "$TMUX_CONF" ] && grep -qF "$MARK_BEGIN" "$TMUX_CONF"; then
say "binding already present"
elif ! command -v tmux >/dev/null 2>&1; then
say "tmux not installed — the keybinding is unavailable."
say "cc-yank still works as a CLI: $REPO/bin/cc-yank"
else
cat >> "$TMUX_CONF" <<EOF
# Quote for two parsers: tmux reads a single command argument, then sh
# executes that command. A pair of double quotes around the path alone
# still expands dollar signs and breaks on embedded quotes.
binding=$(python3 - "$REPO/bin/cc-yank" <<'PY'
import shlex, sys
command = shlex.quote(sys.argv[1]).replace("#", "##") + " #{pane_id}"
print("bind-key y run-shell -b " + shlex.quote(command))
PY
)
if [ -f "$TMUX_CONF" ] && grep -qF "$MARK_BEGIN" "$TMUX_CONF"; then
python3 - "$TMUX_CONF" "$binding" <<'PY'
import os, shutil, sys
path, binding = sys.argv[1:]
with open(path) as fh:
original = fh.readlines()
lines = []
managed = False
for line in original:
if line.rstrip("\n") == "# >>> cc-yank >>>":
managed = True
elif line.rstrip("\n") == "# <<< cc-yank <<<":
managed = False
if managed and line.startswith("bind-key y run-shell "):
line = binding + "\n"
lines.append(line)
if lines != original:
tmp = path + ".tmp"
with open(tmp, "w") as fh:
fh.writelines(lines)
shutil.copymode(path, tmp)
os.replace(tmp, path)
print(" updated binding quoting")
else:
print(" binding already present")
PY
else
cat >> "$TMUX_CONF" <<EOF
$MARK_BEGIN
# prefix + y copies Claude Code's last answer. Prefix-based on purpose: a raw
# binding would swallow the key before Claude Code's TUI ever sees it.
bind-key y run-shell -b '"$REPO/bin/cc-yank" #{pane_id}'
$binding
$MARK_END
EOF
say "added prefix + y binding to $TMUX_CONF"
say "added prefix + y binding to $TMUX_CONF"
fi
fi

# set-clipboard drives OSC 52, the only thing that reaches a clipboard over
# SSH. `on` and `external` both work; only `off` breaks cc-yank.
if command -v tmux >/dev/null 2>&1 && [ "$uninstall" -eq 0 ]; then
clip=$(tmux show-options -gv set-clipboard 2>/dev/null || echo external)
if [ "$clip" = "off" ]; then
printf '%s\nset -g set-clipboard external\n%s\n' "$MARK_BEGIN" "$MARK_END" >> "$TMUX_CONF"
say "set-clipboard was off — set to external (required for OSC 52)"
if ! sed -n "/^$MARK_BEGIN\$/,/^$MARK_END\$/p" "$TMUX_CONF" |
grep -qFx 'set -g set-clipboard external'; then
printf '%s\nset -g set-clipboard external\n%s\n' "$MARK_BEGIN" "$MARK_END" >> "$TMUX_CONF"
fi
say "set-clipboard is off — external is configured; reload tmux config to activate"
else
say "set-clipboard = $clip (ok)"
fi
fi

echo
if [ "$uninstall" -eq 1 ]; then
echo "Uninstalled. Restart Claude Code, and run: tmux kill-server"
echo "Uninstalled. Restart Claude Code. In a running tmux, run: tmux unbind-key y"
else
cat <<EOF
Installed. To activate:
Expand Down
4 changes: 2 additions & 2 deletions tests/test_cli.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ set -eu
# ------------------------------------------------------------------- locate
# Resolve paths relative to this script, not the caller's cwd (install.sh
# uses the same CDPATH= cd -- pattern for the same reason).
SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
ROOT=$(CDPATH= cd -- "$SELF_DIR/.." && pwd)
SELF_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
ROOT=$(CDPATH='' cd -- "$SELF_DIR/.." && pwd)
CLI="$ROOT/bin/cc-yank"

TMP=$(mktemp -d)
Expand Down
Loading
Loading