Skip to content

Commit d2bca06

Browse files
garyshengclaude
andcommitted
Add peon --pack CLI command with autocomplete and cycling
- `peon --packs` lists available packs, marks active with * - `peon --pack <name>` switches to a specific pack with validation - `peon --pack` (no arg) cycles to the next pack alphabetically - Tab completion for `peon --pack <TAB>` via completions.bash - install.sh sources completions in .zshrc/.bashrc - Updated --help output and README documentation - 14 new bats tests covering all pack switching scenarios Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 946435a commit d2bca06

8 files changed

Lines changed: 294 additions & 7 deletions

File tree

README.md

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,16 @@ Need to mute sounds and notifications during a meeting or pairing session? Two o
4141
Other CLI commands:
4242

4343
```bash
44-
peon --pause # Mute sounds
45-
peon --resume # Unmute sounds
46-
peon --status # Check if paused or active
44+
peon --pause # Mute sounds
45+
peon --resume # Unmute sounds
46+
peon --status # Check if paused or active
47+
peon --packs # List available sound packs
48+
peon --pack <name> # Switch to a specific pack
49+
peon --pack # Cycle to the next pack
4750
```
4851

52+
Tab completion is supported — type `peon --pack <TAB>` to see available pack names.
53+
4954
Pausing mutes sounds and desktop notifications instantly. Persists across sessions until you resume. Tab titles remain active when paused.
5055

