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/README.md +251 -38
- package/boops.default.toml +70 -0
- package/cli/browse +2122 -0
- package/index.ts +389 -36
- package/package.json +19 -2
- package/sounds.json +23289 -0
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
|
-
//
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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
|
|
311
|
+
await log(`session.idle event received`, {
|
|
312
|
+
properties: event
|
|
313
|
+
})
|
|
58
314
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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": "
|
|
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
|
}
|