opencode-plugin-boops 1.0.0 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -1,14 +1,48 @@
1
1
  import type { Plugin } from "@opencode-ai/plugin"
2
+ import { tool } from "@opencode-ai/plugin"
3
+ import { parse } from "smol-toml"
4
+ import { existsSync } from "fs"
5
+ import { readFile, writeFile, mkdir, unlink, readdir } from "fs/promises"
6
+ import { homedir } from "os"
7
+ import { join, dirname } from "path"
8
+ import { fileURLToPath } from "url"
9
+
10
+ // Load sounds database
11
+ const __filename = fileURLToPath(import.meta.url)
12
+ const __dirname = dirname(__filename)
13
+ const soundsDbPath = join(__dirname, "sounds.json")
14
+
15
+ interface SoundEntry {
16
+ name: string
17
+ id: string
18
+ url: string
19
+ }
20
+
21
+ let soundsDb: SoundEntry[] = []
22
+
23
+ // Load sounds database
24
+ try {
25
+ const soundsData = await readFile(soundsDbPath, "utf-8")
26
+ soundsDb = JSON.parse(soundsData)
27
+ } catch (error) {
28
+ console.error("Failed to load sounds.json:", error)
29
+ }
30
+
31
+ interface EventFilter {
32
+ sound: string
33
+ only_if?: Record<string, any> // Only play if these properties match
34
+ not_if?: Record<string, any> // Don't play if these properties match
35
+ }
36
+
37
+ interface BoopsConfig {
38
+ player?: string
39
+ sounds: Record<string, string | EventFilter>
40
+ fallbacks?: {
41
+ default?: string
42
+ }
43
+ }
2
44
 