5156
## Configuration
@@ -83,7 +88,15 @@ Edit `~/.claude/hooks/peon-ping/config.json`:
8388
| `sc_battlecruiser` | Battlecruiser (StarCraft) | "Battlecruiser operational", "Make it happen", "Engage" | [@garysheng](https://github.com/garysheng) |
8489
| `sc_kerrigan` | Sarah Kerrigan (StarCraft) | "I gotcha", "What now?", "Easily amused, huh?" | [@garysheng](https://github.com/garysheng) |
8590

86-
Switch packs in `~/.claude/hooks/peon-ping/config.json`:
91+
Switch packs from the CLI:
92+
93+
```bash
94+
peon --pack ra2_soviet_engineer # switch to a specific pack
95+
peon --pack # cycle to the next pack
96+
peon --packs # list all packs
97+
```
98+
99+
Or edit `~/.claude/hooks/peon-ping/config.json` directly:
87100

88101
```json
89102
{ "active_pack": "ra2_soviet_engineer" }

completions.bash

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
#!/bin/bash
2+
# peon-ping tab completion for bash and zsh
3+
4+
_peon_completions() {
5+
local cur prev opts packs_dir
6+
COMPREPLY=()
7+
cur="${COMP_WORDS[COMP_CWORD]}"
8+
prev="${COMP_WORDS[COMP_CWORD-1]}"
9+
10+
# Top-level options
11+
opts="--pause --resume --toggle --status --packs --pack --help"
12+
13+
if [ "$prev" = "--pack" ]; then
14+
# Complete pack names by scanning manifest files
15+
packs_dir="${CLAUDE_PEON_DIR:-$HOME/.claude/hooks/peon-ping}/packs"
16+
if [ -d "$packs_dir" ]; then
17+
local names
18+
names=$(find "$packs_dir" -maxdepth 2 -name manifest.json -exec dirname {} \; 2>/dev/null | xargs -I{} basename {} | sort)
19+
COMPREPLY=( $(compgen -W "$names" -- "$cur") )
20+
fi
21+
return 0
22+
fi
23+
24+
COMPREPLY=( $(compgen -W "$opts" -- "$cur") )
25+
return 0
26+
}
27+
28+
complete -F _peon_completions peon
29+
30+
# zsh compatibility: if running under zsh, enable bashcompinit
31+
if [ -n "$ZSH_VERSION" ]; then
32+
autoload -Uz bashcompinit 2>/dev/null && bashcompinit
33+
complete -F _peon_completions peon
34+
fi

config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,4 @@
1313
},
1414
"annoyed_threshold": 3,
1515
"annoyed_window_seconds": 10
16-
}
16+
}

install.sh

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ if [ -n "$SCRIPT_DIR" ]; then
6565
# Local clone — copy files directly (including sounds)
6666
cp -r "$SCRIPT_DIR/packs/"* "$INSTALL_DIR/packs/"
6767
cp "$SCRIPT_DIR/peon.sh" "$INSTALL_DIR/"
68+
cp "$SCRIPT_DIR/completions.bash" "$INSTALL_DIR/"
6869
cp "$SCRIPT_DIR/VERSION" "$INSTALL_DIR/"
6970
if [ "$UPDATING" = false ]; then
7071
cp "$SCRIPT_DIR/config.json" "$INSTALL_DIR/"
@@ -73,6 +74,7 @@ else
7374
# curl|bash — download from GitHub (sounds are version-controlled in repo)
7475
echo "Downloading from GitHub..."
7576
curl -fsSL "$REPO_BASE/peon.sh" -o "$INSTALL_DIR/peon.sh"
77+
curl -fsSL "$REPO_BASE/completions.bash" -o "$INSTALL_DIR/completions.bash"
7678
curl -fsSL "$REPO_BASE/VERSION" -o "$INSTALL_DIR/VERSION"
7779
curl -fsSL "$REPO_BASE/uninstall.sh" -o "$INSTALL_DIR/uninstall.sh"
7880
for pack in $PACKS; do
@@ -125,6 +127,15 @@ for rcfile in "$HOME/.zshrc" "$HOME/.bashrc"; do
125127
fi
126128
done
127129

130+
# --- Add tab completion ---
131+
COMPLETION_LINE='[ -f ~/.claude/hooks/peon-ping/completions.bash ] && source ~/.claude/hooks/peon-ping/completions.bash'
132+
for rcfile in "$HOME/.zshrc" "$HOME/.bashrc"; do
133+
if [ -f "$rcfile" ] && ! grep -qF 'peon-ping/completions.bash' "$rcfile"; then
134+
echo "$COMPLETION_LINE" >> "$rcfile"
135+
echo "Added tab completion to $(basename "$rcfile")"
136+
fi
137+
done
138+
128139
# --- Verify sounds are installed ---
129140
echo ""
130141
for pack in $PACKS; do

peon.sh

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,100 @@ case "${1:-}" in
1919
--status)
2020
[ -f "$PAUSED_FILE" ] && echo "peon-ping: paused" || echo "peon-ping: active"
2121
exit 0 ;;
22+
--packs)
23+
/usr/bin/python3 -c "
24+
import json, os, glob
25+
config_path = '$CONFIG'
26+
try:
27+
active = json.load(open(config_path)).get('active_pack', 'peon')
28+
except:
29+
active = 'peon'
30+
packs_dir = '$PEON_DIR/packs'
31+
for m in sorted(glob.glob(os.path.join(packs_dir, '*/manifest.json'))):
32+
info = json.load(open(m))
33+
name = info.get('name', os.path.basename(os.path.dirname(m)))
34+
display = info.get('display_name', name)
35+
marker = ' *' if name == active else ''
36+
print(f' {name:24s} {display}{marker}')
37+
"
38+
exit 0 ;;
39+
--pack)
40+
PACK_ARG="${2:-}"
41+
if [ -z "$PACK_ARG" ]; then
42+
# No argument — cycle to next pack alphabetically
43+
/usr/bin/python3 -c "
44+
import json, os, glob
45+
config_path = '$CONFIG'
46+
try:
47+
cfg = json.load(open(config_path))
48+
except:
49+
cfg = {}
50+
active = cfg.get('active_pack', 'peon')
51+
packs_dir = '$PEON_DIR/packs'
52+
names = sorted([
53+
os.path.basename(os.path.dirname(m))
54+
for m in glob.glob(os.path.join(packs_dir, '*/manifest.json'))
55+
])
56+
if not names:
57+
print('Error: no packs found', flush=True)
58+
raise SystemExit(1)
59+
try:
60+
idx = names.index(active)
61+
next_pack = names[(idx + 1) % len(names)]
62+
except ValueError:
63+
next_pack = names[0]
64+
cfg['active_pack'] = next_pack
65+
json.dump(cfg, open(config_path, 'w'), indent=2)
66+
# Read display name
67+
mpath = os.path.join(packs_dir, next_pack, 'manifest.json')
68+
display = json.load(open(mpath)).get('display_name', next_pack)
69+
print(f'peon-ping: switched to {next_pack} ({display})')
70+
"
71+
else
72+
# Argument given — set specific pack
73+
/usr/bin/python3 -c "
74+
import json, os, glob, sys
75+
config_path = '$CONFIG'
76+
pack_arg = '$PACK_ARG'
77+
packs_dir = '$PEON_DIR/packs'
78+
names = sorted([
79+
os.path.basename(os.path.dirname(m))
80+
for m in glob.glob(os.path.join(packs_dir, '*/manifest.json'))
81+
])
82+
if pack_arg not in names:
83+
print(f'Error: pack \"{pack_arg}\" not found.', file=sys.stderr)
84+
print(f'Available packs: {\", \".join(names)}', file=sys.stderr)
85+
sys.exit(1)
86+
try:
87+
cfg = json.load(open(config_path))
88+
except:
89+
cfg = {}
90+
cfg['active_pack'] = pack_arg
91+
json.dump(cfg, open(config_path, 'w'), indent=2)
92+
mpath = os.path.join(packs_dir, pack_arg, 'manifest.json')
93+
display = json.load(open(mpath)).get('display_name', pack_arg)
94+
print(f'peon-ping: switched to {pack_arg} ({display})')
95+
" || exit 1
96+
fi
97+
exit 0 ;;
2298
--help|-h)
23-
echo "Usage: peon --pause | --resume | --toggle | --status"; exit 0 ;;
99+
cat <<'HELPEOF'
100+
Usage: peon <command>
101+
102+
Commands:
103+
--pause Mute sounds
104+
--resume Unmute sounds
105+
--toggle Toggle mute on/off
106+
--status Check if paused or active
107+
--packs List available sound packs
108+
--pack <name> Switch to a specific pack
109+
--pack Cycle to the next pack
110+
--help Show this help
111+
HELPEOF
112+
exit 0 ;;
24113
--*)
25114
echo "Unknown option: $1" >&2
26-
echo "Usage: peon --pause | --resume | --toggle | --status" >&2; exit 1 ;;
115+
echo "Run 'peon --help' for usage." >&2; exit 1 ;;
27116
esac
28117

29118
INPUT=$(cat)

tests/install.bats

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ setup() {
1515
cp "$(dirname "$BATS_TEST_FILENAME")/../peon.sh" "$CLONE_DIR/"
1616
cp "$(dirname "$BATS_TEST_FILENAME")/../config.json" "$CLONE_DIR/"
1717
cp "$(dirname "$BATS_TEST_FILENAME")/../VERSION" "$CLONE_DIR/"
18+
cp "$(dirname "$BATS_TEST_FILENAME")/../completions.bash" "$CLONE_DIR/"
1819
cp "$(dirname "$BATS_TEST_FILENAME")/../uninstall.sh" "$CLONE_DIR/" 2>/dev/null || touch "$CLONE_DIR/uninstall.sh"
1920
cp -r "$(dirname "$BATS_TEST_FILENAME")/../packs" "$CLONE_DIR/"
2021

@@ -86,3 +87,14 @@ print('OK')
8687
bash "$CLONE_DIR/install.sh"
8788
[ -x "$INSTALL_DIR/peon.sh" ]
8889
}
90+
91+
@test "fresh install copies completions.bash" {
92+
bash "$CLONE_DIR/install.sh"
93+
[ -f "$INSTALL_DIR/completions.bash" ]
94+
}
95+
96+
@test "fresh install adds completions source to shell rc" {
97+
touch "$TEST_HOME/.zshrc"
98+
bash "$CLONE_DIR/install.sh"
99+
grep -qF 'peon-ping/completions.bash' "$TEST_HOME/.zshrc"
100+
}

tests/peon.bats

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,3 +324,106 @@ JSON
324324
[ "$PEON_EXIT" -eq 0 ]
325325
! afplay_was_called
326326
}
327+
328+
# ============================================================
329+
# --packs (list packs)
330+
# ============================================================
331+
332+
@test "--packs lists all available packs" {
333+
run bash "$PEON_SH" --packs
334+
[ "$status" -eq 0 ]
335+
[[ "$output" == *"peon"* ]]
336+
[[ "$output" == *"sc_kerrigan"* ]]
337+
}
338+
339+
@test "--packs marks the active pack with *" {
340+
run bash "$PEON_SH" --packs
341+
[ "$status" -eq 0 ]
342+
[[ "$output" == *"Orc Peon *"* ]]
343+
# sc_kerrigan should NOT be marked
344+
line=$(echo "$output" | grep "sc_kerrigan")
345+
[[ "$line" != *"*"* ]]
346+
}
347+
348+
@test "--packs marks correct pack after switch" {
349+
bash "$PEON_SH" --pack sc_kerrigan
350+
run bash "$PEON_SH" --packs
351+
[ "$status" -eq 0 ]
352+
[[ "$output" == *"Sarah Kerrigan (StarCraft) *"* ]]
353+
}
354+
355+
# ============================================================
356+
# --pack <name> (set specific pack)
357+
# ============================================================
358+
359+
@test "--pack <name> switches to valid pack" {
360+
run bash "$PEON_SH" --pack sc_kerrigan
361+
[ "$status" -eq 0 ]
362+
[[ "$output" == *"switched to sc_kerrigan"* ]]
363+
[[ "$output" == *"Sarah Kerrigan"* ]]
364+
# Verify config was updated
365+
active=$(/usr/bin/python3 -c "import json; print(json.load(open('$TEST_DIR/config.json'))['active_pack'])")
366+
[ "$active" = "sc_kerrigan" ]
367+
}
368+
369+
@test "--pack <name> preserves other config fields" {
370+
bash "$PEON_SH" --pack sc_kerrigan
371+
volume=$(/usr/bin/python3 -c "import json; print(json.load(open('$TEST_DIR/config.json'))['volume'])")
372+
[ "$volume" = "0.5" ]
373+
}
374+
375+
@test "--pack <name> errors on nonexistent pack" {
376+
run bash "$PEON_SH" --pack nonexistent
377+
[ "$status" -ne 0 ]
378+
[[ "$output" == *"not found"* ]]
379+
[[ "$output" == *"Available packs"* ]]
380+
}
381+
382+
@test "--pack <name> does not modify config on invalid pack" {
383+
bash "$PEON_SH" --pack nonexistent || true
384+
active=$(/usr/bin/python3 -c "import json; print(json.load(open('$TEST_DIR/config.json'))['active_pack'])")
385+
[ "$active" = "peon" ]
386+
}
387+
388+
# ============================================================
389+
# --pack (cycle, no argument)
390+
# ============================================================
391+
392+
@test "--pack cycles to next pack alphabetically" {
393+
# Active is peon, next alphabetically is sc_kerrigan
394+
run bash "$PEON_SH" --pack
395+
[ "$status" -eq 0 ]
396+
[[ "$output" == *"switched to sc_kerrigan"* ]]
397+
}
398+
399+
@test "--pack cycle wraps around from last to first" {
400+
# Set to sc_kerrigan (last alphabetically), should wrap to peon
401+
bash "$PEON_SH" --pack sc_kerrigan
402+
run bash "$PEON_SH" --pack
403+
[ "$status" -eq 0 ]
404+
[[ "$output" == *"switched to peon"* ]]
405+
}
406+
407+
@test "--pack cycle updates config correctly" {
408+
bash "$PEON_SH" --pack
409+
active=$(/usr/bin/python3 -c "import json; print(json.load(open('$TEST_DIR/config.json'))['active_pack'])")
410+
[ "$active" = "sc_kerrigan" ]
411+
}
412+
413+
# ============================================================
414+
# --help (updated)
415+
# ============================================================
416+
417+
@test "--help shows pack commands" {
418+
run bash "$PEON_SH" --help
419+
[ "$status" -eq 0 ]
420+
[[ "$output" == *"--packs"* ]]
421+
[[ "$output" == *"--pack"* ]]
422+
}
423+
424+
@test "unknown option shows helpful error" {
425+
run bash "$PEON_SH" --foobar
426+
[ "$status" -ne 0 ]
427+
[[ "$output" == *"Unknown option"* ]]
428+
[[ "$output" == *"peon --help"* ]]
429+
}

tests/setup.bash

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ setup_test_env() {
77

88
# Create directory structure
99
mkdir -p "$TEST_DIR/packs/peon/sounds"
10+
mkdir -p "$TEST_DIR/packs/sc_kerrigan/sounds"
1011

1112
# Create minimal manifest
1213
cat > "$TEST_DIR/packs/peon/manifest.json" <<'JSON'
@@ -56,6 +57,30 @@ JSON
5657
touch "$TEST_DIR/packs/peon/sounds/$f"
5758
done
5859

60+
# Create second pack manifest (for pack switching tests)
61+
cat > "$TEST_DIR/packs/sc_kerrigan/manifest.json" <<'JSON'
62+
{
63+
"name": "sc_kerrigan",
64+
"display_name": "Sarah Kerrigan (StarCraft)",
65+
"categories": {
66+
"greeting": {
67+
"sounds": [
68+
{ "file": "Hello1.wav", "line": "What now?" }
69+
]
70+
},
71+
"complete": {
72+
"sounds": [
73+
{ "file": "Done1.wav", "line": "I gotcha." }
74+
]
75+
}
76+
}
77+
}
78+
JSON
79+
80+
for f in Hello1.wav Done1.wav; do
81+
touch "$TEST_DIR/packs/sc_kerrigan/sounds/$f"
82+
done
83+
5984
# Create default config
6085
cat > "$TEST_DIR/config.json" <<'JSON'
6186
{

0 commit comments

Comments
 (0)