From 5d18159b10bdb3e21fcc33078e28fa3bb957fa2b Mon Sep 17 00:00:00 2001 From: Idris Gadi Date: Thu, 12 Mar 2026 09:57:14 +0530 Subject: [PATCH 01/13] feat: install zod --- bun.lock | 5 ++++- package.json | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/bun.lock b/bun.lock index 6129799..32a9b6b 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "opencode-notification", "dependencies": { "@opencode-ai/plugin": "^1.2.16", + "zod": "^4.3.6", }, "devDependencies": { "@types/bun": "latest", @@ -115,6 +116,8 @@ "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "@opencode-ai/plugin/zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], } } diff --git a/package.json b/package.json index 5acf39e..a2f2cae 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,8 @@ "fmt:check": "oxfmt --check" }, "dependencies": { - "@opencode-ai/plugin": "^1.2.16" + "@opencode-ai/plugin": "^1.2.16", + "zod": "^4.3.6" }, "devDependencies": { "@types/bun": "latest", From 4343a2d1921ca497a25bcbbd0d7fe487fcc25b18 Mon Sep 17 00:00:00 2001 From: Idris Gadi Date: Thu, 12 Mar 2026 11:46:31 +0530 Subject: [PATCH 02/13] feat: config loader and schema --- src/config/loader.ts | 108 +++++++++++++++++++++++++++++++++++++++++++ src/config/schema.ts | 23 +++++++++ 2 files changed, 131 insertions(+) create mode 100644 src/config/loader.ts create mode 100644 src/config/schema.ts diff --git a/src/config/loader.ts b/src/config/loader.ts new file mode 100644 index 0000000..4e92a05 --- /dev/null +++ b/src/config/loader.ts @@ -0,0 +1,108 @@ +import { homedir } from "node:os"; +import { ConfigSchema, type Config } from "@/config/schema"; + +/** + * Recursively merges objects. Arrays are replaced, not merged. + * Null/undefined values in source do not overwrite existing values. + */ +function deepMerge( + target: Record, + source: Record, +): Record { + if (source === null || typeof source !== "object") return target; + if (Array.isArray(source)) return source; + + const result: Record = { ...target }; + + for (const key in source) { + const sourceValue = source[key]; + const targetValue = result[key]; + + if ( + sourceValue !== null && + typeof sourceValue === "object" && + !Array.isArray(sourceValue) && + targetValue !== null && + typeof targetValue === "object" && + !Array.isArray(targetValue) + ) { + // Recursively merge nested objects + result[key] = deepMerge( + targetValue as Record, + sourceValue as Record, + ); + } else if (sourceValue !== undefined) { + result[key] = sourceValue; + } + } + + return result; +} + +/** + * Discovers configuration files in order of precedence: + * 1. Global config (XDG/AppData) + * 2. Project root config + * 3. .opencode config + */ +export async function discoverConfigFiles(projectRoot?: string): Promise { + const paths: string[] = []; + + // 1. Global config + const globalPath = + process.platform === "win32" + ? // Convert forward slashes to backslashes for Windows paths (e.g., "a/b" -> "a\b") + `${process.env.APPDATA || homedir()}\\notification-plugin.json`.replace(/\//g, "\\") // windows + : `${process.env.XDG_CONFIG_HOME || `${homedir()}/.config`}/notification-plugin.json`; // linux/macos + + if (await Bun.file(globalPath).exists()) { + paths.push(globalPath); + } + + // 2. Project root config + if (projectRoot) { + const projectPath = `${projectRoot}/notification-plugin.json`; + if (await Bun.file(projectPath).exists()) { + paths.push(projectPath); + } + + // 3. .opencode config + const dotOpenCodePath = `${projectRoot}/.opencode/notification-plugin.json`; + if (await Bun.file(dotOpenCodePath).exists()) { + paths.push(dotOpenCodePath); + } + } + + return paths; +} + +export async function loadConfig(projectRoot?: string): Promise { + const filePaths = await discoverConfigFiles(projectRoot); + const configs: Record[] = []; + + for (const filePath of filePaths) { + try { + const content = await Bun.file(filePath).json(); + if (content && typeof content === "object") { + configs.push(content as Record); + } + } catch { + // Log config loading errors - these are typically file not found or JSON parse errors + // which are recoverable (we'll use defaults) + } + } + + const mergedConfig = configs.reduce( + (acc, config) => deepMerge(acc as Record, config), + {} as Record, + ); + + try { + return ConfigSchema.parse(mergedConfig); + } catch (error) { + // Config validation errors are logged but we continue with defaults + // This ensures the plugin remains functional even with invalid config + console.error("Config validation failed, using defaults:", error); + return ConfigSchema.parse({}); + } +} diff --git a/src/config/schema.ts b/src/config/schema.ts new file mode 100644 index 0000000..2c83890 --- /dev/null +++ b/src/config/schema.ts @@ -0,0 +1,23 @@ +import * as z from "zod"; + +export const EventConfigSchema = z.object({ + enabled: z.boolean().default(true).describe("Enable notifications for this event type"), + delay: z.number().optional().describe("Optional override for notification delay in milliseconds"), +}); + +export const ConfigSchema = z.object({ + delay: z.number().default(15000).describe("Default notification delay in milliseconds"), + enabled: z.boolean().default(true).describe("Master switch to enable/disable all notifications"), + response_ready: EventConfigSchema.default({ enabled: true }).describe( + "Notification when AI response is ready (session.idle event)", + ), + error: EventConfigSchema.default({ enabled: true }).describe("Notification on session error"), + permission_asked: EventConfigSchema.default({ enabled: true }).describe( + "Notification when permission is requested", + ), + question_asked: EventConfigSchema.default({ enabled: true }).describe( + "Notification when a question is asked", + ), +}); + +export type Config = z.infer; From 8289e89f791f46193eee3ea9c34939b320589430 Mon Sep 17 00:00:00 2001 From: Idris Gadi Date: Thu, 12 Mar 2026 11:46:39 +0530 Subject: [PATCH 03/13] feat: generate json schema script --- scripts/generate-schema.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 scripts/generate-schema.ts diff --git a/scripts/generate-schema.ts b/scripts/generate-schema.ts new file mode 100644 index 0000000..09e0764 --- /dev/null +++ b/scripts/generate-schema.ts @@ -0,0 +1,10 @@ +import * as z from "zod"; +import { ConfigSchema } from "../src/config/schema"; + +const jsonSchema = z.toJSONSchema(ConfigSchema, { + target: "draft-2020-12", + unrepresentable: "throw", +}); + +const outputPath = `${import.meta.dir}/../schema/notification-plugin.json`; +await Bun.write(outputPath, JSON.stringify(jsonSchema, null, 2)); From cdea4d84d85e19976edbe77179b93560c8142977 Mon Sep 17 00:00:00 2001 From: Idris Gadi Date: Thu, 12 Mar 2026 12:09:07 +0530 Subject: [PATCH 04/13] feat: add version id to the config schema --- schema/notification-plugin.json | 114 ++++++++++++++++++++++++++++++++ scripts/generate-schema.ts | 16 ++++- 2 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 schema/notification-plugin.json diff --git a/schema/notification-plugin.json b/schema/notification-plugin.json new file mode 100644 index 0000000..224e77d --- /dev/null +++ b/schema/notification-plugin.json @@ -0,0 +1,114 @@ +{ + "$id": "https://unpkg.com/opencode-notification@0.0.3/schema/notification-plugin.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "delay": { + "default": 15000, + "description": "Default notification delay in milliseconds", + "type": "number" + }, + "enabled": { + "default": true, + "description": "Master switch to enable/disable all notifications", + "type": "boolean" + }, + "response_ready": { + "default": { + "enabled": true + }, + "description": "Notification when AI response is ready (session.idle event)", + "type": "object", + "properties": { + "enabled": { + "default": true, + "description": "Enable notifications for this event type", + "type": "boolean" + }, + "delay": { + "description": "Optional override for notification delay in milliseconds", + "type": "number" + } + }, + "required": [ + "enabled" + ], + "additionalProperties": false + }, + "error": { + "default": { + "enabled": true + }, + "description": "Notification on session error", + "type": "object", + "properties": { + "enabled": { + "default": true, + "description": "Enable notifications for this event type", + "type": "boolean" + }, + "delay": { + "description": "Optional override for notification delay in milliseconds", + "type": "number" + } + }, + "required": [ + "enabled" + ], + "additionalProperties": false + }, + "permission_asked": { + "default": { + "enabled": true + }, + "description": "Notification when permission is requested", + "type": "object", + "properties": { + "enabled": { + "default": true, + "description": "Enable notifications for this event type", + "type": "boolean" + }, + "delay": { + "description": "Optional override for notification delay in milliseconds", + "type": "number" + } + }, + "required": [ + "enabled" + ], + "additionalProperties": false + }, + "question_asked": { + "default": { + "enabled": true + }, + "description": "Notification when a question is asked", + "type": "object", + "properties": { + "enabled": { + "default": true, + "description": "Enable notifications for this event type", + "type": "boolean" + }, + "delay": { + "description": "Optional override for notification delay in milliseconds", + "type": "number" + } + }, + "required": [ + "enabled" + ], + "additionalProperties": false + } + }, + "required": [ + "delay", + "enabled", + "response_ready", + "error", + "permission_asked", + "question_asked" + ], + "additionalProperties": false +} \ No newline at end of file diff --git a/scripts/generate-schema.ts b/scripts/generate-schema.ts index 09e0764..4978626 100644 --- a/scripts/generate-schema.ts +++ b/scripts/generate-schema.ts @@ -1,10 +1,24 @@ import * as z from "zod"; import { ConfigSchema } from "../src/config/schema"; +// Read package.json to get version for schema $id +const packageJsonPath = `${import.meta.dir}/../package.json`; +const packageJson = await Bun.file(packageJsonPath).json(); +const version = packageJson.version; + const jsonSchema = z.toJSONSchema(ConfigSchema, { target: "draft-2020-12", unrepresentable: "throw", }); +// Add $id with versioned URL for npm/unpkg CDN +// This allows users to reference specific schema versions +const schemaWithId = { + $id: `https://unpkg.com/opencode-notification@${version}/schema/notification-plugin.json`, + ...jsonSchema, +}; + const outputPath = `${import.meta.dir}/../schema/notification-plugin.json`; -await Bun.write(outputPath, JSON.stringify(jsonSchema, null, 2)); +await Bun.write(outputPath, JSON.stringify(schemaWithId, null, 2)); + +console.log(`Generated JSON schema: schema/notification-plugin.json (version ${version})`); From ead5f187846007929119c357e5ac906ed7a120f2 Mon Sep 17 00:00:00 2001 From: Idris Gadi Date: Thu, 12 Mar 2026 12:43:14 +0530 Subject: [PATCH 05/13] feat: load config and resolve values --- index.ts | 27 +++++++++--- src/config/loader.ts | 43 ++++++++++++++----- src/config/resolver.ts | 77 +++++++++++++++++++++++++++++++++++ src/config/schema.ts | 1 + src/notification-scheduler.ts | 21 ++++++---- 5 files changed, 146 insertions(+), 23 deletions(-) create mode 100644 src/config/resolver.ts diff --git a/index.ts b/index.ts index 55ea84b..b66783b 100644 --- a/index.ts +++ b/index.ts @@ -1,9 +1,25 @@ import path from "node:path"; import type { Plugin } from "@opencode-ai/plugin"; import { createNotificationScheduler } from "@/notification-scheduler"; +import { loadConfig, ConfigError } from "@/config/loader"; +import { createResolvedConfig } from "@/config/resolver"; export const SimpleNotificationPlugin: Plugin = async ({ client }) => { - const scheduler = createNotificationScheduler(); + const config = await loadConfig().catch(async (error) => { + const message = + error instanceof ConfigError + ? `Notification plugin config error: ${error.message}` + : `Notification plugin failed to load: ${error instanceof Error ? error.message : String(error)}`; + + await client.tui.showToast({ + body: { message, variant: "error" }, + }); + + throw error; + }); + + const resolvedConfig = createResolvedConfig(config); + const scheduler = createNotificationScheduler(resolvedConfig); // Tracks sessions where assistant has responded since last user message const activeSessions = new Set(); @@ -22,7 +38,7 @@ export const SimpleNotificationPlugin: Plugin = async ({ client }) => { .get({ path: { id: sessionId } }) .then((details) => details.data?.title) .catch(() => undefined); - scheduler.schedule(sessionId, "Response ready", title ?? sessionId); + scheduler.schedule(sessionId, "Response ready", title ?? sessionId, "session.idle"); } activeSessions.delete(sessionId); break; @@ -37,7 +53,7 @@ export const SimpleNotificationPlugin: Plugin = async ({ client }) => { .catch(() => undefined) : (event.properties.error?.data.message as string); if (sessionId) { - scheduler.schedule(sessionId, "Session error", message ?? sessionId); + scheduler.schedule(sessionId, "Session error", message ?? sessionId, "session.error"); } break; } @@ -58,6 +74,7 @@ export const SimpleNotificationPlugin: Plugin = async ({ client }) => { sessionId, "Permission Asked", `${session?.title} in ${projectName} needs permission`, + "permission.asked", ); break; } @@ -78,6 +95,7 @@ export const SimpleNotificationPlugin: Plugin = async ({ client }) => { sessionId, "Question", `${session?.title} in ${projectName} has a question`, + "question.asked", ); break; } @@ -95,8 +113,6 @@ export const SimpleNotificationPlugin: Plugin = async ({ client }) => { case "message.updated": { const info = event.properties.info; if (info.role === "user") { - // Only cancel for real user messages, not automatic system messages - // System messages have 'agent' or 'model' fields const infoAny = info; const isAutomaticMessage = infoAny.agent || infoAny.model; if (!isAutomaticMessage) { @@ -119,7 +135,6 @@ export const SimpleNotificationPlugin: Plugin = async ({ client }) => { case "tui.prompt.append": case "tui.command.execute": - // No sessionID in these events, can't cancel reliably break; } }, diff --git a/src/config/loader.ts b/src/config/loader.ts index 4e92a05..0bc2e37 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -1,6 +1,16 @@ import { homedir } from "node:os"; import { ConfigSchema, type Config } from "@/config/schema"; +/** + * Custom error class for configuration-related errors. + */ +export class ConfigError extends Error { + constructor(message: string) { + super(message); + this.name = "ConfigError"; + } +} + /** * Recursively merges objects. Arrays are replaced, not merged. * Null/undefined values in source do not overwrite existing values. @@ -76,6 +86,14 @@ export async function discoverConfigFiles(projectRoot?: string): Promise { const filePaths = await discoverConfigFiles(projectRoot); const configs: Record[] = []; @@ -86,23 +104,28 @@ export async function loadConfig(projectRoot?: string): Promise { if (content && typeof content === "object") { configs.push(content as Record); } - } catch { - // Log config loading errors - these are typically file not found or JSON parse errors - // which are recoverable (we'll use defaults) + } catch (error) { + // JSON parse errors are fatal - user has a config file but it's broken + if (error instanceof SyntaxError) { + throw new ConfigError(`Invalid JSON in ${filePath}: ${error.message}`); + } + // Other errors (e.g., file not readable) are also fatal + throw new ConfigError( + `Failed to read config file ${filePath}: ${error instanceof Error ? error.message : String(error)}`, + ); } } - const mergedConfig = configs.reduce( - (acc, config) => deepMerge(acc as Record, config), - {} as Record, + const mergedConfig = configs.reduce>( + (acc, config) => deepMerge(acc, config), + {}, ); try { return ConfigSchema.parse(mergedConfig); } catch (error) { - // Config validation errors are logged but we continue with defaults - // This ensures the plugin remains functional even with invalid config - console.error("Config validation failed, using defaults:", error); - return ConfigSchema.parse({}); + // Zod validation errors - rethrow as ConfigError for consistent error handling + const message = error instanceof Error ? error.message : String(error); + throw new ConfigError(`Config validation failed: ${message}`); } } diff --git a/src/config/resolver.ts b/src/config/resolver.ts new file mode 100644 index 0000000..8b26702 --- /dev/null +++ b/src/config/resolver.ts @@ -0,0 +1,77 @@ +import type { Config, EventConfig } from "@/config/schema"; +import type { Event } from "@opencode-ai/sdk"; + +type EventType = Event["type"] | "permission.asked" | "permission.replied" | "question.asked"; +const EVENT_CONFIG_MAP: Record = { + "session.idle": "response_ready", + "session.error": "error", + "permission.asked": "permission_asked", + "question.asked": "question_asked", +}; + +function isEventConfig(value: unknown): value is EventConfig { + return typeof value === "object" && value !== null && "enabled" in value; +} + +export interface ResolvedEventConfig { + enabled: boolean; + delay: number; +} + +export interface ResolvedConfig { + readonly globalEnabled: boolean; + getEventConfig(eventType: EventType): ResolvedEventConfig; + isEnabled(eventType: EventType): boolean; + getDelay(eventType: EventType): number; +} + +export function createResolvedConfig(config: Config): ResolvedConfig { + return { + get globalEnabled() { + return config.enabled; + }, + + getEventConfig(eventType: EventType): ResolvedEventConfig { + if (!config.enabled) { + return { enabled: false, delay: config.delay }; + } + + const configKey = EVENT_CONFIG_MAP[eventType]; + const rawEventConfig = configKey ? config[configKey] : undefined; + const eventConfig = isEventConfig(rawEventConfig) ? rawEventConfig : undefined; + + if (!eventConfig) { + return { enabled: config.enabled, delay: config.delay }; + } + + return { + enabled: eventConfig.enabled ?? true, + delay: eventConfig.delay ?? config.delay, + }; + }, + + isEnabled(eventType: EventType): boolean { + if (!config.enabled) { + return false; + } + + const configKey = EVENT_CONFIG_MAP[eventType]; + const rawEventConfig = configKey ? config[configKey] : undefined; + const eventConfig = isEventConfig(rawEventConfig) ? rawEventConfig : undefined; + + if (!eventConfig) { + return config.enabled; + } + + return eventConfig.enabled ?? true; + }, + + getDelay(eventType: EventType): number { + const configKey = EVENT_CONFIG_MAP[eventType]; + const rawEventConfig = configKey ? config[configKey] : undefined; + const eventConfig = isEventConfig(rawEventConfig) ? rawEventConfig : undefined; + + return eventConfig?.delay ?? config.delay; + }, + }; +} diff --git a/src/config/schema.ts b/src/config/schema.ts index 2c83890..54d5e46 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -20,4 +20,5 @@ export const ConfigSchema = z.object({ ), }); +export type EventConfig = z.infer; export type Config = z.infer; diff --git a/src/notification-scheduler.ts b/src/notification-scheduler.ts index ffe2c66..3b04f5b 100644 --- a/src/notification-scheduler.ts +++ b/src/notification-scheduler.ts @@ -1,32 +1,39 @@ +import type { Event } from "@opencode-ai/sdk"; +import type { ResolvedConfig } from "@/config/resolver"; import { Notification } from "@/notification"; -export const DELAY_MS = 15 * 1000; +type EventType = Event["type"] | "permission.asked" | "permission.replied" | "question.asked"; +type Schedule = (sessionId: string, title: string, message: string, eventType: EventType) => void; interface PendingNotification { title: string; message: string; timeoutId: ReturnType; } - -export interface NotificationScheduler { - schedule: (sessionId: string, title: string, message: string) => void; +interface NotificationScheduler { + schedule: Schedule; cancelForSession: (sessionId: string) => void; cancelAll: () => void; } -export function createNotificationScheduler(): NotificationScheduler { +export function createNotificationScheduler(resolvedConfig: ResolvedConfig): NotificationScheduler { const pendingBySession = new Map(); - const schedule = (sessionId: string, title: string, message: string): void => { + const schedule: Schedule = (sessionId, title, message, eventType) => { + if (eventType && !resolvedConfig.isEnabled(eventType)) { + return; + } + const existing = pendingBySession.get(sessionId); if (existing) { clearTimeout(existing.timeoutId); } + const delay = resolvedConfig.getDelay(eventType); const timeoutId = setTimeout(() => { Notification.notify({ title, message }); pendingBySession.delete(sessionId); - }, DELAY_MS); + }, delay); pendingBySession.set(sessionId, { title, message, timeoutId }); }; From 26fc8c9acf54c1600af7ae7f985828ab38754d41 Mon Sep 17 00:00:00 2001 From: Idris Gadi Date: Thu, 12 Mar 2026 13:55:32 +0530 Subject: [PATCH 06/13] feat: move delay to seconds --- src/config/resolver.ts | 11 ++++------- src/config/schema.ts | 6 +++--- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/config/resolver.ts b/src/config/resolver.ts index 8b26702..a35790a 100644 --- a/src/config/resolver.ts +++ b/src/config/resolver.ts @@ -1,5 +1,5 @@ -import type { Config, EventConfig } from "@/config/schema"; import type { Event } from "@opencode-ai/sdk"; +import type { Config, EventConfig } from "@/config/schema"; type EventType = Event["type"] | "permission.asked" | "permission.replied" | "question.asked"; const EVENT_CONFIG_MAP: Record = { @@ -59,19 +59,16 @@ export function createResolvedConfig(config: Config): ResolvedConfig { const rawEventConfig = configKey ? config[configKey] : undefined; const eventConfig = isEventConfig(rawEventConfig) ? rawEventConfig : undefined; - if (!eventConfig) { - return config.enabled; - } - - return eventConfig.enabled ?? true; + return eventConfig?.enabled ?? config.enabled; }, getDelay(eventType: EventType): number { const configKey = EVENT_CONFIG_MAP[eventType]; const rawEventConfig = configKey ? config[configKey] : undefined; const eventConfig = isEventConfig(rawEventConfig) ? rawEventConfig : undefined; + const delayMS = eventConfig?.delay ?? config.delay; - return eventConfig?.delay ?? config.delay; + return delayMS * 1000; }, }; } diff --git a/src/config/schema.ts b/src/config/schema.ts index 54d5e46..31c921f 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -2,14 +2,14 @@ import * as z from "zod"; export const EventConfigSchema = z.object({ enabled: z.boolean().default(true).describe("Enable notifications for this event type"), - delay: z.number().optional().describe("Optional override for notification delay in milliseconds"), + delay: z.number().optional().describe("Optional override for notification delay in seconds"), }); export const ConfigSchema = z.object({ - delay: z.number().default(15000).describe("Default notification delay in milliseconds"), + delay: z.number().default(15).describe("Default notification delay in seconds"), enabled: z.boolean().default(true).describe("Master switch to enable/disable all notifications"), response_ready: EventConfigSchema.default({ enabled: true }).describe( - "Notification when AI response is ready (session.idle event)", + "Notification when AI response is ready", ), error: EventConfigSchema.default({ enabled: true }).describe("Notification on session error"), permission_asked: EventConfigSchema.default({ enabled: true }).describe( From 9bab05c30783c98e0f125478db8756b64806aeba Mon Sep 17 00:00:00 2001 From: Idris Gadi Date: Thu, 12 Mar 2026 14:14:55 +0530 Subject: [PATCH 07/13] chore: cleanup not required get functions --- src/config/resolver.ts | 55 ++++++++++++------------------------------ 1 file changed, 16 insertions(+), 39 deletions(-) diff --git a/src/config/resolver.ts b/src/config/resolver.ts index a35790a..c909452 100644 --- a/src/config/resolver.ts +++ b/src/config/resolver.ts @@ -2,70 +2,47 @@ import type { Event } from "@opencode-ai/sdk"; import type { Config, EventConfig } from "@/config/schema"; type EventType = Event["type"] | "permission.asked" | "permission.replied" | "question.asked"; -const EVENT_CONFIG_MAP: Record = { +const EVENT_CONFIG_MAP: Record = { "session.idle": "response_ready", "session.error": "error", "permission.asked": "permission_asked", "question.asked": "question_asked", }; -function isEventConfig(value: unknown): value is EventConfig { - return typeof value === "object" && value !== null && "enabled" in value; -} - -export interface ResolvedEventConfig { - enabled: boolean; - delay: number; -} - export interface ResolvedConfig { readonly globalEnabled: boolean; - getEventConfig(eventType: EventType): ResolvedEventConfig; isEnabled(eventType: EventType): boolean; getDelay(eventType: EventType): number; } export function createResolvedConfig(config: Config): ResolvedConfig { + const globalEnabled = config.enabled; + return { get globalEnabled() { - return config.enabled; + return globalEnabled; }, - getEventConfig(eventType: EventType): ResolvedEventConfig { - if (!config.enabled) { - return { enabled: false, delay: config.delay }; - } - + isEnabled(eventType: EventType): boolean { const configKey = EVENT_CONFIG_MAP[eventType]; - const rawEventConfig = configKey ? config[configKey] : undefined; - const eventConfig = isEventConfig(rawEventConfig) ? rawEventConfig : undefined; + // TS infers config[keyof Config] as union; cast safe since Zod validates + configKey from EVENT_CONFIG_MAP + const eventConfig: EventConfig | undefined = configKey + ? (config[configKey] as EventConfig) + : undefined; - if (!eventConfig) { - return { enabled: config.enabled, delay: config.delay }; + if (eventConfig?.enabled) { + return eventConfig.enabled; } - return { - enabled: eventConfig.enabled ?? true, - delay: eventConfig.delay ?? config.delay, - }; - }, - - isEnabled(eventType: EventType): boolean { - if (!config.enabled) { - return false; - } - - const configKey = EVENT_CONFIG_MAP[eventType]; - const rawEventConfig = configKey ? config[configKey] : undefined; - const eventConfig = isEventConfig(rawEventConfig) ? rawEventConfig : undefined; - - return eventConfig?.enabled ?? config.enabled; + return globalEnabled; }, getDelay(eventType: EventType): number { const configKey = EVENT_CONFIG_MAP[eventType]; - const rawEventConfig = configKey ? config[configKey] : undefined; - const eventConfig = isEventConfig(rawEventConfig) ? rawEventConfig : undefined; + // TS infers config[keyof Config] as union; cast safe since Zod validates + configKey from EVENT_CONFIG_MAP + const eventConfig: EventConfig | undefined = configKey + ? (config[configKey] as EventConfig) + : undefined; const delayMS = eventConfig?.delay ?? config.delay; return delayMS * 1000; From 5ef4d1952a4d7132584614a55b92d4cb1d245703 Mon Sep 17 00:00:00 2001 From: Idris Gadi Date: Thu, 12 Mar 2026 14:32:28 +0530 Subject: [PATCH 08/13] feat: move config file name to a constant --- scripts/generate-schema.ts | 7 ++++--- src/config/constants.ts | 1 + src/config/loader.ts | 9 +++++---- 3 files changed, 10 insertions(+), 7 deletions(-) create mode 100644 src/config/constants.ts diff --git a/scripts/generate-schema.ts b/scripts/generate-schema.ts index 4978626..5d85749 100644 --- a/scripts/generate-schema.ts +++ b/scripts/generate-schema.ts @@ -1,5 +1,6 @@ import * as z from "zod"; import { ConfigSchema } from "../src/config/schema"; +import { CONFIG_FILE_NAME } from "../src/config/constants"; // Read package.json to get version for schema $id const packageJsonPath = `${import.meta.dir}/../package.json`; @@ -14,11 +15,11 @@ const jsonSchema = z.toJSONSchema(ConfigSchema, { // Add $id with versioned URL for npm/unpkg CDN // This allows users to reference specific schema versions const schemaWithId = { - $id: `https://unpkg.com/opencode-notification@${version}/schema/notification-plugin.json`, + $id: `https://unpkg.com/opencode-notification@${version}/schema/${CONFIG_FILE_NAME}`, ...jsonSchema, }; -const outputPath = `${import.meta.dir}/../schema/notification-plugin.json`; +const outputPath = `${import.meta.dir}/../schema/${CONFIG_FILE_NAME}`; await Bun.write(outputPath, JSON.stringify(schemaWithId, null, 2)); -console.log(`Generated JSON schema: schema/notification-plugin.json (version ${version})`); +console.log(`Generated JSON schema: schema/${CONFIG_FILE_NAME} (version ${version})`); diff --git a/src/config/constants.ts b/src/config/constants.ts new file mode 100644 index 0000000..e1adeb1 --- /dev/null +++ b/src/config/constants.ts @@ -0,0 +1 @@ +export const CONFIG_FILE_NAME = "oc-notification.json"; diff --git a/src/config/loader.ts b/src/config/loader.ts index 0bc2e37..d44062d 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -1,5 +1,6 @@ import { homedir } from "node:os"; import { ConfigSchema, type Config } from "@/config/schema"; +import { CONFIG_FILE_NAME } from "@/config/constants"; /** * Custom error class for configuration-related errors. @@ -62,8 +63,8 @@ export async function discoverConfigFiles(projectRoot?: string): Promise "a\b") - `${process.env.APPDATA || homedir()}\\notification-plugin.json`.replace(/\//g, "\\") // windows - : `${process.env.XDG_CONFIG_HOME || `${homedir()}/.config`}/notification-plugin.json`; // linux/macos + `${process.env.APPDATA || homedir()}\\${CONFIG_FILE_NAME}`.replace(/\//g, "\\") // windows + : `${process.env.XDG_CONFIG_HOME || `${homedir()}/.config`}/${CONFIG_FILE_NAME}`; // linux/macos if (await Bun.file(globalPath).exists()) { paths.push(globalPath); @@ -71,13 +72,13 @@ export async function discoverConfigFiles(projectRoot?: string): Promise Date: Thu, 12 Mar 2026 14:35:30 +0530 Subject: [PATCH 09/13] feat: rename config and setup npm publish --- package.json | 7 +++++-- ...fication-plugin.json => oc-notification.json} | 16 ++++++++-------- 2 files changed, 13 insertions(+), 10 deletions(-) rename schema/{notification-plugin.json => oc-notification.json} (88%) diff --git a/package.json b/package.json index a2f2cae..3e5f642 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "url": "git+https://github.com/IdrisGit/opencode-notification.git" }, "files": [ - "dist" + "dist", + "schema" ], "type": "module", "main": "dist/index.js", @@ -28,7 +29,9 @@ } }, "scripts": { - "build": "bun run clean && bun build index.ts --outdir dist --target bun --format esm && bunx tsc -p tsconfig.build.json --emitDeclarationOnly", + "prepack": "bun run build", + "build": "bun run clean && bun run generate-schema && bun build index.ts --outdir dist --target bun --format esm && bunx tsc -p tsconfig.build.json --emitDeclarationOnly", + "generate-schema": "bun scripts/generate-schema.ts", "clean": "rm -rf dist", "lint": "oxlint .", "fmt": "oxfmt", diff --git a/schema/notification-plugin.json b/schema/oc-notification.json similarity index 88% rename from schema/notification-plugin.json rename to schema/oc-notification.json index 224e77d..efa36be 100644 --- a/schema/notification-plugin.json +++ b/schema/oc-notification.json @@ -1,11 +1,11 @@ { - "$id": "https://unpkg.com/opencode-notification@0.0.3/schema/notification-plugin.json", + "$id": "https://unpkg.com/opencode-notification@0.0.3/schema/oc-notification.json", "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { "delay": { - "default": 15000, - "description": "Default notification delay in milliseconds", + "default": 15, + "description": "Default notification delay in seconds", "type": "number" }, "enabled": { @@ -17,7 +17,7 @@ "default": { "enabled": true }, - "description": "Notification when AI response is ready (session.idle event)", + "description": "Notification when AI response is ready", "type": "object", "properties": { "enabled": { @@ -26,7 +26,7 @@ "type": "boolean" }, "delay": { - "description": "Optional override for notification delay in milliseconds", + "description": "Optional override for notification delay in seconds", "type": "number" } }, @@ -48,7 +48,7 @@ "type": "boolean" }, "delay": { - "description": "Optional override for notification delay in milliseconds", + "description": "Optional override for notification delay in seconds", "type": "number" } }, @@ -70,7 +70,7 @@ "type": "boolean" }, "delay": { - "description": "Optional override for notification delay in milliseconds", + "description": "Optional override for notification delay in seconds", "type": "number" } }, @@ -92,7 +92,7 @@ "type": "boolean" }, "delay": { - "description": "Optional override for notification delay in milliseconds", + "description": "Optional override for notification delay in seconds", "type": "number" } }, From 16affb04ad1c541c017d7bf33f2cee442ff1f4dd Mon Sep 17 00:00:00 2001 From: Idris Gadi Date: Thu, 12 Mar 2026 14:40:44 +0530 Subject: [PATCH 10/13] ci: update release to use prepack and move duplicate version check before installing --- .github/workflows/release.yml | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b5d2330..07dcacf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,14 +19,11 @@ jobs: with: bun-version: latest - - name: Install dependencies - run: bun install - - - name: Lint - run: bun run lint + - uses: actions/setup-node@v4 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" - - name: Build - run: bun run build - name: Prevent duplicate publish run: | if npm view opencode-notification@$(node -p "require('./package.json').version") version; then @@ -34,10 +31,11 @@ jobs: exit 1 fi - - uses: actions/setup-node@v4 - with: - node-version: "24" - registry-url: "https://registry.npmjs.org" + - name: Install dependencies + run: bun install + + - name: Lint + run: bun run lint - name: Publish to npm run: npm publish --access public From b811cb04ddf9d167373d9c052475dc0b08720ddb Mon Sep 17 00:00:00 2001 From: Idris Gadi Date: Thu, 12 Mar 2026 14:54:54 +0530 Subject: [PATCH 11/13] chore: use absolute imports --- scripts/generate-schema.ts | 4 ++-- tsconfig.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/generate-schema.ts b/scripts/generate-schema.ts index 5d85749..10d41f9 100644 --- a/scripts/generate-schema.ts +++ b/scripts/generate-schema.ts @@ -1,6 +1,6 @@ import * as z from "zod"; -import { ConfigSchema } from "../src/config/schema"; -import { CONFIG_FILE_NAME } from "../src/config/constants"; +import { CONFIG_FILE_NAME } from "@/config/constants"; +import { ConfigSchema } from "@/config/schema"; // Read package.json to get version for schema $id const packageJsonPath = `${import.meta.dir}/../package.json`; diff --git a/tsconfig.json b/tsconfig.json index f131acf..e3a1b66 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,6 +24,6 @@ "@/*": ["src/*"] } }, - "include": ["*.ts", "src/**/*.ts"], + "include": ["*.ts", "src/**/*.ts", "scripts/**/*.ts"], "exclude": ["node_modules", "dist"] } From 77551fa0f129d6e641870201511a0afb53e06551 Mon Sep 17 00:00:00 2001 From: Idris Gadi Date: Thu, 12 Mar 2026 14:55:13 +0530 Subject: [PATCH 12/13] feat: use zod io input and inject $schema --- schema/oc-notification.json | 39 +++++++++---------------------------- scripts/generate-schema.ts | 7 +++++++ 2 files changed, 16 insertions(+), 30 deletions(-) diff --git a/schema/oc-notification.json b/schema/oc-notification.json index efa36be..8442641 100644 --- a/schema/oc-notification.json +++ b/schema/oc-notification.json @@ -29,11 +29,7 @@ "description": "Optional override for notification delay in seconds", "type": "number" } - }, - "required": [ - "enabled" - ], - "additionalProperties": false + } }, "error": { "default": { @@ -51,11 +47,7 @@ "description": "Optional override for notification delay in seconds", "type": "number" } - }, - "required": [ - "enabled" - ], - "additionalProperties": false + } }, "permission_asked": { "default": { @@ -73,11 +65,7 @@ "description": "Optional override for notification delay in seconds", "type": "number" } - }, - "required": [ - "enabled" - ], - "additionalProperties": false + } }, "question_asked": { "default": { @@ -95,20 +83,11 @@ "description": "Optional override for notification delay in seconds", "type": "number" } - }, - "required": [ - "enabled" - ], - "additionalProperties": false + } + }, + "$schema": { + "type": "string", + "description": "JSON Schema URL for editor autocomplete and validation" } - }, - "required": [ - "delay", - "enabled", - "response_ready", - "error", - "permission_asked", - "question_asked" - ], - "additionalProperties": false + } } \ No newline at end of file diff --git a/scripts/generate-schema.ts b/scripts/generate-schema.ts index 10d41f9..4dc2820 100644 --- a/scripts/generate-schema.ts +++ b/scripts/generate-schema.ts @@ -8,9 +8,16 @@ const packageJson = await Bun.file(packageJsonPath).json(); const version = packageJson.version; const jsonSchema = z.toJSONSchema(ConfigSchema, { + io: "input", target: "draft-2020-12", unrepresentable: "throw", }); +const rootSchema = jsonSchema as z.core.JSONSchema.ObjectSchema; +rootSchema.properties ??= {}; +rootSchema.properties.$schema = { + type: "string", + description: "JSON Schema URL for editor autocomplete and validation", +}; // Add $id with versioned URL for npm/unpkg CDN // This allows users to reference specific schema versions From cb139a5750e4e282c19b218e7acfa12d581c57bc Mon Sep 17 00:00:00 2001 From: Idris Gadi Date: Thu, 12 Mar 2026 15:01:31 +0530 Subject: [PATCH 13/13] fix: formatting --- schema/oc-notification.json | 184 ++++++++++++++++++------------------ 1 file changed, 92 insertions(+), 92 deletions(-) diff --git a/schema/oc-notification.json b/schema/oc-notification.json index 8442641..e2ec10f 100644 --- a/schema/oc-notification.json +++ b/schema/oc-notification.json @@ -1,93 +1,93 @@ { - "$id": "https://unpkg.com/opencode-notification@0.0.3/schema/oc-notification.json", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "delay": { - "default": 15, - "description": "Default notification delay in seconds", - "type": "number" - }, - "enabled": { - "default": true, - "description": "Master switch to enable/disable all notifications", - "type": "boolean" - }, - "response_ready": { - "default": { - "enabled": true - }, - "description": "Notification when AI response is ready", - "type": "object", - "properties": { - "enabled": { - "default": true, - "description": "Enable notifications for this event type", - "type": "boolean" - }, - "delay": { - "description": "Optional override for notification delay in seconds", - "type": "number" - } - } - }, - "error": { - "default": { - "enabled": true - }, - "description": "Notification on session error", - "type": "object", - "properties": { - "enabled": { - "default": true, - "description": "Enable notifications for this event type", - "type": "boolean" - }, - "delay": { - "description": "Optional override for notification delay in seconds", - "type": "number" - } - } - }, - "permission_asked": { - "default": { - "enabled": true - }, - "description": "Notification when permission is requested", - "type": "object", - "properties": { - "enabled": { - "default": true, - "description": "Enable notifications for this event type", - "type": "boolean" - }, - "delay": { - "description": "Optional override for notification delay in seconds", - "type": "number" - } - } - }, - "question_asked": { - "default": { - "enabled": true - }, - "description": "Notification when a question is asked", - "type": "object", - "properties": { - "enabled": { - "default": true, - "description": "Enable notifications for this event type", - "type": "boolean" - }, - "delay": { - "description": "Optional override for notification delay in seconds", - "type": "number" - } - } - }, - "$schema": { - "type": "string", - "description": "JSON Schema URL for editor autocomplete and validation" - } - } -} \ No newline at end of file + "$id": "https://unpkg.com/opencode-notification@0.0.3/schema/oc-notification.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "delay": { + "default": 15, + "description": "Default notification delay in seconds", + "type": "number" + }, + "enabled": { + "default": true, + "description": "Master switch to enable/disable all notifications", + "type": "boolean" + }, + "response_ready": { + "default": { + "enabled": true + }, + "description": "Notification when AI response is ready", + "type": "object", + "properties": { + "enabled": { + "default": true, + "description": "Enable notifications for this event type", + "type": "boolean" + }, + "delay": { + "description": "Optional override for notification delay in seconds", + "type": "number" + } + } + }, + "error": { + "default": { + "enabled": true + }, + "description": "Notification on session error", + "type": "object", + "properties": { + "enabled": { + "default": true, + "description": "Enable notifications for this event type", + "type": "boolean" + }, + "delay": { + "description": "Optional override for notification delay in seconds", + "type": "number" + } + } + }, + "permission_asked": { + "default": { + "enabled": true + }, + "description": "Notification when permission is requested", + "type": "object", + "properties": { + "enabled": { + "default": true, + "description": "Enable notifications for this event type", + "type": "boolean" + }, + "delay": { + "description": "Optional override for notification delay in seconds", + "type": "number" + } + } + }, + "question_asked": { + "default": { + "enabled": true + }, + "description": "Notification when a question is asked", + "type": "object", + "properties": { + "enabled": { + "default": true, + "description": "Enable notifications for this event type", + "type": "boolean" + }, + "delay": { + "description": "Optional override for notification delay in seconds", + "type": "number" + } + } + }, + "$schema": { + "type": "string", + "description": "JSON Schema URL for editor autocomplete and validation" + } + } +}