diff --git a/config/claude/notify.sh b/config/claude/notify.sh index 15415d5fe..70a4f41b1 100644 --- a/config/claude/notify.sh +++ b/config/claude/notify.sh @@ -1,8 +1,7 @@ #!/usr/bin/env bash -# Claude Code Local Notification Script -# Sends macOS notifications via osascript -# Skips if Pushover is configured (to avoid duplicate notifications) +# Claude Code local notification script. +# Uses the shared local notifier and skips local popups when Pushover is configured. # Source credentials to check if Pushover is configured if [ -z "$PUSHOVER_API_TOKEN" ] || [ -z "$PUSHOVER_USER_KEY" ]; then @@ -22,14 +21,22 @@ fi # Read JSON input from stdin input=$(cat) -# Function to send local macOS notification +# Function to send a local notification on the active platform notify() { local message="$1" local sound="${2:-Sonar}" + local notifier [ -z "$message" ] && return - osascript -e "display notification \"${message}\" with title \"Claude Code\" sound name \"${sound}\"" 2>/dev/null + notifier="$(command -v notify-local 2>/dev/null || true)" + if [ -z "$notifier" ] && [ -x "$HOME/.local/scripts/notify-local" ]; then + notifier="$HOME/.local/scripts/notify-local" + fi + + if [ -n "$notifier" ]; then + "$notifier" "Claude Code" "$message" "$sound" + fi } # Handle Notification hook diff --git a/config/codex/config.toml b/config/codex/config.toml index 3cd761845..318b6e5da 100644 --- a/config/codex/config.toml +++ b/config/codex/config.toml @@ -1,7 +1,7 @@ notify = [ "bash", "-lc", - "JSON=\"$1\"; LAST_MESSAGE=$(echo \"$JSON\" | jq -r '.\"last-assistant-message\" // \"Codex task completed\"'); osascript -e \"display notification \\\"$LAST_MESSAGE\\\" with title \\\"Codex\\\"\"", + "JSON=\"$1\"; LAST_MESSAGE=$(echo \"$JSON\" | jq -r '.\"last-assistant-message\" // \"Codex task completed\"'); \"$HOME/.local/scripts/notify-local\" \"Codex\" \"$LAST_MESSAGE\"", "notify", ] diff --git a/config/codex/config.tpl.toml b/config/codex/config.tpl.toml index ff5f83b0a..386866760 100644 --- a/config/codex/config.tpl.toml +++ b/config/codex/config.tpl.toml @@ -1,7 +1,7 @@ notify = [ "bash", "-lc", - "JSON=\"$1\"; LAST_MESSAGE=$(echo \"$JSON\" | jq -r '.\"last-assistant-message\" // \"Codex task completed\"'); osascript -e \"display notification \\\"$LAST_MESSAGE\\\" with title \\\"Codex\\\"\"", + "JSON=\"$1\"; LAST_MESSAGE=$(echo \"$JSON\" | jq -r '.\"last-assistant-message\" // \"Codex task completed\"'); \"$HOME/.local/scripts/notify-local\" \"Codex\" \"$LAST_MESSAGE\"", "notify", ] diff --git a/config/ghostty/config b/config/ghostty/config index e2c0c1e30..a74eb29b4 100644 --- a/config/ghostty/config +++ b/config/ghostty/config @@ -3,8 +3,7 @@ # auto-update-channel = tip -# theme = dark:Dracula,light:Catppuccin Latte -theme = Dracula +theme = dark:Dracula Custom,light:Catppuccin Latte Custom background-opacity = 0.8 confirm-close-surface = true macos-option-as-alt = true @@ -47,9 +46,5 @@ keybind = ctrl+shift+z=toggle_split_zoom keybind = ctrl+shift+e=equalize_splits keybind = ctrl+shift+w=close_surface -# Split border styling -split-divider-color = #88c0d0 - # Active panel highlighting (dim unfocused splits) unfocused-split-opacity = 0.7 -unfocused-split-fill = #1e1e2e diff --git a/config/ghostty/default.nix b/config/ghostty/default.nix index b8d9bb528..56498a512 100644 --- a/config/ghostty/default.nix +++ b/config/ghostty/default.nix @@ -8,4 +8,10 @@ in xdg.configFile."ghostty/config" = { text = configText; }; + xdg.configFile."ghostty/themes/Dracula Custom" = { + source = ./themes + "/Dracula Custom"; + }; + xdg.configFile."ghostty/themes/Catppuccin Latte Custom" = { + source = ./themes + "/Catppuccin Latte Custom"; + }; } diff --git a/config/ghostty/themes/Catppuccin Latte Custom b/config/ghostty/themes/Catppuccin Latte Custom new file mode 100644 index 000000000..ee523dff0 --- /dev/null +++ b/config/ghostty/themes/Catppuccin Latte Custom @@ -0,0 +1,24 @@ +palette = 0=#5c5f77 +palette = 1=#d20f39 +palette = 2=#40a02b +palette = 3=#df8e1d +palette = 4=#1e66f5 +palette = 5=#ea76cb +palette = 6=#179299 +palette = 7=#acb0be +palette = 8=#6c6f85 +palette = 9=#de293e +palette = 10=#49af3d +palette = 11=#eea02d +palette = 12=#456eff +palette = 13=#fe85d8 +palette = 14=#2d9fa8 +palette = 15=#bcc0cc +background = #eff1f5 +foreground = #4c4f69 +cursor-color = #dc8a78 +cursor-text = #eff1f5 +selection-background = #acb0be +selection-foreground = #4c4f69 +unfocused-split-fill = #dce0e8 +split-divider-color = #7287fd diff --git a/config/ghostty/themes/Dracula Custom b/config/ghostty/themes/Dracula Custom new file mode 100644 index 000000000..0e059b26d --- /dev/null +++ b/config/ghostty/themes/Dracula Custom @@ -0,0 +1,24 @@ +palette = 0=#21222c +palette = 1=#ff5555 +palette = 2=#50fa7b +palette = 3=#f1fa8c +palette = 4=#bd93f9 +palette = 5=#ff79c6 +palette = 6=#8be9fd +palette = 7=#f8f8f2 +palette = 8=#6272a4 +palette = 9=#ff6e6e +palette = 10=#69ff94 +palette = 11=#ffffa5 +palette = 12=#d6acff +palette = 13=#ff92df +palette = 14=#a4ffff +palette = 15=#ffffff +background = #282a36 +foreground = #f8f8f2 +cursor-color = #f8f8f2 +cursor-text = #282a36 +selection-background = #44475a +selection-foreground = #ffffff +unfocused-split-fill = #1e1e2e +split-divider-color = #88c0d0 diff --git a/config/gtk/default.nix b/config/gtk/default.nix index e0aac758a..0f25b8e6c 100644 --- a/config/gtk/default.nix +++ b/config/gtk/default.nix @@ -8,7 +8,7 @@ gtk = lib.mkIf pkgs.stdenv.isLinux { enable = true; theme = { - name = "Adwaita-dark"; + name = "Adwaita"; package = pkgs.gnome-themes-extra; }; iconTheme = { @@ -19,17 +19,5 @@ name = "Adwaita"; size = 24; }; - gtk3.extraConfig = { - gtk-application-prefer-dark-theme = 1; - }; - gtk4.extraConfig = { - gtk-application-prefer-dark-theme = 1; - }; - }; - - dconf.settings = lib.mkIf pkgs.stdenv.isLinux { - "org/gnome/desktop/interface" = { - color-scheme = "prefer-dark"; - }; }; } diff --git a/config/hyprland/hyprland.conf b/config/hyprland/hyprland.conf index f97b22f23..8fe606529 100644 --- a/config/hyprland/hyprland.conf +++ b/config/hyprland/hyprland.conf @@ -25,7 +25,6 @@ env = XCURSOR_THEME,Adwaita env = XCURSOR_SIZE,24 env = HYPRCURSOR_SIZE,24 env = HYPRCURSOR_THEME,Adwaita -env = GTK_THEME,Adwaita:dark env = GDK_SCALE,1.5 env = QT_AUTO_SCREEN_SCALE_FACTOR,1 env = QT_QPA_PLATFORM,wayland diff --git a/home-manager/modules/default.nix b/home-manager/modules/default.nix index f80fe58a4..9ee58df2e 100644 --- a/home-manager/modules/default.nix +++ b/home-manager/modules/default.nix @@ -3,6 +3,7 @@ ./cargo-globals ./openclaw ./local-binaries + ./local-scripts ./npm-globals ./tailscale ./uv-globals diff --git a/home-manager/modules/local-scripts/clipboard-copy.sh b/home-manager/modules/local-scripts/clipboard-copy.sh new file mode 100755 index 000000000..bf230b52a --- /dev/null +++ b/home-manager/modules/local-scripts/clipboard-copy.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if command -v pbcopy >/dev/null 2>&1; then + exec pbcopy +fi + +if [[ -n ${WAYLAND_DISPLAY:-} ]] && command -v wl-copy >/dev/null 2>&1; then + exec wl-copy +fi + +if command -v xclip >/dev/null 2>&1; then + exec xclip -selection clipboard +fi + +if command -v xsel >/dev/null 2>&1; then + exec xsel --clipboard --input +fi + +printf 'No clipboard backend available\n' >&2 +exit 1 diff --git a/home-manager/modules/local-scripts/default.nix b/home-manager/modules/local-scripts/default.nix new file mode 100644 index 000000000..59b621648 --- /dev/null +++ b/home-manager/modules/local-scripts/default.nix @@ -0,0 +1,13 @@ +{ ... }: +{ + home.file.".local/scripts/clipboard-copy" = { + executable = true; + force = true; + source = ./clipboard-copy.sh; + }; + home.file.".local/scripts/notify-local" = { + executable = true; + force = true; + source = ./notify-local.sh; + }; +} diff --git a/home-manager/modules/local-scripts/notify-local.sh b/home-manager/modules/local-scripts/notify-local.sh new file mode 100755 index 000000000..babc65053 --- /dev/null +++ b/home-manager/modules/local-scripts/notify-local.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash + +set -euo pipefail + +title="${1:-Notification}" +message="${2:-}" +sound="${3:-}" + +if [[ -z $message ]]; then + exit 0 +fi + +title="${title//$'\n'/ }" +message="${message//$'\n'/ }" +sound="${sound//$'\n'/ }" + +escape_applescript() { + local value="$1" + + value="${value//\\/\\\\}" + value="${value//\"/\\\"}" + + printf '%s' "$value" +} + +if command -v osascript >/dev/null 2>&1; then + escaped_title="$(escape_applescript "$title")" + escaped_message="$(escape_applescript "$message")" + + if [[ -n $sound ]]; then + escaped_sound="$(escape_applescript "$sound")" + osascript -e "display notification \"$escaped_message\" with title \"$escaped_title\" sound name \"$escaped_sound\"" >/dev/null 2>&1 || true + else + osascript -e "display notification \"$escaped_message\" with title \"$escaped_title\"" >/dev/null 2>&1 || true + fi + + exit 0 +fi + +if command -v notify-send >/dev/null 2>&1; then + notify-send "$title" "$message" >/dev/null 2>&1 || true + exit 0 +fi + +if command -v terminal-notifier >/dev/null 2>&1; then + terminal-notifier -title "$title" -message "$message" >/dev/null 2>&1 || true + exit 0 +fi + +exit 0 diff --git a/home-manager/programs/bash/default.nix b/home-manager/programs/bash/default.nix index 8c9dbf240..29ece8299 100644 --- a/home-manager/programs/bash/default.nix +++ b/home-manager/programs/bash/default.nix @@ -63,6 +63,7 @@ export PATH="$HOME/.nix-profile/bin:$PATH" export PATH="$HOME/.cargo/bin:$PATH" export PATH="$HOME/.local/bin:$PATH" + export PATH="$HOME/.local/scripts:$PATH" export PATH="$HOME/.bun/bin:$PATH" export PATH="/opt/homebrew/opt/postgresql@18/bin:$PATH" export PATH="/opt/homebrew/bin:$PATH" diff --git a/home-manager/programs/fish/default.nix b/home-manager/programs/fish/default.nix index f9037a491..966588760 100644 --- a/home-manager/programs/fish/default.nix +++ b/home-manager/programs/fish/default.nix @@ -39,6 +39,7 @@ fish_add_path -p -m ~/go/bin fish_add_path -p -m ~/.cargo/bin fish_add_path -p -m ~/.local/bin + fish_add_path -p -m ~/.local/scripts fish_add_path -p -m ~/.bun/bin fish_add_path -p -m /opt/homebrew/opt/postgresql@18/bin fish_add_path -p -m /opt/homebrew/bin @@ -60,6 +61,7 @@ fish_add_path -p -m ~/go/bin fish_add_path -p -m ~/.cargo/bin fish_add_path -p -m ~/.local/bin + fish_add_path -p -m ~/.local/scripts fish_add_path -p -m ~/.bun/bin fish_add_path -p -m /opt/homebrew/opt/postgresql@18/bin fish_add_path -p -m /opt/homebrew/bin diff --git a/home-manager/programs/tmux/tmux.conf b/home-manager/programs/tmux/tmux.conf index ce9cdbb11..0afbac186 100644 --- a/home-manager/programs/tmux/tmux.conf +++ b/home-manager/programs/tmux/tmux.conf @@ -37,7 +37,7 @@ set-option -g repeat-time 1000 # Clipboard set -g set-clipboard on -set -g @clipboard_cmd "command -v pbcopy >/dev/null 2>&1 && pbcopy || { [ -n \"$WAYLAND_DISPLAY\" ] && wl-copy || xclip -selection clipboard; }" +set -g @clipboard_cmd "~/.local/scripts/clipboard-copy" # Status bar set -g status "on" diff --git a/home-manager/programs/zsh/default.nix b/home-manager/programs/zsh/default.nix index 9ad99ea8a..d72458685 100644 --- a/home-manager/programs/zsh/default.nix +++ b/home-manager/programs/zsh/default.nix @@ -56,6 +56,7 @@ export PATH="$HOME/.nix-profile/bin:$PATH" export PATH="$HOME/.cargo/bin:$PATH" export PATH="$HOME/.local/bin:$PATH" + export PATH="$HOME/.local/scripts:$PATH" export PATH="$HOME/.bun/bin:$PATH" export PATH="/opt/homebrew/opt/postgresql@18/bin:$PATH" export PATH="/opt/homebrew/bin:$PATH" diff --git a/home-manager/services/darkman/dark-mode.sh b/home-manager/services/darkman/dark-mode.sh new file mode 100644 index 000000000..dc7ccbb08 --- /dev/null +++ b/home-manager/services/darkman/dark-mode.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +@dconf@ write /org/gnome/desktop/interface/color-scheme "'prefer-dark'" +@dconf@ write /org/gnome/desktop/interface/gtk-theme "'Adwaita-dark'" diff --git a/home-manager/services/darkman/default.nix b/home-manager/services/darkman/default.nix new file mode 100644 index 000000000..9aa401d6e --- /dev/null +++ b/home-manager/services/darkman/default.nix @@ -0,0 +1,20 @@ +{ pkgs, ... }: +let + inherit (pkgs) lib; + dconf = "${pkgs.dconf}/bin/dconf"; + readScript = file: builtins.replaceStrings [ "@dconf@" ] [ dconf ] (builtins.readFile file); +in +{ + services.darkman = lib.mkIf pkgs.stdenv.isLinux { + enable = true; + settings = { + usegeoclue = true; + }; + darkModeScripts = { + gtk-theme = readScript ./dark-mode.sh; + }; + lightModeScripts = { + gtk-theme = readScript ./light-mode.sh; + }; + }; +} diff --git a/home-manager/services/darkman/light-mode.sh b/home-manager/services/darkman/light-mode.sh new file mode 100644 index 000000000..5f4ce7f1e --- /dev/null +++ b/home-manager/services/darkman/light-mode.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +@dconf@ write /org/gnome/desktop/interface/color-scheme "'prefer-light'" +@dconf@ write /org/gnome/desktop/interface/gtk-theme "'Adwaita'" diff --git a/home-manager/services/default.nix b/home-manager/services/default.nix index d86314376..e6b6d7223 100644 --- a/home-manager/services/default.nix +++ b/home-manager/services/default.nix @@ -8,6 +8,7 @@ let brewUpgrader = import ./brew-upgrader { inherit pkgs; }; cliproxyapi = import ./cliproxyapi; codeSyncer = import ./code-syncer { inherit pkgs; }; + darkman = import ./darkman { inherit pkgs; }; docker = import ./docker { inherit lib pkgs; }; dockerPostgres = import ./docker-postgres { inherit pkgs; }; dotfilesUpdater = import ./dotfiles-updater { inherit pkgs; }; @@ -23,6 +24,7 @@ in brewUpgrader cliproxyapi codeSyncer + darkman docker dockerPostgres dotfilesUpdater diff --git a/named-hosts/matic/default.nix b/named-hosts/matic/default.nix index 00eec5d90..9e3f3fdf1 100644 --- a/named-hosts/matic/default.nix +++ b/named-hosts/matic/default.nix @@ -315,7 +315,10 @@ inputs.nixpkgs.lib.nixosSystem { "hyprland" "gtk" ]; - "org.freedesktop.impl.portal.Settings" = [ "gtk" ]; + "org.freedesktop.impl.portal.Settings" = [ + "darkman" + "gtk" + ]; }; }; }; diff --git a/spec/clipboard_copy_spec.sh b/spec/clipboard_copy_spec.sh new file mode 100644 index 000000000..1f010415d --- /dev/null +++ b/spec/clipboard_copy_spec.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2329,SC2016 + +Describe 'clipboard-copy.sh' +SCRIPT="$PWD/home-manager/modules/local-scripts/clipboard-copy.sh" + +Describe 'when pbcopy is available' +setup() { + mock_bin_setup pbcopy +} +cleanup() { + mock_bin_cleanup +} +Before 'setup' +After 'cleanup' + +It 'uses pbcopy' +When run bash "$SCRIPT" /dev/null 2>&1; then + local bash_dir cat_dir printf_dir + bash_dir="$(dirname "$(command -v bash)")" + cat_dir="$(dirname "$(command -v cat)")" + printf_dir="$(dirname "$(command -v printf)")" + export PATH="$MOCK_BIN:$bash_dir:$cat_dir:$printf_dir" + fi +} +cleanup() { + mock_bin_cleanup +} +Before 'setup' +After 'cleanup' + +It 'uses notify-send' +When run bash -c 'bash '"$SCRIPT"' "Title" "Message"; cat "$MOCK_LOG"' +The status should be success +The output should include 'notify-send' +The output should include 'Title' +The output should include 'Message' +End +End + +Describe 'with terminal-notifier available (no osascript or notify-send)' +setup() { + mock_bin_setup terminal-notifier + # Hide osascript so terminal-notifier path is taken + if command -v osascript >/dev/null 2>&1; then + local bash_dir cat_dir printf_dir + bash_dir="$(dirname "$(command -v bash)")" + cat_dir="$(dirname "$(command -v cat)")" + printf_dir="$(dirname "$(command -v printf)")" + export PATH="$MOCK_BIN:$bash_dir:$cat_dir:$printf_dir" + fi +} +cleanup() { + mock_bin_cleanup +} +Before 'setup' +After 'cleanup' + +It 'uses terminal-notifier' +When run bash -c 'bash '"$SCRIPT"' "Title" "Message"; cat "$MOCK_LOG"' +The status should be success +The output should include 'terminal-notifier' +The output should include 'Title' +The output should include 'Message' +End +End + +Describe 'with no notification backend' +setup() { + MOCK_BIN="$(mktemp -d)" + MOCK_ORIGINAL_PATH="${PATH:-}" + local bash_dir + bash_dir="$(dirname "$(command -v bash)")" + export PATH="$MOCK_BIN:$bash_dir" + export MOCK_BIN MOCK_ORIGINAL_PATH +} +cleanup() { + export PATH="$MOCK_ORIGINAL_PATH" + rm -rf "$MOCK_BIN" + unset MOCK_BIN MOCK_ORIGINAL_PATH +} +Before 'setup' +After 'cleanup' + +It 'exits 0 silently' +When run bash "$SCRIPT" "Title" "Message" +The status should be success +End +End +End diff --git a/spec/notify_spec.sh b/spec/notify_spec.sh index bb3a66e96..c773edd22 100644 --- a/spec/notify_spec.sh +++ b/spec/notify_spec.sh @@ -20,7 +20,7 @@ End Describe 'Pushover configured with .env present' setup() { - mock_bin_setup osascript + mock_bin_setup notify-local TEMP_HOME=$(mktemp -d) mkdir -p "$TEMP_HOME/dotfiles" cat >"$TEMP_HOME/dotfiles/.env" <<'ENV' @@ -45,7 +45,7 @@ End Describe 'Notification hook (no Pushover)' setup() { - mock_bin_setup osascript + mock_bin_setup notify-local # Unset Pushover credentials to ensure clean test environment unset PUSHOVER_API_TOKEN @@ -73,13 +73,13 @@ It 'sends Basso notification for permission request' When run bash -c 'echo "{\"message\": \"Claude needs your permission to use Bash\"}" | env HOME=/nonexistent bash '"$SCRIPT"'; cat "$MOCK_LOG"' The status should be success The output should include 'Bash permission required' -The output should include 'sound name "Basso"' +The output should include 'Basso' End End Describe 'when credentials come from HOME/dotfiles/.env' setup() { - mock_bin_setup osascript + mock_bin_setup notify-local TEMP_HOME=$(mktemp -d) mkdir -p "$TEMP_HOME/dotfiles" cat >"$TEMP_HOME/dotfiles/.env" <<'ENV' @@ -106,7 +106,7 @@ End Describe 'credential sourcing edge cases' setup() { - mock_bin_setup osascript + mock_bin_setup notify-local TEMP_HOME=$(mktemp -d) mkdir -p "$TEMP_HOME/dotfiles" @@ -135,7 +135,8 @@ PUSHOVER_API_TOKEN=only_token ENV When run bash -c 'echo "{\"message\": \"Hello\"}" | env HOME="'"$TEMP_HOME"'" bash '"$SCRIPT"'; cat "$MOCK_LOG"' The status should be success -The output should include 'display notification' +The output should include 'Claude Code' +The output should include 'Hello' End It 'ignores .env stderr and still sends local notification on source error' @@ -144,13 +145,14 @@ this is not valid bash ENV When run bash -c 'echo "{\"message\": \"Hello\"}" | env HOME="'"$TEMP_HOME"'" bash '"$SCRIPT"'; cat "$MOCK_LOG"' The status should be success -The output should include 'display notification' +The output should include 'Claude Code' +The output should include 'Hello' End End Describe 'SessionEnd hook (no Pushover)' setup() { - mock_bin_setup osascript + mock_bin_setup notify-local # Unset Pushover credentials to ensure clean test environment unset PUSHOVER_API_TOKEN @@ -171,7 +173,7 @@ End Describe 'other hooks (no Pushover)' setup() { - mock_bin_setup osascript + mock_bin_setup notify-local TEMP_HOME=$(mktemp -d) unset PUSHOVER_API_TOKEN @@ -206,7 +208,7 @@ It 'warns on risky PreToolUse Bash command' When run bash -c 'echo "{\"tool\": {\"name\": \"Bash\", \"input\": \"rm -rf /\"}}" | env HOME=/nonexistent bash '"$SCRIPT"'; cat "$MOCK_LOG"' The status should be success The output should include 'Risky: rm -rf /' -The output should include 'sound name "Basso"' +The output should include 'Basso' End It 'does not warn on PreToolUse non-Bash tool'