oc-tweaks 0.1.0

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.
@@ -0,0 +1,184 @@
1
+ import type { Plugin } from "@opencode-ai/plugin"
2
+
3
+ import { loadJsonConfig, loadOcTweaksConfig, safeHook } from "../utils"
4
+ import { log as sharedLog } from "../utils/logger"
5
+
6
+ declare const Bun: any
7
+
8
+ function getHome(): string {
9
+ return Bun.env?.HOME ?? ((globalThis as any)?.process?.env?.HOME ?? "") ?? ""
10
+ }
11
+ const API_ENDPOINT = "https://api.claudecount.com/api/usage/hook"
12
+
13
+ interface LeaderboardConfig {
14
+ twitter_handle: string
15
+ twitter_user_id: string
16
+ }
17
+
18
+ interface AssistantMessageInfo {
19
+ id: string
20
+ sessionID: string
21
+ role: "assistant"
22
+ time: { created: number; completed?: number }
23
+ modelID: string
24
+ providerID: string
25
+ cost: number
26
+ tokens: {
27
+ input: number
28
+ output: number
29
+ reasoning: number
30
+ cache: { read: number; write: number }
31
+ }
32
+ }
33
+
34
+ // claudecount.com API only accepts official Anthropic model IDs with date suffixes.
35
+ // Map opencode model IDs (which may be custom names or non-Claude models) to accepted format.
36
+ const MODEL_MAP: Record<string, string> = {
37
+ // Claude 4.x family → closest accepted ID
38
+ "claude-opus-4.6": "claude-opus-4-20250514",
39
+ "claude-opus-4.5": "claude-opus-4-20250514",
40
+ "claude-sonnet-4.5": "claude-sonnet-4-20250514",
41
+ "claude-sonnet-4": "claude-sonnet-4-20250514",
42
+ "claude-haiku-4.5": "claude-3.5-haiku-20241022",
43
+ // GPT family → mapped by performance tier
44
+ "gpt-5.2-codex": "claude-sonnet-4-20250514",
45
+ "gpt-5.1-codex": "claude-sonnet-4-20250514",
46
+ "gpt-5.1-codex-max": "claude-opus-4-20250514",
47
+ "gpt-5.2": "claude-sonnet-4-20250514",
48
+ "gpt-5.1": "claude-sonnet-4-20250514",
49
+ "gpt-5-mini": "claude-3.5-haiku-20241022",
50
+ "gpt-5": "claude-sonnet-4-20250514",
51
+ // Gemini family
52
+ "gemini-3-pro-preview": "claude-sonnet-4-20250514",
53
+ "gemini-2.5-pro": "claude-sonnet-4-20250514",
54
+ // Grok family
55
+ "grok-code-fast-1": "claude-3.5-haiku-20241022",
56
+ }
57
+
58
+ // Default fallback for unknown models
59
+ const DEFAULT_MODEL = "claude-sonnet-4-20250514"
60
+
61
+ function mapModel(modelID: string): string {
62
+ // Direct match in map
63
+ if (MODEL_MAP[modelID]) return MODEL_MAP[modelID]
64
+ // Already an accepted Anthropic format (contains date suffix like -20250514)
65
+ if (/claude-.*-\d{8}$/.test(modelID)) return modelID
66
+ // Fallback
67
+ return DEFAULT_MODEL
68
+ }
69
+
70
+
71
+ function parseLeaderboardConfig(parsed: Record<string, any>): LeaderboardConfig | null {
72
+ // Support both field naming conventions (snake_case from old format, camelCase from new)
73
+ const handle = parsed.twitter_handle ?? parsed.twitterUrl
74
+ const userId = parsed.twitter_user_id ?? parsed.twitterUserId ?? handle
75
+ if (!handle || !userId) return null
76
+ return { twitter_handle: handle, twitter_user_id: userId }
77
+ }
78
+
79
+ async function readLeaderboardConfig(path: string): Promise<LeaderboardConfig | null> {
80
+ const parsed = await loadJsonConfig<Record<string, any>>(path, {})
81
+ return parseLeaderboardConfig(parsed)
82
+ }
83
+
84
+ async function loadLeaderboardConfig(
85
+ configPath: string | null | undefined,
86
+ ): Promise<LeaderboardConfig | null> {
87
+ if (typeof configPath === "string") {
88
+ return readLeaderboardConfig(configPath)
89
+ }
90
+
91
+ const paths = [`${getHome()}/.claude/leaderboard.json`, `${getHome()}/.config/claude/leaderboard.json`]
92
+ for (const path of paths) {
93
+ const config = await readLeaderboardConfig(path)
94
+ if (config) return config
95
+ }
96
+
97
+ return null
98
+ }
99
+
100
+ async function submitUsage(config: LeaderboardConfig, msg: AssistantMessageInfo): Promise<void> {
101
+ const timestamp = new Date(msg.time.created).toISOString()
102
+ const hashInput = `${msg.time.created}${msg.id}${msg.sessionID}`
103
+ const interactionHash = new Bun.CryptoHasher("sha256").update(hashInput).digest("hex")
104
+
105
+ const payload = {
106
+ twitter_handle: config.twitter_handle,
107
+ twitter_user_id: config.twitter_user_id,
108
+ timestamp,
109
+ tokens: {
110
+ input: msg.tokens.input,
111
+ output: msg.tokens.output,
112
+ cache_creation: msg.tokens.cache?.write ?? 0,
113
+ cache_read: msg.tokens.cache?.read ?? 0,
114
+ },
115
+ model: mapModel(msg.modelID),
116
+ interaction_id: interactionHash,
117
+ interaction_hash: interactionHash,
118
+ }
119
+
120
+ const res = await fetch(API_ENDPOINT, {
121
+ method: "POST",
122
+ headers: { "Content-Type": "application/json" },
123
+ body: JSON.stringify(payload),
124
+ })
125
+
126
+ if (!res.ok) {
127
+ const body = await res.text().catch(() => "")
128
+ throw new Error(`API ${res.status}: ${body}`)
129
+ }
130
+ }
131
+
132
+ export const leaderboardPlugin: Plugin = async () => {
133
+ const ocTweaks = await loadOcTweaksConfig()
134
+ if (!ocTweaks || ocTweaks.leaderboard?.enabled !== true) return {}
135
+
136
+ const config = await loadLeaderboardConfig(ocTweaks.leaderboard?.configPath)
137
+ if (!config) {
138
+ await sharedLog(ocTweaks.logging, "INFO", "No leaderboard config found, plugin disabled")
139
+ return {}
140
+ }
141
+
142
+ await sharedLog(ocTweaks.logging, "INFO", `Plugin loaded, handle=${config.twitter_handle}`)
143
+
144
+ // Track submitted message IDs to avoid duplicates within this process
145
+ const submitted = new Set<string>()
146
+
147
+ return {
148
+ event: safeHook(
149
+ "leaderboard:event",
150
+ async ({ event }: { event: unknown }) => {
151
+ try {
152
+ if (!event || typeof event !== "object") return
153
+ const eventRecord = event as Record<string, unknown>
154
+ if (eventRecord.type !== "message.updated") return
155
+
156
+ const properties = eventRecord.properties
157
+ if (!properties || typeof properties !== "object") return
158
+ const infoUnknown = (properties as Record<string, unknown>).info
159
+ if (!infoUnknown || typeof infoUnknown !== "object") return
160
+
161
+ const info = infoUnknown as Record<string, any>
162
+ if (info.role !== "assistant") return
163
+ if (!info.time?.completed) return
164
+ if (submitted.has(info.id)) return
165
+
166
+ const msg = info as AssistantMessageInfo
167
+ if (!msg.tokens || msg.tokens.input === 0) return
168
+
169
+ submitted.add(msg.id)
170
+ await sharedLog(
171
+ ocTweaks.logging,
172
+ "INFO",
173
+ `Submitting: msg=${msg.id.slice(0, 16)} model=${msg.modelID}→${mapModel(msg.modelID)} in=${msg.tokens.input} out=${msg.tokens.output} cache_r=${msg.tokens.cache?.read ?? 0} cache_w=${msg.tokens.cache?.write ?? 0}`,
174
+ )
175
+ await submitUsage(config, msg)
176
+ await sharedLog(ocTweaks.logging, "INFO", "Submitted OK")
177
+ } catch (err) {
178
+ await sharedLog(ocTweaks.logging, "ERROR", `Submit failed: ${err}`)
179
+ // Silently ignore — never disrupt the user's workflow
180
+ }
181
+ },
182
+ ),
183
+ }
184
+ }
@@ -0,0 +1,383 @@
1
+ import type { Plugin } from "@opencode-ai/plugin"
2
+
3
+ import { loadOcTweaksConfig, safeHook, log } from "../utils"
4
+ import type { LoggerConfig } from "../utils"
5
+ import type { NotifyStyle } from "../utils/config"
6
+
7
+ type ShellExecutor = (strings: TemplateStringsArray, ...values: any[]) => Promise<any>
8
+
9
+ type NotifySender =
10
+ | { kind: "custom"; commandTemplate: string }
11
+ | { kind: "wpf"; command: string }
12
+ | { kind: "osascript" }
13
+ | { kind: "notify-send" }
14
+ | { kind: "tui"; showToast: (...args: any[]) => any }
15
+ | { kind: "none" }
16
+
17
+ export const notifyPlugin: Plugin = async ({ $, directory, client }) => {
18
+ let logConfig: LoggerConfig | undefined
19
+ try {
20
+ const config = await loadOcTweaksConfig()
21
+ if (!config || config.notify?.enabled !== true) return {}
22
+
23
+ const notifyOnIdle = config.notify?.notifyOnIdle !== false
24
+ const notifyOnError = config.notify?.notifyOnError !== false
25
+ const configuredCommand =
26
+ typeof config.notify?.command === "string" && config.notify.command.trim().length > 0
27
+ ? config.notify.command.trim()
28
+ : null
29
+ const style = config.notify?.style
30
+ logConfig = config.logging
31
+
32
+ const sender = configuredCommand
33
+ ? ({ kind: "custom", commandTemplate: configuredCommand } as const)
34
+ : await detectNotifySender($ as ShellExecutor, client, logConfig)
35
+
36
+ const sendToast = async (projectName: string, message: string, tag: string) => {
37
+ const title = `oc: ${projectName}`
38
+ await notifyWithSender($ as ShellExecutor, sender, title, message, tag, style, logConfig)
39
+ }
40
+
41
+ return {
42
+ event: safeHook("notify:event", async ({ event }: { event: any }) => {
43
+ if (event?.type === "session.idle") {
44
+ if (!notifyOnIdle) return
45
+
46
+ const projectName = getProjectName(directory)
47
+ const sessionId =
48
+ (event.properties as { sessionID?: string; sessionId?: string } | undefined)
49
+ ?.sessionID ??
50
+ (event.properties as { sessionID?: string; sessionId?: string } | undefined)
51
+ ?.sessionId
52
+
53
+ const message = await extractIdleMessage(client, sessionId)
54
+ await sendToast(projectName, message, "Stop")
55
+ return
56
+ }
57
+
58
+ if (event?.type === "session.error") {
59
+ if (!notifyOnError) return
60
+ const projectName = getProjectName(directory)
61
+ await sendToast(projectName, "❌ Session error", "Error")
62
+ }
63
+ }),
64
+ }
65
+ } catch (error) {
66
+ await log(logConfig, "WARN", "[oc-tweaks] notify:init: " + String(error))
67
+ return {}
68
+ }
69
+ }
70
+
71
+ function getProjectName(directory: string): string {
72
+ const normalized = directory.replace(/\\/g, "/")
73
+ const segments = normalized.split("/").filter(Boolean)
74
+ return segments[segments.length - 1] || "opencode"
75
+ }
76
+
77
+ async function extractIdleMessage(client: any, sessionId?: string): Promise<string> {
78
+ let message = "✓ Task completed"
79
+ if (!sessionId || !client?.session?.messages) return message
80
+
81
+ try {
82
+ const result = await client.session.messages({ path: { id: sessionId } })
83
+ const messages = result?.data
84
+ if (!Array.isArray(messages)) return message
85
+
86
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
87
+ const msg = messages[i]
88
+ if (msg?.info?.role !== "assistant" || !Array.isArray(msg?.parts)) continue
89
+ for (const part of msg.parts) {
90
+ if (part?.type === "text" && typeof part.text === "string") {
91
+ message = `✓ ${truncateText(cleanMarkdown(part.text), 400)}`
92
+ return message
93
+ }
94
+ }
95
+ }
96
+
97
+ return message
98
+ } catch {
99
+ return message
100
+ }
101
+ }
102
+
103
+ async function detectNotifySender(
104
+ $: ShellExecutor,
105
+ client: any,
106
+ logConfig?: LoggerConfig,
107
+ ): Promise<NotifySender> {
108
+ if (await commandExists($, "pwsh")) {
109
+ return { kind: "wpf", command: "pwsh" }
110
+ }
111
+
112
+ if (await commandExists($, "powershell.exe")) {
113
+ return { kind: "wpf", command: "powershell.exe" }
114
+ }
115
+
116
+ if (await commandExists($, "osascript")) {
117
+ return { kind: "osascript" }
118
+ }
119
+
120
+ if (await commandExists($, "notify-send")) {
121
+ return { kind: "notify-send" }
122
+ }
123
+
124
+ if (typeof client?.tui?.showToast === "function") {
125
+ return { kind: "tui", showToast: client.tui.showToast }
126
+ }
127
+
128
+ await log(
129
+ logConfig,
130
+ "WARN",
131
+ "[oc-tweaks] notify: no available notifier, set notify.command to override",
132
+ )
133
+ return { kind: "none" }
134
+ }
135
+
136
+ async function commandExists($: ShellExecutor, command: string): Promise<boolean> {
137
+ try {
138
+ await $`which ${command}`
139
+ return true
140
+ } catch {
141
+ return false
142
+ }
143
+ }
144
+
145
+ async function notifyWithSender(
146
+ $: ShellExecutor,
147
+ sender: NotifySender,
148
+ title: string,
149
+ message: string,
150
+ tag: string,
151
+ style?: NotifyStyle,
152
+ logConfig?: LoggerConfig,
153
+ ): Promise<void> {
154
+ try {
155
+ if (sender.kind === "custom") {
156
+ const command = sender.commandTemplate
157
+ .replace(/\$TITLE/g, title)
158
+ .replace(/\$MESSAGE/g, message)
159
+ await runCustomCommand($, command)
160
+ return
161
+ }
162
+
163
+ if (sender.kind === "wpf") {
164
+ await runWpfNotification($, sender.command, title, message, tag, style)
165
+ return
166
+ }
167
+
168
+ if (sender.kind === "osascript") {
169
+ const script = `display notification "${escapeAppleScript(message)}" with title "${escapeAppleScript(title)}"`
170
+ await $`osascript -e ${script}`
171
+ return
172
+ }
173
+
174
+ if (sender.kind === "notify-send") {
175
+ await $`notify-send ${title} ${message}`
176
+ return
177
+ }
178
+
179
+ if (sender.kind === "tui") {
180
+ await showToastWithFallback(sender.showToast, title, message)
181
+ }
182
+ } catch {
183
+ // Notification flow must stay non-blocking.
184
+ }
185
+ }
186
+
187
+ async function runCustomCommand($: ShellExecutor, command: string): Promise<void> {
188
+ const escaped = command.replace(/"/g, '\\"')
189
+ await $`bun -e ${`const { exec } = require("node:child_process"); exec("${escaped}")`}`
190
+ }
191
+
192
+ async function runWpfNotification(
193
+ $: ShellExecutor,
194
+ shellCommand: string,
195
+ title: string,
196
+ message: string,
197
+ tag: string,
198
+ style?: NotifyStyle,
199
+ ): Promise<void> {
200
+ const backgroundColor = style?.backgroundColor ?? "#101018"
201
+ const backgroundOpacity = style?.backgroundOpacity ?? 0.95
202
+ const textColor = style?.textColor ?? "#AAAAAA"
203
+ const borderRadius = style?.borderRadius ?? 14
204
+ const colorBarWidth = style?.colorBarWidth ?? 5
205
+ const width = style?.width ?? 420
206
+ const height = style?.height ?? 105
207
+ const titleFontSize = style?.titleFontSize ?? 14
208
+ const contentFontSize = style?.contentFontSize ?? 11
209
+ const iconFontSize = style?.iconFontSize ?? 30
210
+ const duration = style?.duration ?? 10000
211
+ const position = style?.position ?? "center"
212
+ const shadow = style?.shadow !== false
213
+ const idleColor = style?.idleColor ?? "#4ADE80"
214
+ const errorColor = style?.errorColor ?? "#EF4444"
215
+
216
+ const accentColor = tag === "Error" ? errorColor : idleColor
217
+ const icon = tag === "Error" ? "❌" : "✅"
218
+ const startupLocation = position === "center" ? "CenterScreen" : position
219
+
220
+ const psTitle = title.replace(/'/g, "''")
221
+ const psText = truncateText(cleanMarkdown(message), 400).replace(/'/g, "''")
222
+
223
+ const shadowXaml = shadow
224
+ ? '<Border.Effect><DropShadowEffect BlurRadius="20" ShadowDepth="2" Opacity="0.7" Color="Black"/></Border.Effect>'
225
+ : ""
226
+
227
+ const xaml = [
228
+ `<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"`,
229
+ ` xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"`,
230
+ ` WindowStyle="None" AllowsTransparency="True" Background="Transparent"`,
231
+ ` Topmost="True" ShowInTaskbar="False" ShowActivated="False"`,
232
+ ` WindowStartupLocation="${startupLocation}"`,
233
+ ` Width="${width}" Height="${height}">`,
234
+ ` <Border CornerRadius="${borderRadius}" Margin="10">`,
235
+ ` <Border.Background>`,
236
+ ` <SolidColorBrush Color="${backgroundColor}" Opacity="${backgroundOpacity}"/>`,
237
+ ` </Border.Background>`,
238
+ ` ${shadowXaml}`,
239
+ ` <Grid>`,
240
+ ` <Border CornerRadius="${borderRadius},0,0,${borderRadius}" Width="${colorBarWidth}" HorizontalAlignment="Left" Name="ColorBar"/>`,
241
+ ` <StackPanel Orientation="Horizontal" Margin="22,0,16,0" VerticalAlignment="Center">`,
242
+ ` <TextBlock Name="IconText" FontSize="${iconFontSize}" VerticalAlignment="Center" Margin="0,0,15,0" Foreground="White"/>`,
243
+ ` <StackPanel VerticalAlignment="Center" MaxWidth="320">`,
244
+ ` <TextBlock Name="TitleText" FontSize="${titleFontSize}" FontWeight="SemiBold"/>`,
245
+ ` <TextBlock Name="ContentText" Foreground="${textColor}" FontSize="${contentFontSize}" Margin="0,4,0,0" TextWrapping="Wrap"/>`,
246
+ ` <TextBlock Text="Click to dismiss" Foreground="#555555" FontSize="9" Margin="0,4,0,0"/>`,
247
+ ` </StackPanel>`,
248
+ ` </StackPanel>`,
249
+ ` </Grid>`,
250
+ ` </Border>`,
251
+ `</Window>`,
252
+ ].join("\n")
253
+
254
+ const psScript = [
255
+ "Add-Type -AssemblyName PresentationFramework",
256
+ "Add-Type -AssemblyName PresentationCore",
257
+ "Add-Type -AssemblyName WindowsBase",
258
+ "",
259
+ "Add-Type -TypeDefinition @'",
260
+ "using System;",
261
+ "using System.Runtime.InteropServices;",
262
+ "",
263
+ "public static class VDesktop {",
264
+ ' [DllImport("user32.dll", SetLastError = true)]',
265
+ " public static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);",
266
+ "",
267
+ ' [DllImport("user32.dll", SetLastError = true)]',
268
+ " public static extern int GetWindowLong(IntPtr hWnd, int nIndex);",
269
+ "",
270
+ " public const int GWL_EXSTYLE = -20;",
271
+ " public const int WS_EX_TOOLWINDOW = 0x00000080;",
272
+ " public const int WS_EX_NOACTIVATE = 0x08000000;",
273
+ " public const int WS_EX_APPWINDOW = 0x00040000;",
274
+ "",
275
+ " public static void MakeGlobalWindow(IntPtr hwnd) {",
276
+ " int style = GetWindowLong(hwnd, GWL_EXSTYLE);",
277
+ " style = style | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE;",
278
+ " style = style & ~WS_EX_APPWINDOW;",
279
+ " SetWindowLong(hwnd, GWL_EXSTYLE, style);",
280
+ " }",
281
+ "}",
282
+ "'@ -ErrorAction SilentlyContinue",
283
+ "",
284
+ `$title = '${psTitle}'`,
285
+ `$text = '${psText}'`,
286
+ `$accentColor = '${accentColor}'`,
287
+ `$icon = '${icon}'`,
288
+ `$duration = ${duration}`,
289
+ "",
290
+ "[xml]$xaml = @'",
291
+ xaml,
292
+ "'@",
293
+ "",
294
+ "$reader = New-Object System.Xml.XmlNodeReader $xaml",
295
+ "$window = [Windows.Markup.XamlReader]::Load($reader)",
296
+ "",
297
+ "$colorBar = $window.FindName('ColorBar')",
298
+ "$iconText = $window.FindName('IconText')",
299
+ "$titleText = $window.FindName('TitleText')",
300
+ "$contentText = $window.FindName('ContentText')",
301
+ "",
302
+ "$colorBar.Background = [System.Windows.Media.BrushConverter]::new().ConvertFromString($accentColor)",
303
+ "$iconText.Text = $icon",
304
+ "$titleText.Text = $title",
305
+ "$titleText.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString($accentColor)",
306
+ "$contentText.Text = $text",
307
+ "",
308
+ "$window.Add_MouseLeftButtonDown({ $window.Close() })",
309
+ "",
310
+ "$window.Add_Loaded({",
311
+ " $hwnd = (New-Object System.Windows.Interop.WindowInteropHelper($window)).Handle",
312
+ " [VDesktop]::MakeGlobalWindow($hwnd)",
313
+ "})",
314
+ "",
315
+ "if ($duration -gt 0) {",
316
+ " $timer = New-Object System.Windows.Threading.DispatcherTimer",
317
+ " $timer.Interval = [TimeSpan]::FromMilliseconds($duration)",
318
+ " $timer.Add_Tick({",
319
+ " $window.Close()",
320
+ " $timer.Stop()",
321
+ " })",
322
+ " $timer.Start()",
323
+ "}",
324
+ "",
325
+ "$window.ShowActivated = $false",
326
+ "$window.Show()",
327
+ "$frame = New-Object System.Windows.Threading.DispatcherFrame",
328
+ "$window.Add_Closed({ $frame.Continue = $false })",
329
+ "[System.Windows.Threading.Dispatcher]::PushFrame($frame)",
330
+ ].join("\n")
331
+
332
+ const jsCode = [
333
+ "const proc = require('node:child_process').spawn(",
334
+ ` ${JSON.stringify(shellCommand)},`,
335
+ ` ['-NoProfile', '-Command', ${JSON.stringify(psScript)}],`,
336
+ " { detached: true, stdio: 'ignore' }",
337
+ ");",
338
+ "proc.unref();",
339
+ ].join("\n")
340
+
341
+ await $`bun -e ${jsCode}`
342
+ }
343
+
344
+ async function showToastWithFallback(
345
+ showToast: (...args: any[]) => any,
346
+ title: string,
347
+ message: string,
348
+ ): Promise<void> {
349
+ try {
350
+ await Promise.resolve(showToast({ title, message }))
351
+ return
352
+ } catch {
353
+ // Try alternative signatures below.
354
+ }
355
+
356
+ try {
357
+ await Promise.resolve(showToast({ title, description: message }))
358
+ return
359
+ } catch {
360
+ // Try alternative signatures below.
361
+ }
362
+
363
+ await Promise.resolve(showToast(title, message))
364
+ }
365
+
366
+ function truncateText(text: string, maxChars: number): string {
367
+ if (text.length <= maxChars) return text
368
+ return `${text.slice(0, maxChars)}...`
369
+ }
370
+
371
+ function cleanMarkdown(text: string): string {
372
+ return text
373
+ .replace(/[`*#]/g, "")
374
+ .replace(/\n+/g, " ")
375
+ .replace(/\s+/g, " ")
376
+ .trim()
377
+ }
378
+
379
+
380
+
381
+ function escapeAppleScript(text: string): string {
382
+ return text.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")
383
+ }
package/src/types.ts ADDED
@@ -0,0 +1,2 @@
1
+ export type { Plugin } from "@opencode-ai/plugin"
2
+ export type { OcTweaksConfig } from "./utils/config"
File without changes
@@ -0,0 +1,71 @@
1
+ declare const Bun: any
2
+
3
+ export async function loadJsonConfig<T extends Record<string, unknown>>(
4
+ path: string,
5
+ defaults: T
6
+ ): Promise<T> {
7
+ try {
8
+ const file = Bun.file(path)
9
+ if (!(await file.exists())) return defaults
10
+ const parsed = await file.json()
11
+ return { ...defaults, ...parsed }
12
+ } catch {
13
+ return defaults
14
+ }
15
+ }
16
+
17
+ export interface NotifyStyle {
18
+ backgroundColor?: string // default: "#101018"
19
+ backgroundOpacity?: number // default: 0.95
20
+ textColor?: string // default: "#AAAAAA"
21
+ borderRadius?: number // default: 14
22
+ colorBarWidth?: number // default: 5
23
+ width?: number // default: 420
24
+ height?: number // default: 105
25
+ titleFontSize?: number // default: 14
26
+ contentFontSize?: number // default: 11
27
+ iconFontSize?: number // default: 30
28
+ duration?: number // default: 10000
29
+ position?: string // default: "center"
30
+ shadow?: boolean // default: true
31
+ idleColor?: string // default: "#4ADE80"
32
+ errorColor?: string // default: "#EF4444"
33
+ }
34
+
35
+ export interface OcTweaksConfig extends Record<string, unknown> {
36
+ compaction: { enabled?: boolean }
37
+ backgroundSubagent: { enabled?: boolean }
38
+ leaderboard: { enabled?: boolean; configPath?: string | null }
39
+ logging?: {
40
+ enabled?: boolean
41
+ maxLines?: number
42
+ }
43
+ notify: {
44
+ enabled?: boolean
45
+ notifyOnIdle?: boolean
46
+ notifyOnError?: boolean
47
+ command?: string | null
48
+ style?: NotifyStyle
49
+ }
50
+ }
51
+
52
+ const DEFAULT_CONFIG: OcTweaksConfig = {
53
+ compaction: {},
54
+ backgroundSubagent: {},
55
+ leaderboard: {},
56
+ notify: {},
57
+ }
58
+
59
+ export async function loadOcTweaksConfig(): Promise<OcTweaksConfig | null> {
60
+ const home =
61
+ Bun.env?.HOME ?? ((globalThis as any)?.process?.env?.HOME ?? "") ?? ""
62
+ const path = `${home}/.config/opencode/oc-tweaks.json`
63
+ try {
64
+ const file = Bun.file(path)
65
+ if (!(await file.exists())) return null
66
+ const parsed = await file.json()
67
+ return { ...DEFAULT_CONFIG, ...parsed }
68
+ } catch {
69
+ return null
70
+ }
71
+ }
@@ -0,0 +1,5 @@
1
+ export { safeHook } from "./safe-hook"
2
+ export { loadJsonConfig, loadOcTweaksConfig } from "./config"
3
+ export type { OcTweaksConfig } from "./config"
4
+ export type { LoggerConfig } from "./logger"
5
+ export { log } from "./logger"