diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9eacd62..a227a53 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,4 +37,4 @@ jobs: su tester -c 'cd ~/forge && ./bootstrap.sh --dry-run --yes --no-desktop --no-security' - name: Validate stow packages link cleanly (zsh/git/tmux) run: | - su tester -c 'cd ~/forge && stow --no --target=$HOME --dir dotfiles zsh git tmux 2>&1 | tee /tmp/stow.log; ! grep -i conflict /tmp/stow.log' + su tester -c 'cd ~/forge && stow --no --target=$HOME --dir dotfiles zsh git tmux claude 2>&1 | tee /tmp/stow.log; ! grep -i conflict /tmp/stow.log' diff --git a/README.md b/README.md index c2a2d53..d19b773 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ cd forge | **CLI** | eza, bat, fd, ripgrep, fzf, zoxide, lazygit, tmux (+tpm), neovim (LazyVim), direnv, mise, gh, glab, jira-cli, aerc | | **Desktop** | **sway** (Wayland tiling) + **waybar** + **dunst** + wl-clipboard + kitty + Nerd Font | | **Security** | hardened SSH, ufw, fail2ban, unattended-upgrades, sysctl hardening, AppArmor, Lynis audit | +| **Claude Code** | tmux tab dot per session (yellow running / red needs you / green done, unseen), `prefix + a` picker, `prefix + Tab` next waiting session, desktop notif with "Aller" action, sessions restored via tmux-resurrect | All of Alexandre's aliases and functions are carried over verbatim where portable; macOS-only bits (clipboard, `open`, `notify`, Spotlight pickers) are reimplemented @@ -42,7 +43,7 @@ The desktop phase auto-skips on headless machines. bootstrap.sh # orchestrator lib/common.sh # logging, OS detection, idempotent helpers, backups install/ # 00-preflight → 10-packages → 20-shell → 30-cli-tools - # → 40-desktop → 50-stow → 90-security + # → 40-desktop → 50-stow → 60-claude → 90-security security/ # ssh, firewall, fail2ban, unattended-upgrades, sysctl, apparmor, audit dotfiles/ # GNU stow packages (one dir per app) secrets/ # how to place local secrets (none are committed) diff --git a/bootstrap.sh b/bootstrap.sh index 9a78c16..c776833 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -72,6 +72,7 @@ run_step "$FORGE_DIR/install/20-shell.sh" run_step "$FORGE_DIR/install/30-cli-tools.sh" [[ "$DO_DESKTOP" == "1" ]] && run_step "$FORGE_DIR/install/40-desktop.sh" run_step "$FORGE_DIR/install/50-stow.sh" +run_step "$FORGE_DIR/install/60-claude.sh" [[ "$DO_SECURITY" == "1" ]] && run_step "$FORGE_DIR/install/90-security.sh" section "done" diff --git a/dotfiles/claude/.claude/hooks/is-watched.sh b/dotfiles/claude/.claude/hooks/is-watched.sh new file mode 100755 index 0000000..c216056 --- /dev/null +++ b/dotfiles/claude/.claude/hooks/is-watched.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# exit 0 si tu regardes ce pane : sa fenêtre est active dans une session attachée ET kitty a le focus sway. +[ -n "$TMUX_PANE" ] || exit 1 +[ "$(tmux display -p -t "$TMUX_PANE" '#{&&:#{window_active},#{session_attached}}')" = 1 ] || exit 1 +# Hors sway (SSH, TTY) on ne peut pas savoir : la fenêtre tmux active suffit. +[ -n "${SWAYSOCK:-}" ] || exit 0 +swaymsg -t get_tree | jq -e '.. | objects | select(.focused == true and .app_id == "kitty")' >/dev/null diff --git a/dotfiles/claude/.claude/hooks/notify.sh b/dotfiles/claude/.claude/hooks/notify.sh new file mode 100755 index 0000000..c424a34 --- /dev/null +++ b/dotfiles/claude/.claude/hooks/notify.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# Hook Notification : notif desktop (dunst) avec projet + tmux ; action "Aller" = retour sur le bon pane. +input=$(cat) +~/.claude/hooks/is-watched.sh && exit 0 # tu regardes déjà ce pane : pas de notif +command -v notify-send >/dev/null || exit 0 +msg=$(jq -r '.message // "Claude attend ton input"' <<<"$input") +proj=$(basename "$(jq -r '.cwd // empty' <<<"$input")") +title="Claude · ${proj:-?}" +if [ -z "$TMUX_PANE" ]; then notify-send -a Claude "$title" "$msg"; exit 0; fi +where=$(tmux display -p -t "$TMUX_PANE" '#S:#I #W') +# --wait bloque jusqu'au clic : on détache pour ne pas bloquer Claude. +( + [ "$(notify-send -a Claude -A jump=Aller --wait "$title" "$msg"$'\n'"tmux $where")" = jump ] || exit 0 + tmux switch-client -t "$TMUX_PANE"; tmux select-window -t "$TMUX_PANE"; tmux select-pane -t "$TMUX_PANE" + [ -n "${SWAYSOCK:-}" ] && swaymsg '[app_id="kitty"] focus' +) >/dev/null 2>&1 & +exit 0 diff --git a/dotfiles/claude/.claude/hooks/tmux-state.sh b/dotfiles/claude/.claude/hooks/tmux-state.sh new file mode 100755 index 0000000..377cdd3 --- /dev/null +++ b/dotfiles/claude/.claude/hooks/tmux-state.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# Pose l'état Claude (run|wait|done) sur la fenêtre tmux du pane ; sans argument = efface. +# "done" sous tes yeux = rien à signaler, donc on efface (le vert veut dire "fini, pas encore vu"). +[ -n "$TMUX_PANE" ] || exit 0 +[ "$1" = "done" ] && ~/.claude/hooks/is-watched.sh && set -- +if [ -n "$1" ]; then tmux set -w -t "$TMUX_PANE" @claude "$1"; else tmux set -wu -t "$TMUX_PANE" @claude; fi +tmux refresh-client -S 2>/dev/null +exit 0 diff --git a/dotfiles/tmux/.config/tmux/claude-next.sh b/dotfiles/tmux/.config/tmux/claude-next.sh new file mode 100755 index 0000000..d1c7c89 --- /dev/null +++ b/dotfiles/tmux/.config/tmux/claude-next.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# Saute à la prochaine fenêtre Claude rouge (wait), sinon verte (done), en tournant à partir de la fenêtre courante. +# Usage (depuis tmux) : claude-next.sh +client=$1 cur=$2 +t=$(tmux list-windows -a -F "#{session_name}:#{window_index} #{@claude}" | awk -F'\t' -v cur="$cur" ' + { id[NR]=$1; st[NR]=$2; if ($1==cur) c=NR } + END { n=split("wait done", want, " ") + for (w=1; w<=n; w++) for (i=1; i<=NR; i++) { j=(c+i-1)%NR+1; if (st[j]==want[w]) { print id[j]; exit } } }') +if [ -n "$t" ]; then tmux switch-client -c "$client" -t "$t"; else tmux display -c "$client" "Aucune session Claude ne t'attend"; fi diff --git a/dotfiles/tmux/.config/tmux/tmux.conf b/dotfiles/tmux/.config/tmux/tmux.conf index 2d70987..9892c81 100644 --- a/dotfiles/tmux/.config/tmux/tmux.conf +++ b/dotfiles/tmux/.config/tmux/tmux.conf @@ -79,9 +79,20 @@ set -g status-style "bg=default,fg=white" set -g status-left-length 40 set -g status-right-length 80 set -g status-left "#[fg=green,bold] #S #[fg=white,nobold]│ " -set -g status-right "#[fg=cyan]#(whoami)@#h #[fg=white]│ #[fg=yellow]%a %d %b #[fg=white]│ #[fg=magenta]%H:%M " -setw -g window-status-format " #I:#W " -setw -g window-status-current-format "#[fg=black,bg=green,bold] #I:#W " +# Pastille d'état Claude Code (posée par ~/.claude/hooks/tmux-state.sh) : +# jaune = en cours, rouge = attend ta validation, vert = terminé / à toi +set -g @claude_dot "#{?#{==:#{@claude},run},#[fg=yellow]●#[default] ,#{?#{==:#{@claude},wait},#[fg=red]●#[default] ,#{?#{==:#{@claude},done},#[fg=green]●#[default] ,}}}" +setw -g window-status-format " #{E:@claude_dot}#I:#W " +setw -g window-status-current-format "#{E:@claude_dot}#[fg=black,bg=green,bold] #I:#W " +# Compteur toutes sessions confondues : un ● par fenêtre Claude rouge, puis verte +set -g @claude_count "#{S:#{W:#{?#{==:#{@claude},wait},#[fg=red]●,}}}#{S:#{W:#{?#{==:#{@claude},done},#[fg=green]●,}}}#[default]" +set -g status-right "#{E:@claude_count} #[fg=cyan]#(whoami)@#h #[fg=white]│ #[fg=yellow]%a %d %b #[fg=white]│ #[fg=magenta]%H:%M " +# Vert = "fini, pas encore vu" : on l'efface dès que tu arrives sur la fenêtre +set-hook -g session-window-changed 'if -F "#{==:#{@claude},done}" "set -wu @claude"' +set-hook -g client-session-changed 'if -F "#{==:#{@claude},done}" "set -wu @claude"' +# prefix + a : picker de toutes les fenêtres Claude | prefix + Tab : prochaine qui t'attend (rouge > vert) +bind a choose-tree -Zw -f '#{@claude}' -F '#{E:@claude_dot}#{pane_current_path}' +bind Tab run-shell "~/.config/tmux/claude-next.sh '#{client_name}' '#{session_name}:#{window_index}'" set -g pane-border-style "fg=brightblack" set -g pane-active-border-style "fg=green" @@ -93,6 +104,11 @@ set -g @plugin 'tmux-plugins/tpm' set -g @plugin 'tmux-plugins/tmux-sensible' set -g @plugin 'christoomey/vim-tmux-navigator' # C-h/j/k/l navigue nvim ⇄ tmux set -g @plugin 'tmux-plugins/tmux-yank' # y dans copy-mode → clipboard système +set -g @plugin 'tmux-plugins/tmux-resurrect' # prefix + C-s sauve / C-r restaure les sessions +set -g @plugin 'tmux-plugins/tmux-continuum' # sauvegarde auto toutes les 15 min + restauration au démarrage +set -g @continuum-restore 'on' +# ponytail: --continue reprend la conv la plus récente du dossier ; 2 sessions Claude dans le même dossier reprendront la même → claude --resume à la main +set -g @resurrect-processes '"claude->claude --continue"' # Doit rester en toute fin de fichier run '~/.config/tmux/plugins/tpm/tpm' diff --git a/install/40-desktop.sh b/install/40-desktop.sh index f5c6b0e..a183589 100755 --- a/install/40-desktop.sh +++ b/install/40-desktop.sh @@ -9,7 +9,7 @@ source "${FORGE_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}/lib/commo log "Installing Wayland tiling stack" apt_install \ sway swaybg swayidle swaylock waybar \ - dunst wl-clipboard grim slurp \ + dunst libnotify-bin wl-clipboard grim slurp \ kitty \ fonts-font-awesome \ brightnessctl playerctl pavucontrol \ diff --git a/install/50-stow.sh b/install/50-stow.sh index a43d833..51cb4a9 100755 --- a/install/50-stow.sh +++ b/install/50-stow.sh @@ -12,7 +12,7 @@ DOTFILES="$FORGE_DIR/dotfiles" [[ -d "$DOTFILES" ]] || die "missing $DOTFILES" # Desktop packages only stow when a GUI is present. -PACKAGES=(zsh starship git tmux kitty aerc jira) +PACKAGES=(zsh starship git tmux kitty aerc jira claude) if has_gui; then PACKAGES+=(sway waybar dunst) fi @@ -37,7 +37,11 @@ for pkg in "${PACKAGES[@]}"; do [[ -d "$DOTFILES/$pkg" ]] || { warn "package missing, skipping: $pkg"; continue; } log "stow: $pkg" backup_conflicts "$pkg" - run stow --dir "$DOTFILES" --target "$HOME" --restow "$pkg" + # ~/.claude is Claude Code's state dir: never fold it into a symlink to the repo, + # or Claude would write its sessions/credentials inside forge. + fold=() + [[ "$pkg" == claude ]] && fold=(--no-folding) + run stow --dir "$DOTFILES" --target "$HOME" "${fold[@]}" --restow "$pkg" done ok "dotfiles linked" diff --git a/install/60-claude.sh b/install/60-claude.sh new file mode 100644 index 0000000..916fd97 --- /dev/null +++ b/install/60-claude.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# 60-claude.sh — register the Claude Code hooks (stowed in ~/.claude/hooks) in +# ~/.claude/settings.json. That file is owned by Claude Code (permissions, model…), +# so it is merged rather than stowed: each hook is added only if its command is +# not already present, which keeps the step idempotent. +set -euo pipefail +# shellcheck source=../lib/common.sh +source "${FORGE_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}/lib/common.sh" + +require_cmd jq "run 10-packages.sh." + +SETTINGS="$HOME/.claude/settings.json" +# shellcheck disable=SC2088 # literal on purpose: expanded later by the shell Claude Code runs hooks with +H='~/.claude/hooks' + +if [[ "${FORGE_DRY_RUN:-0}" == "1" ]]; then + printf '%s\n' "${C_YELLOW}[dry-run]${C_RESET} merge Claude hooks into $SETTINGS" + exit 0 +fi + +mkdir -p "$(dirname "$SETTINGS")" +[[ -s "$SETTINGS" ]] || echo '{}' > "$SETTINGS" + +# jq fails on invalid JSON before anything is written, so the original stays intact. +jq --arg h "$H" ' + def add(ev; cmd; extra): + if any(.hooks[ev][]?.hooks[]?; .command == cmd) then . + else .hooks[ev] += [{hooks: [{type: "command", command: cmd}]} + extra] end; + add("UserPromptSubmit"; "\($h)/tmux-state.sh run"; {}) + | add("PostToolUse"; "\($h)/tmux-state.sh run"; {}) + | add("Notification"; "\($h)/tmux-state.sh wait"; {matcher: "permission_prompt|elicitation_dialog"}) + | add("Notification"; "\($h)/notify.sh"; {}) + | add("Stop"; "\($h)/tmux-state.sh done"; {}) + | add("SessionEnd"; "\($h)/tmux-state.sh"; {}) +' "$SETTINGS" > "$SETTINGS.tmp" || { rm -f "$SETTINGS.tmp"; die "invalid JSON in $SETTINGS, left untouched"; } +mv -f "$SETTINGS.tmp" "$SETTINGS" + +ok "Claude hooks registered in $SETTINGS"