Skip to content

Commit 134153f

Browse files
duyetduyetbot
andcommitted
feat(sag-notify)!: skill-driven notifications, greeting-style messages
Replace auto-firing Notification/Stop hooks with a skill-driven model: Claude runs the notifier itself (bin/speak.sh) right before it asks a question and once at the end of a substantive turn, so the spoken line reflects what it did/needs. - New default templates: "Hi, this is Claude. Project <name> needs you/is done. <body>" (en + vi presets), with empty-body collapse in sag_render. - Add bin/speak.sh (agent CLI: `needs-you`/`done` + body) reusing lib.sh; move hooks/lib.sh -> bin/lib.sh; remove hooks.json, notify.sh, summary.sh. - Drop now-unused summary_file config key (body is passed as a CLI arg). - Rewrite sag-voice SKILL.md with the proactive protocol; refresh README, setup/config/test commands; marketplace type hook -> skill. BREAKING CHANGE: notifications no longer fire from hooks; enable the sag-voice skill so Claude speaks proactively. Co-Authored-By: duyetbot <duyetbot@users.noreply.github.com>
1 parent 2088edc commit 134153f

14 files changed

Lines changed: 148 additions & 157 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@
8787
{
8888
"name": "sag-notify",
8989
"source": "./sag-notify",
90-
"description": "Spoken voice notifications via sag (ElevenLabs TTS). Speaks a short alert when Claude needs you and a per-turn summary when work finishes, naming the project. Auto-injected hooks; configurable voice, language, and templates."
90+
"description": "Spoken voice notifications via sag (ElevenLabs TTS). Skill-driven: Claude speaks a short alert just before it asks you something and a one-line summary when work finishes, naming the project. Configurable voice, language, and templates."
9191
}
9292
]
9393
}

marketplace.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,9 +150,9 @@
150150
{
151151
"name": "sag-notify",
152152
"id": "sag-notify@duyet-claude-plugins",
153-
"description": "Spoken voice notifications via sag (ElevenLabs TTS): a short alert when Claude needs you and a per-turn summary when work finishes, naming the project. Configurable voice, language, and templates.",
154-
"version": "1.0.0",
155-
"type": "hook",
153+
"description": "Spoken voice notifications via sag (ElevenLabs TTS): skill-driven, Claude speaks a short alert just before it asks you something and a one-line summary when work finishes, naming the project. Configurable voice, language, and templates.",
154+
"version": "2.0.0",
155+
"type": "skill",
156156
"category": "ui"
157157
}
158158
],

sag-notify/.claude-plugin/plugin.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "sag-notify",
3-
"version": "1.1.0",
4-
"description": "Spoken voice notifications via sag (ElevenLabs TTS). Speaks a short alert when Claude needs you, and a per-turn summary when work finishes — naming the project. Auto-injected hooks; configurable voice, multi-language presets, and message templates.",
3+
"version": "2.0.0",
4+
"description": "Spoken voice notifications via sag (ElevenLabs TTS). Skill-driven: Claude speaks a short alert just before it asks you something, and a one-line summary when work finishes — naming the project. Configurable voice, multi-language presets, and message templates.",
55
"author": {
66
"name": "duyet"
77
}

sag-notify/README.md

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,36 +2,38 @@
22

