/** * MercuryExtensionAPI implementation. * * Each extension gets its own instance, scoped to its name. * The API collects declarations into ExtensionMeta during setup. */ import fs from "node:fs"; import path from "node:path"; import { registerPermission } from "../core/permissions.js"; import type { Db } from "../storage/db.js"; import type { CliDef, ConfigDef, EnvDef, EventHandler, ExtensionMeta, ExtensionStore, JobDef, MercuryEvents, MercuryExtensionAPI, PermissionDef, WidgetDef, } from "./types.js"; export class MercuryExtensionAPIImpl implements MercuryExtensionAPI { private readonly meta: ExtensionMeta; constructor( readonly name: string, private readonly dir: string, private readonly db: Db, ) { this.meta = { name, dir, clis: [], hooks: new Map(), jobs: new Map(), configs: new Map(), widgets: [], envVars: [], }; } cli(opts: CliDef): void { if (!opts.name || !opts.install) { throw new Error( `Extension "${this.name}": cli() requires name and install`, ); } this.meta.clis.push(opts); } permission(opts: PermissionDef): void { if (this.meta.permission) { throw new Error( `Extension "${this.name}": permission() can only be called once`, ); } if (!Array.isArray(opts.defaultRoles)) { throw new Error( `Extension "${this.name}": permission() requires defaultRoles array`, ); } this.meta.permission = opts; registerPermission(this.name, opts); } env(def: EnvDef): void { if (!def.from) { throw new Error( `Extension "${this.name}": env() requires a "from" field`, ); } this.meta.envVars.push(def); } skill(relativePath: string): void { const absPath = path.resolve(this.dir, relativePath); const skillMd = path.join(absPath, "SKILL.md"); if (!fs.existsSync(skillMd)) { throw new Error( `Extension "${this.name}": SKILL.md not found at ${skillMd}`, ); } this.meta.skillDir = absPath; } on(event: E, handler: EventHandler): void { const handlers = this.meta.hooks.get(event); if (handlers) { handlers.push(handler as EventHandler); } else { this.meta.hooks.set(event, [ handler as EventHandler, ]); } } job(name: string, def: JobDef): void { if (!name) { throw new Error(`Extension "${this.name}": job() requires a name`); } if (this.meta.jobs.has(name)) { throw new Error( `Extension "${this.name}": job "${name}" already registered`, ); } if (!def.interval && !def.cron) { throw new Error( `Extension "${this.name}": job "${name}" requires interval or cron`, ); } if (def.interval && def.cron) { throw new Error( `Extension "${this.name}": job "${name}" cannot have both interval and cron`, ); } if (typeof def.run !== "function") { throw new Error( `Extension "${this.name}": job "${name}" requires a run function`, ); } this.meta.jobs.set(name, def); } config(key: string, def: ConfigDef): void { if (!key) { throw new Error(`Extension "${this.name}": config() requires a key`); } if (this.meta.configs.has(key)) { throw new Error( `Extension "${this.name}": config key "${key}" already registered`, ); } this.meta.configs.set(key, def); } widget(def: WidgetDef): void { if (!def.label) { throw new Error(`Extension "${this.name}": widget() requires a label`); } if (typeof def.render !== "function") { throw new Error( `Extension "${this.name}": widget() requires a render function`, ); } this.meta.widgets.push(def); } get store(): ExtensionStore { return { get: (key: string) => this.db.getExtState(this.name, key), set: (key: string, value: string) => this.db.setExtState(this.name, key, value), delete: (key: string) => this.db.deleteExtState(this.name, key), list: () => this.db.listExtState(this.name), }; } /** Called by the loader after setup — returns collected metadata. */ getMeta(): ExtensionMeta { return this.meta; } }