diff --git a/.devcontainer/mise/devcontainer.json b/.devcontainer/mise/devcontainer.json index bd98b635..25090c2f 100644 --- a/.devcontainer/mise/devcontainer.json +++ b/.devcontainer/mise/devcontainer.json @@ -13,8 +13,18 @@ // The test. Trust this checkout's mise.toml, apply its [dotfiles], and print // the status: both entries should stop reading "missing". No --force, so - // a genuine conflict is reported rather than overwritten. - "postCreateCommand": "~/.local/bin/mise trust && ~/.local/bin/mise dotfiles apply -y && ~/.local/bin/mise install && ~/.local/bin/mise dotfiles status", + // a genuine conflict is reported rather than overwritten. Then the + // claude-hooks task, so a Claude session in here leaves its resume pointer + // on the taskbook board like one on the host. + "postCreateCommand": "~/.local/bin/mise trust && ~/.local/bin/mise dotfiles apply -y && ~/.local/bin/mise install && ~/.local/bin/mise dotfiles status && ~/.local/bin/mise run claude-hooks", + + // The host's taskbook data, so tb in here and tb outside show one board. + // tb takes storage.lock around writes, so two sessions ending together + // is fine. A bind mount, not a volume: a volume would be a second board + // that nothing outside the container reads. + "mounts": [ + "source=${localEnv:HOME}/.taskbook,target=/home/vscode/.taskbook,type=bind" + ], "remoteEnv": { "GIT_ORIGIN_REMOTE": "git@github.com:alycda/dotfiles.git", diff --git a/README.md b/README.md index 8d4ef90f..c0dfdef2 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,26 @@ bare-bones dotfiles (for accounts without Nix or Docker) +### taskbook and Claude Code + +`tb` ([taskbook](https://github.com/taskbook-sh/taskbook)) is a global mise +tool ([tools/mise/global.toml](tools/mise/global.toml)), so it works from any +directory. `mise run claude-hooks` registers a Claude Code `SessionEnd` hook +that leaves one task per session on the `@claude` board: + +```text +resume: claude --resume (in , ) +``` + +The reason is Claude's: `clear`, `resume`, `logout`, `prompt_input_exit` or +`other`. The root `mise.toml` links the hook script, +[session-end-taskbook.sh](tools/claude/hooks/session-end-taskbook.sh), into +`~/.claude/hooks/`. The settings entry is merged in by +[install-hooks](tools/claude/install-hooks), since Claude writes +`~/.claude/settings.json` itself. Without `tb` on the machine the hook exits +quietly. The mise devcontainer runs the same task and bind-mounts the host's +`~/.taskbook`, so a session inside it lands on the same board. + ## Nix The flake grows towards the full setup one testable step at a time. So far it diff --git a/mise.toml b/mise.toml index f50eedbb..06fdedf4 100644 --- a/mise.toml +++ b/mise.toml @@ -28,6 +28,11 @@ "~/.local/bin/huid-tasks-vscode" = { source = "tasks/scripts/huid-tasks-vscode", mode = "symlink" } # just itself, globally: the root [tools] only apply inside this repo. "~/.config/mise/conf.d/dotfiles.toml" = { source = "tools/mise/global.toml", mode = "symlink" } +# Claude Code's SessionEnd hook: one resume pointer per session on taskbook's +# @claude board. Only the script is linked. The hook entry goes into +# ~/.claude/settings.json by `mise run claude-hooks`, a merge, because Claude +# writes that file itself (theme, model), so a link would track those too. +"~/.claude/hooks/session-end-taskbook.sh" = { source = "tools/claude/hooks/session-end-taskbook.sh", mode = "symlink" } [env] EDITOR = "hx" @@ -48,10 +53,18 @@ helix = "25" gh = "latest" claude = "latest" tmux = "latest" +# tools/claude/install-hooks merges JSON with it. +jq = "latest" # Tasks are one-time setup steps you run by hand (`mise run `), written # so that running one again does nothing. Never make them run automatically, # e.g. from a hook: a login prompt with no terminal attached just hangs. +[tasks.claude-hooks] +description = "Register the SessionEnd taskbook hook in ~/.claude/settings.json, unless it is" +# The hook itself is in tools/claude/hooks.json; the script it runs is linked +# by [dotfiles] above, so apply that first. Merging again changes nothing. +run = "{{config_root}}/tools/claude/install-hooks" + [tasks.ssh-key] description = "Create an SSH key for GitHub, unless one exists" # gh-login uses SSH, so this runs first. The key gets the default name diff --git a/tasks/20260930-061909/TASK.md b/tasks/20260930-061909/TASK.md new file mode 100644 index 00000000..8f02cac5 --- /dev/null +++ b/tasks/20260930-061909/TASK.md @@ -0,0 +1,30 @@ +# dev.sh: the taskbook hook inside the container + +- STATUS: OPEN +- TAGS: docker, claude, taskbook + +## Description + +The `SessionEnd` hook (tools/claude/hooks) covers sessions on the host and in +the mise devcontainer. `docker/dev.sh` lives on `main`, and a container it +starts misses three things, so a Claude session in one leaves nothing on the +`@claude` board: + +- **`tb` in the image.** `lib/packages.nix` has no taskbook, and nixpkgs has no + package for the Rust port. Its flake (`github:taskbook-sh/taskbook`) or the + `tb-linux-*.tar.gz` release asset would do. +- **The data.** `-v "$HOME/.taskbook":/root/.taskbook` in `run_container`, + beside the devhome and claude-home volumes, so items land in the host's + `storage.json`. A volume would be a second board nothing outside reads. +- **The hook.** `~/.claude` in there is the claude-home volume, so its + `settings.json` needs `tools/claude/install-hooks` run against it + (`docker/entrypoint.sh`, or the home-manager claude-code module), with the + script at `/root/.claude/hooks/session-end-taskbook.sh`. + +The Nix devcontainer (`.devcontainer.json`) is in the same position: no `tb` +in `lib/packages.nix`, and home-manager doesn't link the script yet. Both +follow the decision in `tools/mise/global.toml` to leave taskbook out of Nix +for now. + +Two writers on one `storage.json` (a host session and a container session +ending together): tb takes `storage.lock` around writes. diff --git a/tests/claude-hooks.bats b/tests/claude-hooks.bats new file mode 100644 index 00000000..1a2946ba --- /dev/null +++ b/tests/claude-hooks.bats @@ -0,0 +1,104 @@ +#!/usr/bin/env bats +# The Claude Code SessionEnd hook (tools/claude/hooks/session-end-taskbook.sh) +# and the installer that registers it (tools/claude/install-hooks). tb is +# stubbed: it logs its arguments and answers --find from a file, so no +# taskbook is touched. HOME is a temp dir, so no settings.json is either. + +bats_require_minimum_version 1.5.0 + +setup() { + repo="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" + hook="$repo/tools/claude/hooks/session-end-taskbook.sh" + install="$repo/tools/claude/install-hooks" + stubs="$BATS_TEST_TMPDIR/bin" + log="$BATS_TEST_TMPDIR/log" + mkdir -p "$stubs" + : > "$log" + export HOME="$BATS_TEST_TMPDIR/home" + mkdir -p "$HOME/.claude" + settings="$HOME/.claude/settings.json" + unset TASKBOOK_DIR + + # tb logs its arguments; --find prints the fixture, if a test wrote one. + cat > "$stubs/tb" <> "$log" +case " \$* " in *" --find "*) cat "$BATS_TEST_TMPDIR/found" 2>/dev/null ;; esac +STUB + chmod +x "$stubs/tb" + PATH="$stubs:$PATH" +} + +# ended ID DIR REASON: what Claude writes to the hook's stdin. +ended() { + printf '{"session_id":"%s","transcript_path":"/t.jsonl","cwd":"%s","hook_event_name":"SessionEnd","reason":"%s"}' "$@" +} + +@test "a session leaves one resume task on the @claude board" { + run sh "$hook" <<< "$(ended abc-123 /work/repo prompt_input_exit)" + [ "$status" -eq 0 ] + [ -z "$output" ] + grep -Fx 'tb --cli --find abc-123' "$log" + grep -Fx 'tb --cli --task @claude resume: claude --resume abc-123 (in /work/repo, prompt_input_exit)' "$log" +} + +@test "a session already on the board is not added again" { + echo ' 1. ☐ resume: claude --resume abc-123 (in /work/repo, clear)' > "$BATS_TEST_TMPDIR/found" + run sh "$hook" <<< "$(ended abc-123 /work/repo other)" + [ "$status" -eq 0 ] + run ! grep -q -- '--task' "$log" +} + +@test "an @ in the directory can't turn into a board" { + run sh "$hook" <<< "$(ended abc-123 /work/me@host other)" + [ "$status" -eq 0 ] + grep -F 'in /work/me_host' "$log" + [ "$(grep -c '@' "$log")" -eq 1 ] +} + +@test "TASKBOOK_DIR reaches tb" { + TASKBOOK_DIR=/data run sh "$hook" <<< "$(ended abc-123 /work/repo other)" + [ "$status" -eq 0 ] + grep -Fx 'tb --cli --taskbook-dir /data --task @claude resume: claude --resume abc-123 (in /work/repo, other)' "$log" +} + +@test "without tb it exits 0 and says nothing" { + rm "$stubs/tb" + PATH="$stubs:/usr/bin:/bin" run sh "$hook" <<< "$(ended abc-123 /work/repo other)" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "empty input exits 0 and calls nothing" { + run sh "$hook" < /dev/null + [ "$status" -eq 0 ] + [ -z "$output" ] + [ ! -s "$log" ] +} + +@test "install-hooks adds SessionEnd and keeps what Claude wrote" { + printf '{"theme":"dark","model":"opus"}\n' > "$settings" + run "$install" + [ "$status" -eq 0 ] + [ "$(jq -r .theme "$settings")" = dark ] + [ "$(jq -r .model "$settings")" = opus ] + [ "$(jq '.hooks.SessionEnd | length' "$settings")" -eq 1 ] + jq -e '.hooks.SessionEnd[0].hooks[0].command | contains("session-end-taskbook.sh")' "$settings" +} + +@test "install-hooks again adds nothing, and a hook of your own stays" { + printf '{"hooks":{"SessionEnd":[{"hooks":[{"type":"command","command":"echo bye"}]}]}}\n' > "$settings" + "$install" + run "$install" + [ "$status" -eq 0 ] + [[ $output == *"already"* ]] + [ "$(jq '.hooks.SessionEnd | length' "$settings")" -eq 2 ] + [ "$(jq -r '.hooks.SessionEnd[0].hooks[0].command' "$settings")" = "echo bye" ] +} + +@test "install-hooks creates the file, at the path it is given" { + rmdir "$HOME/.claude" + run "$install" "$HOME/elsewhere/settings.json" + [ "$status" -eq 0 ] + jq -e '.hooks.SessionEnd | length == 1' "$HOME/elsewhere/settings.json" +} diff --git a/tools/claude/hooks.json b/tools/claude/hooks.json new file mode 100644 index 00000000..d5fbba48 --- /dev/null +++ b/tools/claude/hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "\"$HOME/.claude/hooks/session-end-taskbook.sh\"", + "timeout": 10 + } + ] + } + ] + } +} diff --git a/tools/claude/hooks/session-end-taskbook.sh b/tools/claude/hooks/session-end-taskbook.sh new file mode 100755 index 00000000..7f392346 --- /dev/null +++ b/tools/claude/hooks/session-end-taskbook.sh @@ -0,0 +1,60 @@ +#!/bin/sh +# session-end-taskbook.sh: Claude Code's SessionEnd hook. Leaves one task on +# taskbook's @claude board per session, so `tb` shows where every session was +# and how to resume it. Claude hands the session over on stdin as JSON +# (session_id, cwd, reason, ...). No summary is in it, so the task is a resume +# pointer, not a summary; edit it afterwards, or ask Claude to (tb --mcp). +# +# It never fails. Whatever goes wrong, it exits 0 and stays quiet, so a missing +# tb or a bare PATH can't make Claude's exit hang or print. A hook runs with the +# environment Claude was started from, which under the desktop app may not +# have mise activated, so tb is looked up on PATH first and through mise next. +set -u + +input=$(cat) || exit 0 +[ -n "$input" ] || exit 0 + +# field NAME: the string value of a top-level key. jq, then python3, then a +# sed for plain values, which is all these fields ever are. +field() { + if command -v jq >/dev/null 2>&1; then + printf '%s' "$input" | jq -r --arg k "$1" '.[$k] // empty' + elif command -v python3 >/dev/null 2>&1; then + printf '%s' "$input" | python3 -c \ + 'import json, sys; print(json.load(sys.stdin).get(sys.argv[1]) or "")' "$1" + else + printf '%s' "$input" | + sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -n 1 + fi +} + +session=$(field session_id) +cwd=$(field cwd) +reason=$(field reason) +[ -n "$session" ] || exit 0 + +tb=$(command -v tb 2>/dev/null || true) +if [ -z "$tb" ] && [ -x "$HOME/.local/bin/mise" ]; then + tb=$("$HOME/.local/bin/mise" which tb 2>/dev/null || true) +fi +[ -n "$tb" ] || exit 0 + +# TASKBOOK_DIR points tb at another data directory: a bind mount in a +# container, or a scratch one under test. +set -- --cli +[ -n "${TASKBOOK_DIR:-}" ] && set -- "$@" --taskbook-dir "$TASKBOOK_DIR" + +# One task per session. A session can end more than once (/clear, then the +# terminal closes), and the ID is what makes the item findable. +if "$tb" "$@" --find "$session" 2>/dev/null | grep -q -- "$session"; then + exit 0 +fi + +# tb reads @word and p:N out of the description as board and priority, so +# neither may appear in it. Paths don't carry p:, but @ is legal in them. +cwd=$(printf '%s' "${cwd:-?}" | tr '@' '_') + +"$tb" "$@" --task @claude \ + "resume: claude --resume $session (in $cwd${reason:+, $reason})" \ + >/dev/null 2>&1 || true +exit 0 diff --git a/tools/claude/install-hooks b/tools/claude/install-hooks new file mode 100755 index 00000000..959f781c --- /dev/null +++ b/tools/claude/install-hooks @@ -0,0 +1,42 @@ +#!/bin/sh +# install-hooks [SETTINGS]: merge tools/claude/hooks.json into Claude Code's +# user settings, ~/.claude/settings.json unless a path is given. +# +# A merge, not a link: Claude writes that file itself (theme, model, tui), so +# linking it would put those into the tracked copy, the way ~/.gitconfig would +# carry identity. Only the hooks come from the repo. An event's list keeps +# whatever is already in it and gains the entries whose command it lacks, so +# running this again changes nothing and hand-added hooks survive. +# +# Needs jq. The repo's mise.toml installs it, so `mise run claude-hooks` has it. +set -eu + +here=$(cd "$(dirname "$0")" && pwd) +fragment="$here/hooks.json" +settings="${1:-$HOME/.claude/settings.json}" + +if ! command -v jq >/dev/null 2>&1; then + echo "install-hooks: jq not found; run it as 'mise run claude-hooks'" >&2 + exit 1 +fi + +mkdir -p "$(dirname "$settings")" +[ -s "$settings" ] || printf '{}\n' > "$settings" + +merged=$(jq --slurpfile add "$fragment" ' + reduce ($add[0].hooks | to_entries[]) as $event (.; + ($event.key) as $name + | (.hooks[$name] // []) as $have + | [$have[] | .hooks[]?.command] as $commands + | .hooks[$name] = $have + [ + $event.value[] + | select(any(.hooks[]?.command; . as $c | $commands | index($c) | not)) + ]) +' "$settings") + +if [ "$merged" = "$(cat "$settings")" ]; then + echo "claude: hooks already in $settings" +else + printf '%s\n' "$merged" > "$settings.tmp" && mv "$settings.tmp" "$settings" + echo "claude: hooks merged into $settings" +fi