Skip to content
Draft
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
12 changes: 12 additions & 0 deletions modules/misc/news/2026/02/2026-02-15_12-00-00.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
time = "2026-02-15T12:00:00+00:00";
condition = true;
message = ''
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why start the entry with an empty line?

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.
'';
}
196 changes: 196 additions & 0 deletions modules/programs/peon-ping.nix
Original file line number Diff line number Diff line change
@@ -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=";
};

Comment on lines +11 to +17
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't want to manage packages in Home Manager. Can either remove altogether or add it to Nixpkgs and refer to it there.

hookCommand = "${cfg.package}/bin/peon";

hookEntry = event: {
matcher = "";
hooks = [
(
{
type = "command";
command = hookCommand;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hookCommand variable seems unnecessary, it only seems to be used here.

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hookEntry function is only used here but defined further up. Could put it in a let binding inside this definition instead.

);
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"
Comment on lines +181 to +182
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to unset these variables at the end of the code block

if [ ! -f "$peonConfigFile" ]; then
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if [ ! -f "$peonConfigFile" ]; then
if [[ ! -f $peonConfigFile ]]; then

run mkdir -p "$peonConfigDir"
run cp "${cfg.package}/lib/peon-ping/config.json" "$peonConfigFile"
run chmod u+w "$peonConfigFile"
Comment on lines +184 to +186
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can use the VERBOSE_ARG variable to add --verbose on verbose runs:

Suggested change
run mkdir -p "$peonConfigDir"
run cp "${cfg.package}/lib/peon-ping/config.json" "$peonConfigFile"
run chmod u+w "$peonConfigFile"
run mkdir $VERBOSE_ARG -p "$peonConfigDir"
run cp $VERBOSE_ARG "${cfg.package}/lib/peon-ping/config.json" "$peonConfigFile"
run chmod $VERBOSE_ARG u+w "$peonConfigFile"

verboseEcho "Seeded peon-ping default config at $peonConfigFile"
fi
''
);

programs.claude-code.settings = lib.mkIf cfg.enableClaudeCodeIntegration {
hooks = claudeCodeHooks;
};
};
}
13 changes: 13 additions & 0 deletions tests/modules/programs/peon-ping/basic.nix
Original file line number Diff line number Diff line change
@@ -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
'';
}
34 changes: 34 additions & 0 deletions tests/modules/programs/peon-ping/claude-code-integration.nix
Original file line number Diff line number Diff line change
@@ -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
'';
}
6 changes: 6 additions & 0 deletions tests/modules/programs/peon-ping/default.nix
Original file line number Diff line number Diff line change
@@ -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;
}
11 changes: 11 additions & 0 deletions tests/modules/programs/peon-ping/expected-config.json
Original file line number Diff line number Diff line change
@@ -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
}
82 changes: 82 additions & 0 deletions tests/modules/programs/peon-ping/expected-settings.json
Original file line number Diff line number Diff line change
@@ -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": ""
}
]
}
}
25 changes: 25 additions & 0 deletions tests/modules/programs/peon-ping/packs.nix
Original file line number Diff line number Diff line change
@@ -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
'';
}
Loading