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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions bootstrap.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 7 additions & 0 deletions dotfiles/claude/.claude/hooks/is-watched.sh
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions dotfiles/claude/.claude/hooks/notify.sh
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions dotfiles/claude/.claude/hooks/tmux-state.sh
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions dotfiles/tmux/.config/tmux/claude-next.sh
Original file line number Diff line number Diff line change
@@ -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_name> <session:window courant>
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
22 changes: 19 additions & 3 deletions dotfiles/tmux/.config/tmux/tmux.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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'
2 changes: 1 addition & 1 deletion install/40-desktop.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
8 changes: 6 additions & 2 deletions install/50-stow.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
38 changes: 38 additions & 0 deletions install/60-claude.sh
Original file line number Diff line number Diff line change
@@ -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"
Loading