Reads gh-inbox's GitHub notices aloud through tts, filtered by kind, repository and whose activity it was.

English | 日本語
A Claude Code mod that reads gh-inbox's notices aloud through tts.
notify./config (en by default, ja for Japanese) to match your tts voice.tts lives in another marketplace, so add that marketplace first.
/plugin marketplace add kajidog/cc-mods-tts
/plugin install gh-inbox-tts --marketplace kajidog/cc-mods-gh-inbox
/plugin marketplace add kajidog/cc-mods-tts, then /reload-plugins./config)| Setting | Default | What it does |
|---|---|---|
| Kinds read | (empty) | Comma-separated kinds to read: comment review commit ci merge close branch open push. Empty: every kind |
| Repositories read | (empty) | Comma-separated repositories to read, each as owner/repo, host/owner/repo or its /inbox repo alias. Empty: every repository (the branch's pull request included) |
| Read my own activity | false | Also read notices of your own activity |
| What is said | {text} | The text read. {text} the line shown, {body} the same without its leading [alias], {label} the alias, {repo} owner/repo, {kind} the kind, {author} the GitHub login |
| Interrupt | false | Stop what tts is saying and read the notice at once |
Example: read only reviews and CI failures, as "tts, reviewer approved #18…".
review,ci{label}, {body}MIT
hooks/register.ts 17 lines1import type { Register } from "claude-code"
2
3import { speechFor, toSettings } from "./filter"
4
5export const register: Register = (on, options) => {
6 const settings = toSettings(options)
7
8 // Told once every hook on `inbox.notify` has answered: a notice another mod refused never comes, a rewritten one comes as it shows.
9 on("inbox.notified", async ($, e, next) => {
10 const speech = speechFor(settings, e)
11 // Not awaited: the next notice is told while tts queues this one.
12 if (speech !== undefined) void $.tts.speak(speech).catch(() => undefined)
13
14 return next(e)
15 })
16}
17hooks/filter.ts 69 lines1import type { InboxNotice } from "gh-inbox"
2
3/** What the options say to read, and how. */
4export type Settings = {
5 /** null reads every kind. */
6 kinds: ReadonlySet<string> | null
7 /** Lowercase; null reads every repository. */
8 repos: readonly string[] | null
9 readSelf: boolean
10 format: string
11 interrupt: boolean
12}
13
14const words = (value: unknown) =>
15 String(value ?? "")
16 .split(",")
17 .map(word => word.trim().toLowerCase())
18 .filter(word => word !== "")
19
20export function toSettings(options: Record<string, unknown>): Settings {
21 const kinds = words(options.kinds)
22 const repos = words(options.repos)
23 const format = typeof options.format === "string" && options.format.trim() !== "" ? options.format : "{text}"
24 return {
25 kinds: kinds.length === 0 ? null : new Set(kinds),
26 repos: repos.length === 0 ? null : repos,
27 readSelf: options.readSelf === true,
28 format,
29 interrupt: options.interrupt === true,
30 }
31}
32
33/** `host/owner/repo` as `owner/repo`. */
34const ownerRepo = (repo: string) => repo.split("/").slice(-2).join("/")
35
36/** A word names the repository by `owner/repo`, `host/owner/repo` or its alias. */
37function isNamed(word: string, notice: InboxNotice) {
38 const repo = notice.repo.toLowerCase()
39 return word === repo || word === ownerRepo(repo) || (notice.label !== null && word === notice.label.toLowerCase())
40}
41
42export function shouldRead(settings: Settings, notice: InboxNotice) {
43 if (notice.isSelf && !settings.readSelf) return false
44 if (settings.kinds !== null && !settings.kinds.has(notice.event.kind)) return false
45 if (settings.repos !== null && !settings.repos.some(word => isNamed(word, notice))) return false
46 return true
47}
48
49/** The text read for a notice, its placeholders filled. */
50export function speechOf(format: string, notice: InboxNotice) {
51 const prefix = notice.label === null ? "" : `[${notice.label}] `
52 const body = notice.text.startsWith(prefix) ? notice.text.slice(prefix.length) : notice.text
53 const values: Record<string, string> = {
54 text: notice.text,
55 body,
56 label: notice.label ?? "",
57 repo: ownerRepo(notice.repo),
58 kind: notice.event.kind,
59 author: notice.event.author ?? "",
60 }
61 return format.replace(/\{(\w+)\}/g, (whole, name: string) => values[name] ?? whole)
62}
63
64/** What to have tts say for a notice as it shows; undefined says nothing. */
65export function speechFor(settings: Settings, notice: InboxNotice) {
66 if (!shouldRead(settings, notice)) return undefined
67 return { text: speechOf(settings.format, notice), interrupt: settings.interrupt }
68}
69