diff --git a/modules/misc/news/2026/02/2026-02-15_12-00-00.nix b/modules/misc/news/2026/02/2026-02-15_12-00-00.nix new file mode 100644 index 000000000000..e14dc8f2d858 --- /dev/null +++ b/modules/misc/news/2026/02/2026-02-15_12-00-00.nix @@ -0,0 +1,12 @@ +{ + time = "2026-02-15T12:00:00+00:00"; + condition = true; + message = '' + + A new module is available: 'programs.peon-ping'. + + Peon-ping is a notification sound player for AI coding agents + (Claude Code, Cursor, Codex, etc.) that plays sound effects when + tasks complete, permissions are needed, or other events occur. + ''; +} diff --git a/modules/programs/peon-ping.nix b/modules/programs/peon-ping.nix new file mode 100644 index 000000000000..f5f34f85b44b --- /dev/null +++ b/modules/programs/peon-ping.nix @@ -0,0 +1,196 @@ +{ + config, + lib, + pkgs, + ... +}: +let + cfg = config.programs.peon-ping; + jsonFormat = pkgs.formats.json { }; + + defaultOgPacksSource = pkgs.fetchFromGitHub { + owner = "PeonPing"; + repo = "og-packs"; + rev = "v1.1.0"; + hash = "sha256-spao/GTIhH4c5HOmVc0umMvrwOaMRa4s5Pem1AWyUOw="; + }; + + hookCommand = "${cfg.package}/bin/peon"; + + hookEntry = event: { + matcher = ""; + hooks = [ + ( + { + type = "command"; + command = hookCommand; + timeout = 10; + } + // lib.optionalAttrs (event != "SessionStart") { async = true; } + ) + ]; + }; + + skillNames = [ + "peon-ping-config" + "peon-ping-toggle" + "peon-ping-use" + ]; + + packFiles = lib.listToAttrs ( + map ( + name: + lib.nameValuePair ".claude/hooks/peon-ping/packs/${name}" { + source = "${cfg.ogPacksSource}/${name}"; + recursive = true; + } + ) cfg.packs + ); + + skillFiles = lib.listToAttrs ( + map ( + name: + lib.nameValuePair ".claude/skills/${name}" { + source = "${cfg.package.src}/skills/${name}"; + recursive = true; + } + ) skillNames + ); + + claudeCodeHooks = lib.listToAttrs ( + map (event: lib.nameValuePair event [ (hookEntry event) ]) cfg.claudeCodeHookEvents + ); +in +{ + meta.maintainers = [ lib.maintainers.workflow ]; + + options.programs.peon-ping = { + enable = lib.mkEnableOption "peon-ping, a notification sound player for AI coding agents"; + + package = lib.mkPackageOption pkgs "peon-ping" { }; + + settings = lib.mkOption { + inherit (jsonFormat) type; + default = { }; + example = { + active_pack = "peon"; + volume = 0.5; + enabled = true; + desktop_notifications = true; + categories = { + "session.start" = true; + "task.complete" = true; + "input.required" = true; + }; + }; + description = '' + Declarative peon-ping configuration written to + {file}`~/.claude/hooks/peon-ping/config.json`. + + When non-empty, the config file is managed by Home Manager as an + immutable symlink. When left empty (the default), a mutable default + config is seeded on first activation so that the `peon` CLI and + Claude Code skills can modify it at runtime. + ''; + }; + + packs = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ "peon" ]; + example = [ + "peon" + "peon_de" + "aoe2" + ]; + description = '' + Sound pack names to install from {option}`ogPacksSource`. + Each name corresponds to a subdirectory in the og-packs repository. + ''; + }; + + ogPacksSource = lib.mkOption { + type = lib.types.package; + default = defaultOgPacksSource; + defaultText = lib.literalExpression '' + pkgs.fetchFromGitHub { + owner = "PeonPing"; + repo = "og-packs"; + rev = "v1.1.0"; + hash = "sha256-spao/GTIhH4c5HOmVc0umMvrwOaMRa4s5Pem1AWyUOw="; + } + ''; + description = '' + Source derivation containing sound packs. Pack names in + {option}`packs` are resolved as subdirectories of this source. + ''; + }; + + enableClaudeCodeIntegration = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Whether to automatically configure Claude Code hooks and skills + for peon-ping integration. + + Requires {option}`programs.claude-code.enable` to be set. + ''; + }; + + claudeCodeHookEvents = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ + "SessionStart" + "SessionEnd" + "UserPromptSubmit" + "Stop" + "Notification" + "PermissionRequest" + ]; + description = '' + Claude Code hook events to register peon-ping for. + Each event fires the `peon` command which reads the event + from stdin and plays the appropriate sound. + ''; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = !cfg.enableClaudeCodeIntegration || config.programs.claude-code.enable; + message = '' + `programs.peon-ping.enableClaudeCodeIntegration` requires + `programs.claude-code.enable` to be set. + ''; + } + ]; + + home.packages = [ cfg.package ]; + + home.file = + packFiles + // lib.optionalAttrs cfg.enableClaudeCodeIntegration skillFiles + // { + ".claude/hooks/peon-ping/config.json" = lib.mkIf (cfg.settings != { }) { + source = jsonFormat.generate "peon-ping-config.json" cfg.settings; + }; + }; + + home.activation.seedPeonPingConfig = lib.mkIf (cfg.settings == { }) ( + lib.hm.dag.entryAfter [ "linkGeneration" ] '' + peonConfigDir="''${CLAUDE_CONFIG_DIR:-$HOME/.claude}/hooks/peon-ping" + peonConfigFile="$peonConfigDir/config.json" + if [ ! -f "$peonConfigFile" ]; then + run mkdir -p "$peonConfigDir" + run cp "${cfg.package}/lib/peon-ping/config.json" "$peonConfigFile" + run chmod u+w "$peonConfigFile" + verboseEcho "Seeded peon-ping default config at $peonConfigFile" + fi + '' + ); + + programs.claude-code.settings = lib.mkIf cfg.enableClaudeCodeIntegration { + hooks = claudeCodeHooks; + }; + }; +} diff --git a/tests/modules/programs/peon-ping/basic.nix b/tests/modules/programs/peon-ping/basic.nix new file mode 100644 index 000000000000..07560b628556 --- /dev/null +++ b/tests/modules/programs/peon-ping/basic.nix @@ -0,0 +1,13 @@ +{ + programs.peon-ping = { + enable = true; + packs = [ ]; + }; + + test.stubs.peon-ping = { }; + + nmt.script = '' + assertPathNotExists home-files/.claude/hooks/peon-ping/config.json + assertPathNotExists home-files/.claude/settings.json + ''; +} diff --git a/tests/modules/programs/peon-ping/claude-code-integration.nix b/tests/modules/programs/peon-ping/claude-code-integration.nix new file mode 100644 index 000000000000..a5a6a771d6a4 --- /dev/null +++ b/tests/modules/programs/peon-ping/claude-code-integration.nix @@ -0,0 +1,34 @@ +{ config, ... }: +{ + programs.claude-code.enable = true; + + programs.peon-ping = { + enable = true; + enableClaudeCodeIntegration = true; + packs = [ ]; + }; + + test.stubs.peon-ping = { + extraAttrs.src = config.lib.test.mkStubPackage { + name = "peon-ping-src"; + buildScript = '' + mkdir -p $out/skills/peon-ping-config + mkdir -p $out/skills/peon-ping-toggle + mkdir -p $out/skills/peon-ping-use + echo "# Config skill" > $out/skills/peon-ping-config/SKILL.md + echo "# Toggle skill" > $out/skills/peon-ping-toggle/SKILL.md + echo "# Use skill" > $out/skills/peon-ping-use/SKILL.md + ''; + }; + }; + + nmt.script = '' + assertFileExists home-files/.claude/settings.json + assertFileContent home-files/.claude/settings.json \ + ${./expected-settings.json} + + assertFileExists home-files/.claude/skills/peon-ping-config/SKILL.md + assertFileExists home-files/.claude/skills/peon-ping-toggle/SKILL.md + assertFileExists home-files/.claude/skills/peon-ping-use/SKILL.md + ''; +} diff --git a/tests/modules/programs/peon-ping/default.nix b/tests/modules/programs/peon-ping/default.nix new file mode 100644 index 000000000000..4b168d4c216a --- /dev/null +++ b/tests/modules/programs/peon-ping/default.nix @@ -0,0 +1,6 @@ +{ + peon-ping-basic = ./basic.nix; + peon-ping-settings = ./settings.nix; + peon-ping-packs = ./packs.nix; + peon-ping-claude-code-integration = ./claude-code-integration.nix; +} diff --git a/tests/modules/programs/peon-ping/expected-config.json b/tests/modules/programs/peon-ping/expected-config.json new file mode 100644 index 000000000000..0419b9cf15cb --- /dev/null +++ b/tests/modules/programs/peon-ping/expected-config.json @@ -0,0 +1,11 @@ +{ + "active_pack": "glados", + "categories": { + "input.required": false, + "session.start": true, + "task.complete": true + }, + "desktop_notifications": false, + "enabled": true, + "volume": 0.8 +} diff --git a/tests/modules/programs/peon-ping/expected-settings.json b/tests/modules/programs/peon-ping/expected-settings.json new file mode 100644 index 000000000000..3f02bfde0ed7 --- /dev/null +++ b/tests/modules/programs/peon-ping/expected-settings.json @@ -0,0 +1,82 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "hooks": { + "Notification": [ + { + "hooks": [ + { + "async": true, + "command": "@peon-ping@/bin/peon", + "timeout": 10, + "type": "command" + } + ], + "matcher": "" + } + ], + "PermissionRequest": [ + { + "hooks": [ + { + "async": true, + "command": "@peon-ping@/bin/peon", + "timeout": 10, + "type": "command" + } + ], + "matcher": "" + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "async": true, + "command": "@peon-ping@/bin/peon", + "timeout": 10, + "type": "command" + } + ], + "matcher": "" + } + ], + "SessionStart": [ + { + "hooks": [ + { + "command": "@peon-ping@/bin/peon", + "timeout": 10, + "type": "command" + } + ], + "matcher": "" + } + ], + "Stop": [ + { + "hooks": [ + { + "async": true, + "command": "@peon-ping@/bin/peon", + "timeout": 10, + "type": "command" + } + ], + "matcher": "" + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "async": true, + "command": "@peon-ping@/bin/peon", + "timeout": 10, + "type": "command" + } + ], + "matcher": "" + } + ] + } +} diff --git a/tests/modules/programs/peon-ping/packs.nix b/tests/modules/programs/peon-ping/packs.nix new file mode 100644 index 000000000000..695cd4293d30 --- /dev/null +++ b/tests/modules/programs/peon-ping/packs.nix @@ -0,0 +1,25 @@ +{ config, ... }: +{ + programs.peon-ping = { + enable = true; + packs = [ + "peon" + "glados" + ]; + ogPacksSource = config.lib.test.mkStubPackage { + name = "test-og-packs"; + buildScript = '' + mkdir -p $out/peon/sounds $out/glados/sounds + echo '{}' > $out/peon/openpeon.json + echo '{}' > $out/glados/openpeon.json + ''; + }; + }; + + test.stubs.peon-ping = { }; + + nmt.script = '' + assertDirectoryExists home-files/.claude/hooks/peon-ping/packs/peon + assertDirectoryExists home-files/.claude/hooks/peon-ping/packs/glados + ''; +} diff --git a/tests/modules/programs/peon-ping/settings.nix b/tests/modules/programs/peon-ping/settings.nix new file mode 100644 index 000000000000..9f22c785cead --- /dev/null +++ b/tests/modules/programs/peon-ping/settings.nix @@ -0,0 +1,25 @@ +{ + programs.peon-ping = { + enable = true; + packs = [ ]; + settings = { + active_pack = "glados"; + volume = 0.8; + enabled = true; + desktop_notifications = false; + categories = { + "session.start" = true; + "task.complete" = true; + "input.required" = false; + }; + }; + }; + + test.stubs.peon-ping = { }; + + nmt.script = '' + assertFileExists home-files/.claude/hooks/peon-ping/config.json + assertFileContent home-files/.claude/hooks/peon-ping/config.json \ + ${./expected-config.json} + ''; +}