SLOPSHOPPER

gh-inbox-tts

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

A shopper browsing a rack in a slop shop
README

gh-inbox-tts

English | 日本語

A Claude Code mod that reads gh-inbox's notices aloud through tts.

  • It reads only in the window that shows notices (the one used last). However many windows are open, a notice is read once.
  • A notice another mod stopped is not read. A notice whose text was rewritten is read as rewritten (as it shows).
  • How it reads (skipping code and URLs, and so on) and the voice follow tts's settings. It reads even when tts's reading mode is notify.
  • It reads in the language of gh-inbox's notices: set GitHub: Language in /config (en by default, ja for Japanese) to match your tts voice.

Install

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
  • With the marketplace added, gh-inbox and tts come along if they are not installed yet.
  • Installed without it, tts is not installed and gh-inbox-tts does not load. Run /plugin marketplace add kajidog/cc-mods-tts, then /reload-plugins.
  • For setting up tts (an engine such as VOICEVOX), see tts's README.

Settings (/config)

SettingDefaultWhat 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 activityfalseAlso 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
InterruptfalseStop what tts is saying and read the notice at once

Example: read only reviews and CI failures, as "tts, reviewer approved #18…".

  • Kinds read: review,ci
  • What is said: {label}, {body}

License

MIT

Source 2 files
hooks/register.ts 17 lines
1import 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}
17
hooks/filter.ts 69 lines
1import 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