Skip to content
Closed
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
14 changes: 12 additions & 2 deletions .devcontainer/mise/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "[email protected]:alycda/dotfiles.git",
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <session id> (in <directory>, <reason>)
```

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
Expand Down
13 changes: 13 additions & 0 deletions mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 <task>`), 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
Expand Down
30 changes: 30 additions & 0 deletions tasks/20260930-061909/TASK.md
Original file line number Diff line number Diff line change
@@ -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.
104 changes: 104 additions & 0 deletions tests/claude-hooks.bats
Original file line number Diff line number Diff line change
@@ -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" <<STUB
#!/bin/sh
echo "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"
}
15 changes: 15 additions & 0 deletions tools/claude/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"hooks": {
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "\"$HOME/.claude/hooks/session-end-taskbook.sh\"",
"timeout": 10
}
]
}
]
}
}
60 changes: 60 additions & 0 deletions tools/claude/hooks/session-end-taskbook.sh
Original file line number Diff line number Diff line change
@@ -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
42 changes: 42 additions & 0 deletions tools/claude/install-hooks
Original file line number Diff line number Diff line change
@@ -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
Loading