33
Spoken voice notifications for Claude Code via [`sag`](https://github.com/steipete/sag) (ElevenLabs TTS).
44

5-
- **Needs you**when Claude waits for permission/input, it speaks a short alert naming the project.
6-
- **Turn done** → when Claude finishes substantive work, it speaks a one-line summary you author.
5+
- **Needs you**just before Claude asks you a question, it speaks a short alert naming the project.
6+
- **Turn done** → when Claude finishes substantive work, it speaks a one-line summary of what it did.
77

8-
Both are driven by **auto-injected hooks** — once the plugin is enabled, every session gets them, no per-session setup.
8+
Both are **skill-driven**: Claude runs `sag` itself at the right moment (via the bundled `bin/speak.sh`), so the spoken line reflects what it actually did or needs. There are no auto-firing hooks — the **sag-voice** skill teaches Claude when and how to speak.
99

1010
## Requirements
1111

1212
The `sag` CLI must be installed and authenticated. Install with `brew install steipete/tap/sag` and set `ELEVENLABS_API_KEY`. Full guide: [skills/sag-voice/references/sag-cli.md](skills/sag-voice/references/sag-cli.md).
1313

1414
## How it works
1515

16-
| Event | Hook | Behavior |
17-
|-------|------|----------|
18-
| `Notification` | `hooks/notify.sh` | Speaks the notification template, naming the project, in the configured language. (The harness message is English, so it is replaced by the template.) |
19-
| `Stop` | `hooks/summary.sh` | Reads the body Claude wrote to `summary_file`, speaks it via the summary template, deletes the file. **Silent when no file exists**, so trivial turns are quiet. |
16+
Claude calls one helper at two moments:
2017

21-
To make Claude speak a summary, it writes a short body to `~/.claude/.sag-summary` during a substantive turn. The hook adds the `This is <name>, reporting from project <name>:` framing automatically.
18+
| Moment | Command | Speaks (default) |
19+
|--------|---------|------------------|
20+
| Right before asking you (e.g. before `AskUserQuestion`) | `bin/speak.sh needs-you "<reason>"` | `Hi, this is Claude. Project <name> needs you. <reason>` |
21+
| End of a substantive turn | `bin/speak.sh done "<summary>"` | `Hi, this is Claude. Project <name> is done. <summary>` |
22+
23+
`bin/speak.sh` reads your config, names the project from `$PWD`, renders the configured template, and speaks in the background. It exits silently when `sag`/`jq`/the API key are missing or the plugin is disabled, so it never blocks a turn. Trivial turns stay quiet because Claude simply doesn't call it.
2224

2325
## Setup
2426

2527
1. Install + authenticate `sag` (see Requirements).
26-
2. Enable the plugin (it ships `hooks/hooks.json`).
28+
2. Enable the plugin and the **sag-voice** skill.
2729
3. Run `/sag-notify:setup` — checks `sag`/`jq`, resolves the API key, picks a voice, writes config, and tests audio.
2830
4. Or just rely on defaults: Brian voice, English templates, key from the `ELEVENLABS_API_KEY` environment variable.
2931

3032
`ELEVENLABS_API_KEY` must be resolvable — from the environment, or from an optional key file you point `key_file` at in your user config.
3133

3234
## Configuration
3335

34-
User config at `~/.config/sag-notify/config.json` overrides [`config.default.json`](config.default.json). See the **sag-voice** skill or run `/sag-notify:config`. Key settings: `enabled`, `events.{notification,summary}`, `voice_id`, `model_id`, `self_name`, `language`, `key_file`, `summary_file`, `error_log`, `max_chars`, `templates.{notification,summary}`, `languages.<lang>.{notification,summary}`.
36+
User config at `~/.config/sag-notify/config.json` overrides [`config.default.json`](config.default.json). See the **sag-voice** skill or run `/sag-notify:config`. Key settings: `enabled`, `events.{notification,summary}`, `voice_id`, `model_id`, `self_name`, `language`, `key_file`, `error_log`, `max_chars`, `templates.{notification,summary}`, `languages.<lang>.{notification,summary}`.
3537

3638
### Languages
3739

@@ -49,8 +51,8 @@ jq '.language = "vi"' ~/.config/sag-notify/config.json > /tmp/c && mv /tmp/c ~/.
4951

5052
- `/sag-notify:setup` — guided first-time setup + audio test
5153
- `/sag-notify:config` — change voice, language, templates, toggles
52-
- `/sag-notify:test` — fire both hooks and check `~/.claude/.sag-error.log`
54+
- `/sag-notify:test` — fire both notification lines and check `~/.claude/.sag-error.log`
5355

5456
## Troubleshooting
5557

56-
Silent? Check `~/.claude/.sag-error.log` (the hooks log stderr there, never `/dev/null`). A `402` means a paid voice; switch to a premade one. Verify real audio in the **foreground**the hooks background the call so their exit code can't tell you if sound played.
58+
Silent? Check `~/.claude/.sag-error.log` (errors are logged there, never `/dev/null`). A `402` means a paid voice; switch to a premade one. Verify real audio in the **foreground**`bin/speak.sh` backgrounds the call so its exit code can't tell you if sound played.
Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#!/usr/bin/env bash
2-
# Shared helpers for the sag-notify hooks. Sourced by notify.sh and summary.sh.
2+
# Shared helpers for sag-notify. Sourced by bin/speak.sh (the agent-driven CLI).
33
# Config precedence: user config (~/.config/sag-notify/config.json) → plugin default.
44

55
# Plugin root: prefer the env Claude Code sets; fall back to this file's parent dir.
@@ -66,10 +66,14 @@ sag_speak() {
6666
}
6767

6868
# sag_render <template> <name> <project> [body] — substitute {name}/{project}/{body}.
69+
# Collapses a dangling " {body}" to nothing when no body is given, and trims any
70+
# leftover double spaces / trailing space so empty-body lines read cleanly.
6971
sag_render() {
7072
local out="$1"
7173
out=${out//\{name\}/$2}
7274
out=${out//\{project\}/$3}
7375
out=${out//\{body\}/${4:-}}
76+
out=${out// / } # collapse double spaces left by an empty {body}
77+
out=${out% } # drop a single trailing space
7478
printf '%s' "$out"
7579
}

sag-notify/bin/speak.sh

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
#!/usr/bin/env bash
2+
# Agent-driven voice notification via sag. The sag-voice skill instructs Claude to
3+
# run this itself — right before it asks you a question, and once at the end of a
4+
# substantive turn — instead of relying on auto-firing hooks.
5+
#
6+
# speak.sh needs-you "I have two questions before continuing."
7+
# speak.sh done "Refactored the hooks into a skill; build is green."
8+
#
9+
# Resolves config from ~/.config/sag-notify/config.json (falls back to the plugin
10+
# default), renders the configured template, names the current project from $PWD,
11+
# and speaks in the background. Exits 0 silently on any missing prerequisite so it
12+
# never blocks a turn.
13+
set -uo pipefail
14+
15+
. "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
16+
17+
kind="${1:-done}"
18+
body="${2:-}"
19+
20+
command -v sag >/dev/null 2>&1 || exit 0
21+
command -v jq >/dev/null 2>&1 || exit 0
22+
[ "$(cfg '.enabled' 'true')" = "true" ] || exit 0
23+
sag_resolve_key || exit 0
24+
25+
# Map the friendly verb to a template kind + the toggle that gates it.
26+
case "$kind" in
27+
notify|notification|needs-you|needsyou|input|ask) tkind=notification; event=notification ;;
28+
done|summary|finished|complete) tkind=summary; event=summary ;;
29+
*) tkind=summary; event=summary ;;
30+
esac
31+
[ "$(cfg ".events.$event" 'true')" = "true" ] || exit 0
32+
33+
name=$(cfg '.self_name' 'Claude')
34+
project=$(sag_project "$PWD")
35+
maxlen=$(cfg '.max_chars' '280')
36+
body=${body:0:$maxlen}
37+
tmpl=$(sag_template "$tkind")
38+
39+
sag_speak "$(sag_render "$tmpl" "$name" "$project" "$body")"
40+
exit 0

sag-notify/commands/config.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ Common changes:
2424
- **Length cap**: `.max_chars = 200`.
2525
- **Key file**: `.key_file = "~/path/to/keyfile"` (sourced if `ELEVENLABS_API_KEY` is unset).
2626

27-
After changing the voice or templates, verify in the FOREGROUND (hooks background the call):
27+
After changing the voice or templates, verify in the FOREGROUND (`bin/speak.sh` backgrounds the call):
2828
```
2929
! V=$(jq -r .voice_id ~/.config/sag-notify/config.json); M=$(jq -r .model_id ~/.config/sag-notify/config.json); sag speak --model-id "$M" --voice-id "$V" "test" 2>&1 | grep -iE "failed|402" || echo OK
3030
```

sag-notify/commands/setup.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Walk the user through setup, doing the work for them where possible:
88

99
1. **Check `sag` and `jq`** are installed: `command -v sag jq`. If `sag` is missing, point them at the install guide [skills/sag-voice/references/sag-cli.md](../skills/sag-voice/references/sag-cli.md) (`brew install steipete/tap/sag`) and stop.
1010

11-
2. **Resolve the API key.** Check `ELEVENLABS_API_KEY` in the environment. If absent, instruct the user to add it themselves (do NOT ask them to paste the secret in chat). Recommend their shell env file so non-interactive hook shells inherit it:
11+
2. **Resolve the API key.** Check `ELEVENLABS_API_KEY` in the environment. If absent, instruct the user to add it themselves (do NOT ask them to paste the secret in chat). Recommend their shell env file so non-interactive shells (where `bin/speak.sh` runs) inherit it:
1212
```
1313
! echo "export ELEVENLABS_API_KEY='your-key'" >> ~/.zshenv
1414
```
@@ -26,4 +26,4 @@ Walk the user through setup, doing the work for them where possible:
2626
```
2727
Empty/`OK` = success. A 402 means the voice needs a paid plan — go back to step 4.
2828

29-
7. Confirm the hooks are active and explain that summaries only speak when Claude writes a body to the configured `summary_file`.
29+
7. Confirm the **sag-voice** skill is enabled and explain the model: Claude itself runs `${CLAUDE_PLUGIN_ROOT}/bin/speak.sh``needs-you` right before it asks a question, and `done` once at the end of a substantive turn. There are no auto-firing hooks, so trivial turns stay silent.

sag-notify/commands/test.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,27 @@
11
# /sag-notify:test
22

3-
Speak a test line through the current configuration to confirm audio works.
3+
Speak both notification lines through the current configuration to confirm audio works.
44

55
## Action Required
66

7-
Run both hook paths the same way Claude Code would, then check the error log.
7+
Fire the helper the same way Claude does during a turn, then check the error log.
88

99
1. Resolve config values:
1010
```
1111
! CFG=~/.config/sag-notify/config.json; [ -f "$CFG" ] || CFG="${CLAUDE_PLUGIN_ROOT}/config.default.json"; jq . "$CFG"
1212
```
1313

14-
2. **Notification path**fire the hook with a sample cwd so it names a project:
14+
2. **Needs-you line**names the current project:
1515
```
16-
! echo '{"cwd":"/path/to/demo-project"}' | "${CLAUDE_PLUGIN_ROOT}/hooks/notify.sh"; echo exit=$?
16+
! "${CLAUDE_PLUGIN_ROOT}/bin/speak.sh" needs-you "this is an audio test."; echo exit=$?
1717
```
1818

19-
3. **Summary path**prime a body, then fire the Stop hook (use the configured language for the body):
19+
3. **Done line** — the end-of-turn summary:
2020
```
21-
! printf 'this is an audio test.' > ~/.claude/.sag-summary; echo '{"cwd":"/path/to/demo-project"}' | "${CLAUDE_PLUGIN_ROOT}/hooks/summary.sh"; echo exit=$?
21+
! "${CLAUDE_PLUGIN_ROOT}/bin/speak.sh" done "this is an audio test."; echo exit=$?
2222
```
2323

24-
4. **Check for silent failures** (the hooks background the call, so a clean exit ≠ sound):
24+
4. **Check for silent failures** (the helper backgrounds the call, so a clean exit ≠ sound):
2525
```
2626
! sleep 6; [ -s ~/.claude/.sag-error.log ] && cat ~/.claude/.sag-error.log || echo "(no errors — audio played)"
2727
```

sag-notify/config.default.json

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
"self_name": "Claude",
1010
"language": "en",
1111
"key_file": "",
12-
"summary_file": "~/.claude/.sag-summary",
1312
"error_log": "~/.claude/.sag-error.log",
1413
"max_chars": 280,
1514
"templates": {
@@ -18,12 +17,12 @@
1817
},
1918
"languages": {
2019
"en": {
21-
"notification": "This is {name}. I'm working on project {project} and could use your input.",
22-
"summary": "This is {name}, reporting from project {project}: {body}"
20+
"notification": "Hi, this is {name}. Project {project} needs you. {body}",
21+
"summary": "Hi, this is {name}. Project {project} is done. {body}"
2322
},
2423
"vi": {
25-
"notification": "Đây là {name}. Mình đang làm ở project {project}, cần bạn xem qua một chút.",
26-
"summary": "Đây là {name}, báo cáo từ project {project}: {body}"
24+
"notification": "Chào, mình là {name}. Project {project} cần bạn xem qua. {body}",
25+
"summary": "Chào, mình là {name}. Project {project} đã xong. {body}"
2726
}
2827
}
2928
}

0 commit comments

Comments
 (0)