3
45
  export const BoopsPlugin: Plugin = async ({ client }) => {
4
- // Default sounds - users can customize via config
5
- const inputSound = '/usr/share/sounds/gnome/default/alerts/sonar.ogg'
6
- const completeSound = '/usr/share/sounds/gnome/default/alerts/glass.ogg'
7
-
8
- // Fallback sounds for systems without GNOME sounds
9
- const fallbackInputSound = '/usr/share/sounds/alsa/Front_Left.wav'
10
- const fallbackCompleteSound = '/usr/share/sounds/alsa/Front_Right.wav'
11
-
12
46
  const log = async (message: string, extra?: any) => {
13
47
  await client.app.log({
14
48
  service: "opencode-plugin-boops",
@@ -17,49 +51,368 @@ export const BoopsPlugin: Plugin = async ({ client }) => {
17
51
  extra
18
52
  })
19
53
  }
54
+
55
+ // Cache directory for downloaded sounds
56
+ const cacheDir = join(homedir(), ".cache", "opencode", "boops")
20
57
 
21
- // Try multiple sound players and files
22
- const playSound = async (primaryFile: string, fallbackFile: string, label: string) => {
58
+ // Ensure cache directory exists
59
+ try {
60
+ await mkdir(cacheDir, { recursive: true })
61
+ } catch (error) {
62
+ // Directory might already exist
63
+ }
64
+
65
+ const configPath = join(homedir(), ".config", "opencode", "plugins", "boops", "boops.toml")
66
+
67
+ // Load configuration (can be called multiple times to reload)
68
+ const loadConfig = async (): Promise<BoopsConfig> => {
69
+ let config: BoopsConfig = {
70
+ sounds: {
71
+ "session.idle": "1150-pristine",
72
+ "permission.asked": "1217-relax",
73
+ "session.error": "1219-magic",
74
+ },
75
+ fallbacks: {
76
+ default: "/usr/share/sounds/alsa/Front_Center.wav"
77
+ }
78
+ }
79
+
80
+ // Try to load user config
81
+ if (existsSync(configPath)) {
82
+ try {
83
+ const configContent = await readFile(configPath, "utf-8")
84
+ const userConfig = parse(configContent) as BoopsConfig
85
+ config = { ...config, ...userConfig }
86
+ await log("Loaded custom configuration", { path: configPath })
87
+ } catch (error) {
88
+ await log("Failed to load config, using defaults", { error: String(error) })
89
+ }
90
+ } else {
91
+ await log("No custom config found, using defaults", {
92
+ hint: `Create ${configPath} to customize sounds`
93
+ })
94
+ }
95
+
96
+ return config
97
+ }
98
+
99
+ let config = await loadConfig()
100
+
101
+ // Detect available sound player
102
+ const detectPlayer = async (): Promise<string> => {
103
+ if (config.player) return config.player
104
+
105
+ const players = ["paplay", "aplay", "afplay"]
106
+ for (const player of players) {
107
+ try {
108
+ await Bun.$`which ${player}`.quiet()
109
+ return player
110
+ } catch {
111
+ continue
112
+ }
113
+ }
114
+ return "echo" // Fallback to no sound
115
+ }
116
+
117
+ let player = await detectPlayer()
118
+ if (player === "echo") {
119
+ await log("No sound player found, sounds disabled")
120
+ } else {
121
+ await log(`Using sound player: ${player}`)
122
+ }
123
+
124
+ // Check if string is a URL
125
+ const isUrl = (str: string): boolean => {
126
+ try {
127
+ const url = new URL(str)
128
+ return url.protocol === "http:" || url.protocol === "https:"
129
+ } catch {
130
+ return false
131
+ }
132
+ }
133
+
134
+ // Check if string is a local file path
135
+ const isLocalPath = (str: string): boolean => {
136
+ return str.startsWith("/") || str.startsWith("./") || str.startsWith("../") || str.startsWith("~")
137
+ }
138
+
139
+ // Resolve sound name/ID to URL using sounds database
140
+ const resolveSoundId = async (id: string): Promise<string | null> => {
141
+ try {
142
+ // Search in sounds database by name (fuzzy match)
143
+ const searchTerm = id.toLowerCase().replace(/-/g, ' ')
144
+
145
+ // Try exact match first (by id like "1150-pristine")
146
+ let sound = soundsDb.find(s => s.id === id)
147
+
148
+ // Try exact name match
149
+ if (!sound) {
150
+ sound = soundsDb.find(s => s.name.toLowerCase() === searchTerm)
151
+ }
152
+
153
+ // Try partial name match
154
+ if (!sound) {
155
+ sound = soundsDb.find(s => s.name.toLowerCase().includes(searchTerm))
156
+ }
157
+
158
+ if (sound) {
159
+ await log(`Resolved '${id}' to: ${sound.name} (${sound.id})`)
160
+ return sound.url
161
+ }
162
+
163
+ await log(`No sound found for: ${id}`)
164
+ return null
165
+ } catch (error) {
166
+ await log(`Failed to resolve sound ID: ${id}`, { error: String(error) })
167
+ return null
168
+ }
169
+ }
170
+
171
+ // Get cache path for an event (no extension, stores by event name)
172
+ const getCachePath = (eventName: string): string => {
173
+ // Sanitize event name for filesystem (replace dots with dashes)
174
+ const safeName = eventName.replace(/\./g, "-")
175
+ return join(cacheDir, safeName)
176
+ }
177
+
178
+ // Download and cache a sound file from URL for a specific event
179
+ const downloadSound = async (url: string, eventName: string): Promise<string> => {
180
+ const cachedPath = getCachePath(eventName)
181
+
182
+ // Return cached file if it exists
183
+ if (existsSync(cachedPath)) {
184
+ return cachedPath
185
+ }
186
+
187
+ // Delete any old cached file for this event (in case URL changed)
23
188
  try {
24
- // Try primary sound with paplay
25
- await Bun.$`paplay ${primaryFile}`.quiet()
26
- return
189
+ const files = await readdir(cacheDir)
190
+ const safeName = eventName.replace(/\./g, "-")
191
+ for (const file of files) {
192
+ if (file.startsWith(safeName)) {
193
+ await unlink(join(cacheDir, file))
194
+ }
195
+ }
27
196
  } catch {
197
+ // Ignore errors
198
+ }
199
+
200
+ // Download the file
201
+ await log(`Downloading sound for ${eventName}: ${url}`)
202
+ try {
203
+ const response = await fetch(url)
204
+ if (!response.ok) {
205
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
206
+ }
207
+
208
+ const arrayBuffer = await response.arrayBuffer()
209
+ const buffer = Buffer.from(arrayBuffer)
210
+ await writeFile(cachedPath, buffer)
211
+ await log(`Cached sound for ${eventName}: ${cachedPath}`)
212
+
213
+ return cachedPath
214
+ } catch (error) {
215
+ await log(`Failed to download sound from ${url}`, { error: String(error) })
216
+ throw error
217
+ }
218
+ }
219
+
220
+ // Resolve sound file path (handles URLs, local paths, and notificationsounds.com IDs)
221
+ const resolveSoundPath = async (soundFile: string, eventName: string): Promise<string | null> => {
222
+ // Handle URLs
223
+ if (isUrl(soundFile)) {
28
224
  try {
29
- // Try fallback sound with paplay
30
- await Bun.$`paplay ${fallbackFile}`.quiet()
31
- return
225
+ return await downloadSound(soundFile, eventName)
226
+ } catch {
227
+ return null
228
+ }
229
+ }
230
+
231
+ // Handle local paths
232
+ if (isLocalPath(soundFile) && existsSync(soundFile)) {
233
+ return soundFile
234
+ }
235
+
236
+ // Try as sound ID/name (e.g., "pristine" or "1150-pristine")
237
+ const resolvedUrl = await resolveSoundId(soundFile)
238
+ if (resolvedUrl) {
239
+ try {
240
+ return await downloadSound(resolvedUrl, eventName)
32
241
  } catch {
33
- try {
34
- // Try primary with aplay
35
- await Bun.$`aplay ${primaryFile}`.quiet()
242
+ return null
243
+ }
244
+ }
245
+
246
+ return null
247
+ }
248
+
249
+ // Play sound with fallback mechanism
250
+ const playSound = async (soundFile: string, eventName: string) => {
251
+ if (player === "echo") return // Skip if no player
252
+
253
+ try {
254
+ // Resolve the sound file (download if URL, check if local path exists)
255
+ const resolvedPath = await resolveSoundPath(soundFile, eventName)
256
+
257
+ if (resolvedPath) {
258
+ await Bun.$`${player} ${resolvedPath}`.quiet()
259
+ return
260
+ }
261
+
262
+ // Try fallback
263
+ const fallback = config.fallbacks?.default
264
+ if (fallback) {
265
+ const fallbackPath = await resolveSoundPath(fallback, "fallback")
266
+ if (fallbackPath) {
267
+ await Bun.$`${player} ${fallbackPath}`.quiet()
36
268
  return
37
- } catch {
38
- try {
39
- // Try fallback with aplay
40
- await Bun.$`aplay ${fallbackFile}`.quiet()
41
- return
42
- } catch {
43
- // Last resort: terminal bell
44
- await log(`All sound attempts failed for ${label}, using terminal bell`)
45
- await Bun.$`echo -e "\a"`.quiet()
46
- }
47
269
  }
48
270
  }
271
+
272
+ // Last resort: terminal bell
273
+ await Bun.$`echo -e "\a"`.quiet()
274
+ } catch (error) {
275
+ // Silently fail - don't spam logs for sound errors
49
276
  }
50
277
  }
51
-
52
- await log("Boops plugin initialized - sound notifications enabled")
53
-
278
+
279
+ await log("Boops plugin initialized", {
280
+ configuredEvents: Object.keys(config.sounds).length,
281
+ player
282
+ })
283
+
284
+ // Check if event matches filter conditions
285
+ const matchesFilter = (event: any, filter: EventFilter): boolean => {
286
+ // Check only_if conditions
287
+ if (filter.only_if) {
288
+ for (const [key, value] of Object.entries(filter.only_if)) {
289
+ if (event[key] !== value) {
290
+ return false
291
+ }
292
+ }
293
+ }
294
+
295
+ // Check not_if conditions
296
+ if (filter.not_if) {
297
+ for (const [key, value] of Object.entries(filter.not_if)) {
298
+ if (event[key] === value) {
299
+ return false
300
+ }
301
+ }
302
+ }
303
+
304
+ return true
305
+ }
306
+
54
307
  return {
55
308
  event: async ({ event }) => {
309
+ // Log session.idle events for debugging (helpful for users configuring filters)
56
310
  if (event.type === "session.idle") {
57
- await playSound(completeSound, fallbackCompleteSound, "completion")
311
+ await log(`session.idle event received`, {
312
+ properties: event
313
+ })
58
314
  }
59
-
60
- if (event.type === "permission.asked") {
61
- await playSound(inputSound, fallbackInputSound, "input-needed")
315
+
316
+ const soundConfig = config.sounds[event.type]
317
+ if (!soundConfig) return
318
+
319
+ // Handle simple string (just a sound file path/URL)
320
+ if (typeof soundConfig === "string") {
321
+ await playSound(soundConfig, event.type)
322
+ return
323
+ }
324
+
325
+ // Handle filter object
326
+ if (matchesFilter(event, soundConfig)) {
327
+ await playSound(soundConfig.sound, event.type)
62
328
  }
329
+ },
330
+
331
+ tool: {
332
+ "test-sound": tool({
333
+ description: "Test a boops sound. Usage: test-sound pristine OR test-sound session.idle",
334
+ args: {
335
+ sound: tool.schema.string().describe("Sound to test: event name, ID, URL, or path"),
336
+ },
337
+ async execute(args, context) {
338
+ // Reload config and player
339
+ config = await loadConfig()
340
+ player = await detectPlayer()
341
+
342
+ // Check if it's an event name first
343
+ const soundConfig = config.sounds[args.sound]
344
+ if (soundConfig) {
345
+ await log(`Testing configured sound for event: ${args.sound}`)
346
+
347
+ // Handle filter object
348
+ const soundFile = typeof soundConfig === "string" ? soundConfig : soundConfig.sound
349
+ await playSound(soundFile, args.sound)
350
+
351
+ return `✅ Played sound for event: ${args.sound}\nSound: ${soundFile}`
352
+ }
353
+
354
+ // Otherwise treat it as a direct sound specification (ID, URL, or path)
355
+ await log(`Testing sound directly: ${args.sound}`)
356
+ const resolvedPath = await resolveSoundPath(args.sound, "test")
357
+
358
+ if (!resolvedPath) {
359
+ return `❌ Failed to resolve sound: ${args.sound}\n\nTried:\n - Event name lookup (not found)\n - notificationsounds.com ID (not found)\n - URL/path resolution (failed)\n\nCheck logs for details.`
360
+ }
361
+
362
+ if (player === "echo") {
363
+ return `❌ No sound player found (paplay/aplay/afplay)\nResolved to: ${resolvedPath}`
364
+ }
365
+
366
+ await Bun.$`${player} ${resolvedPath}`.quiet()
367
+
368
+ return `✅ Played sound: ${args.sound}\nResolved to: ${resolvedPath}`
369
+ },
370
+ }),
371
+
372
+ "browse-boops": tool({
373
+ description: "Browse and search through 449 notification sounds",
374
+ args: {
375
+ query: tool.schema.string().optional().describe("Search sounds by name"),
376
+ },
377
+ async execute(args) {
378
+ if (!soundsDb.length) {
379
+ return `❌ Sounds database not loaded`
380
+ }
381
+
382
+ // No query - show suggestions
383
+ if (!args.query) {
384
+ const suggestions = ['pristine', 'relax', 'magic', 'done', 'error', 'attention', 'elegant', 'cheerful']
385
+ return `🔊 **449 notification sounds available**\n\n` +
386
+ `**Try:** ${suggestions.map(s => `browse-boops ${s}`).join(' • ')}\n\n` +
387
+ `💡 Test any sound: **test-sound pristine**`
388
+ }
389
+
390
+ // Search
391
+ const searchTerm = args.query.toLowerCase()
392
+ const results = soundsDb.filter(s => s.name.toLowerCase().includes(searchTerm))
393
+
394
+ if (results.length === 0) {
395
+ return `❌ No sounds matching "${args.query}"\n\nTry: browse-boops notification`
396
+ }
397
+
398
+ // Format results
399
+ const MAX = 30
400
+ const display = results.slice(0, MAX)
401
+
402
+ let output = `🔊 **${results.length}** sound${results.length !== 1 ? 's' : ''} matching "${args.query}"\n\n`
403
+
404
+ if (results.length <= 10) {
405
+ output += display.map((s, i) => `${i + 1}. **${s.name}**`).join('\n')
406
+ output += `\n\n💡 Test: **test-sound ${display[0].name}**`
407
+ } else {
408
+ output += `**Sounds:** ${display.map(s => s.name).join(', ')}`
409
+ if (results.length > MAX) output += `, +${results.length - MAX} more`
410
+ output += `\n\n💡 Test: **test-sound ${display[0].name}**`
411
+ }
412
+
413
+ return output
414
+ },
415
+ }),
63
416
  }
64
417
  }
65
418
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-plugin-boops",
3
- "version": "1.0.0",
3
+ "version": "2.0.2",
4
4
  "description": "Sound notifications for OpenCode - plays pleasant sounds when tasks complete or input is needed",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -22,12 +22,29 @@
22
22
  "url": "https://github.com/towc/opencode-plugin-boops/issues"
23
23
  },
24
24
  "homepage": "https://github.com/towc/opencode-plugin-boops#readme",
25
+ "bin": {
26
+ "opencode-plugin-boops": "cli/browse"
27
+ },
25
28
  "peerDependencies": {
26
29
  "@opencode-ai/plugin": "^1.0.0"
27
30
  },
31
+ "dependencies": {
32
+ "openai": "^6.17.0",
33
+ "smol-toml": "^1.3.1"
34
+ },
35
+ "scripts": {
36
+ "postinstall": "node -e \"const fs=require('fs'),p=require('path'),h=require('os').homedir(),d=p.join(h,'.config','opencode','plugins','boops');fs.mkdirSync(d,{recursive:true});const cfg=p.join(d,'boops.toml');if(!fs.existsSync(cfg))fs.copyFileSync(p.join(__dirname,'boops.default.toml'),cfg);fs.copyFileSync(p.join(__dirname,'cli','browse'),p.join(d,'browse'));fs.copyFileSync(p.join(__dirname,'sounds.json'),p.join(d,'sounds.json'));fs.chmodSync(p.join(d,'browse'),0o755);console.log('✓ Installed to ~/.config/opencode/plugins/boops/')\""
37
+ },
28
38
  "files": [
29
39
  "index.ts",
40
+ "sounds.json",
41
+ "boops.default.toml",
42
+ "cli/browse",
30
43
  "README.md",
31
44
  "LICENSE"
32
- ]
45
+ ],
46
+ "devDependencies": {
47
+ "@google/generative-ai": "^0.24.1",
48
+ "dotenv": "^17.2.3"
49
+ }
33
50
  }