// Shraga — public library surface. // // The FIRST-CLASS way to consume Shraga: `import { createShraga } from 'shraga'`, configure with // typed options, register/extend programmatically through the same seams the built-ins use // (features, engines, the event bus, verified webhooks, data-side extensions), then `start()`. // // import { createShraga } from 'shraga'; // const app = createShraga({ port: 3032, dataDir: './data', authProvider: 'local' }); // app.registerFeature(myFeature); // app.on('gmail', (payload) => { … }); // await app.start(); // // … app.stop() to shut down. // // The run-from-source entry (`src/server/index.ts`, used by prod `bun run src/server/index.ts`) and // the `shraga` CLI both dogfood this exact surface — createShraga(fromEnv()).start(). File-based // `data/extensions/*.ext.ts` drop-ins and `SHRAGA_OVERLAY` keep working: they reach the SAME // programmatic registries, just via a different door. // Type-only imports below: importing this module must NOT eagerly load the server graph (which // resolves DATA_DIR at import time). The runtime boot is a dynamic import inside start(), AFTER // options have been mapped onto the environment. import type { BootRegistrations, ServerHandle } from './server/boot.ts'; import type { ServerFeature, FeatureContext } from './server/features.ts'; import type { AgentEngine, EngineModel, EngineStreamOpts } from './server/engine/types.ts'; import type { ExtRegisterFn, ExtensionContext } from './server/extensions.ts'; import type { WebhookOptions } from './server/events/webhook.ts'; import type { ShragaEvent, ShragaEventMap, PayloadOf } from './server/events/types.ts'; import type { PolicyOptions, PolicyFile } from './server/security/policy.ts'; export type { PolicyFile, ServerHandle, ServerFeature, FeatureContext, AgentEngine, EngineModel, EngineStreamOpts, ExtRegisterFn, ExtensionContext, WebhookOptions, ShragaEvent, ShragaEventMap, PayloadOf, }; /** Typed configuration for a Shraga instance. Follows the repo's ModuleOptions convention: * defaults live here and are merged with the caller's partial. Anything not modelled is still * reachable via `env` (Shraga is heavily env-driven) — those are set before the server boots. */ export class ShragaOptions { /** HTTP/WS port. Default 3032 (or PORT env). */ port?: number; /** Data directory (flat-file storage root). Default ./data (or DATA_DIR env). */ dataDir?: string; /** Auth backend. 'local' (default) = username/password; 'firebase' needs the firebase add-on. */ authProvider?: 'local' | 'firebase'; /** Directory of the built client to serve (index.html + assets). Default = shraga's shipped * `dist/client`. A consumer shipping its own UI (e.g. the EE client build) points this at its * own dist. Precedence: this option > SHRAGA_CLIENT_DIR env > default. */ clientDir?: string; /** Passive mode — HTTP serving only, no schedulers/consumers/background writers (standby twins). */ passive?: boolean; /** Install process SIGTERM/SIGINT handlers (default true — the standalone server/CLI wants them). * A library embedder owning its own lifecycle sets false and uses stop(). */ installSignalHandlers?: boolean = true; /** OPT-IN runtime plug-and-play (default false). When true, the ServerHandle returned by start() * can register extensions/webhooks and subscribe to events AFTER boot (they mount on the live * extension Router / event bus). When false, those handle methods throw. Pre-start registration is * unaffected either way. Features & engines are NEVER runtime-registerable (they mount at boot). */ runtimeRegistration?: boolean = false; /** Arbitrary extra environment to apply before boot (e.g. ANTHROPIC_API_KEY, SHRAGA_FEAT_*). */ env?: Record; /** Security policy seams. `migrate` seeds `data/security/policy.json` ONCE, when it's first generated (no file, no * `.migrated` marker): it receives the default draft (legacy whitelist already bound as operator) and returns the * final draft. The result is validated and saved like any policy; an invalid draft or a throw fails closed (owners * only). Never runs on a PASSIVE standby (it runs on promotion if the file is still missing). Later edits go through * the Owner Console, not this hook. */ security?: { migrate?: PolicyOptions['migrate'] }; } export interface ShragaInstance { /** Register a server feature (routes/WS/consumers) — the same seam Slack uses. */ registerFeature(feature: ServerFeature): this; /** Register a pluggable agent engine (runtime). */ registerEngine(engine: AgentEngine): this; /** Register a programmatic extension — the same shape as a data/extensions/*.ext.ts default export. */ registerExtension(fn: ExtRegisterFn): this; /** Declare a verified vendor webhook (public POST /api/webhooks/ → typed event). */ registerWebhook(opts: WebhookOptions): this; /** Subscribe to a typed event source on the in-process bus. */ on(source: K, handler: (payload: PayloadOf, evt: ShragaEvent) => void): this; /** Publish an event onto the bus (after start()). */ emit(source: K, payload: PayloadOf): this; /** Boot HTTP + WS. Resolves once listening. Idempotent — repeated calls return the same handle. */ start(): Promise; /** Drain and shut down without exiting the process. */ stop(): Promise; /** The live Express app (advanced use) — available after start(). */ readonly app: ServerHandle['app'] | undefined; /** Publish onto the bus directly (advanced use) — available after start(). */ readonly emitEvent: ServerHandle['emitEvent'] | undefined; } class Shraga implements ShragaInstance { public options: ShragaOptions; private reg: Required> ={ features: [], engines: [], extensions: [], eventSubs: [] }; private handle: ServerHandle | null = null; private starting: Promise | null = null; constructor(options?: Partial) { this.options = { ...new ShragaOptions(), ...options }; // Map options onto the environment NOW, before any server module (which resolves DATA_DIR / // AUTH_PROVIDER at import time) can load. start()'s dynamic import happens strictly after this. this.applyEnv(); } private applyEnv(): void { const o = this.options; if (o.port != null) process.env.PORT = String(o.port); if (o.dataDir != null) process.env.DATA_DIR = o.dataDir; if (o.authProvider != null) process.env.AUTH_PROVIDER = o.authProvider; if (o.clientDir != null) process.env.SHRAGA_CLIENT_DIR = o.clientDir; if (o.passive != null) process.env.SHRAGA_PASSIVE = o.passive ? '1' : '0'; if (o.installSignalHandlers === false) process.env.SHRAGA_INSTALL_SIGNALS = '0'; if (o.runtimeRegistration != null) process.env.SHRAGA_RUNTIME_REGISTRATION = o.runtimeRegistration ? '1' : '0'; for (const [k, v] of Object.entries(o.env ?? {})) process.env[k] = v; } private assertNotStarted(what: string): void { if (this.handle || this.starting) throw new Error(`[shraga] ${what} must be called before start()`); } registerFeature(feature: ServerFeature): this { this.assertNotStarted('registerFeature'); this.reg.features.push(feature); return this; } registerEngine(engine: AgentEngine): this { this.assertNotStarted('registerEngine'); this.reg.engines.push(engine); return this; } registerExtension(fn: ExtRegisterFn): this { this.assertNotStarted('registerExtension'); this.reg.extensions.push(fn); return this; } registerWebhook(opts: WebhookOptions): this { this.assertNotStarted('registerWebhook'); // A webhook is just an extension that mounts a verified route on the extension router (before the // SPA catch-all) — identical to a file-based webhook. Reuse the seam; don't invent a parallel one. this.reg.extensions.push((_router, ctx: ExtensionContext) => { ctx.registerWebhook(opts); }); return this; } on(source: K, handler: (payload: PayloadOf, evt: ShragaEvent) => void): this { this.assertNotStarted('on'); this.reg.eventSubs.push({ source, handler: handler as (payload: unknown, evt: unknown) => void }); return this; } emit(source: K, payload: PayloadOf): this { if (!this.handle) throw new Error('[shraga] emit() requires start() first'); this.handle.emitEvent(source, payload); return this; } async start(): Promise { if (this.handle) return this.handle; if (this.starting) return this.starting; this.starting = (async () => { const { bootServer } = await import('./server/boot.ts'); this.handle = await bootServer({ ...this.reg, security: this.options.security }); return this.handle; })(); return this.starting; } async stop(): Promise { if (this.handle) { await this.handle.stop(); this.handle = null; } this.starting = null; } get app() { return this.handle?.app; } get emitEvent() { return this.handle?.emitEvent; } } /** Create a Shraga instance. Configure it, register/extend, then `await instance.start()`. */ export function createShraga(options?: Partial): ShragaInstance { return new Shraga(options); } /** Derive options from the environment (PORT / DATA_DIR / AUTH_PROVIDER / SHRAGA_PASSIVE). The * run-from-source entry and the CLI pass this straight into createShraga. */ export function fromEnv(): Partial { const passive = process.env.SHRAGA_PASSIVE ?? process.env.UNCLAW_PASSIVE; const runtimeReg = process.env.SHRAGA_RUNTIME_REGISTRATION; return { port: process.env.PORT ? Number(process.env.PORT) : undefined, dataDir: process.env.DATA_DIR || undefined, authProvider: (process.env.AUTH_PROVIDER as 'local' | 'firebase' | undefined) || undefined, clientDir: process.env.SHRAGA_CLIENT_DIR || undefined, passive: passive === '1' || passive === 'true' ? true : undefined, runtimeRegistration: runtimeReg === '1' || runtimeReg === 'true' ? true : undefined, }; }