keryx 0.19.2 → 0.20.1

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/classes/API.ts CHANGED
@@ -2,7 +2,11 @@ import { Glob } from "bun";
2
2
  import fs from "fs";
3
3
  import path from "path";
4
4
  import { config } from "../config";
5
- import { deepMerge } from "../util/config";
5
+ import {
6
+ deepMerge,
7
+ deepMergeDefaults,
8
+ formatLoadedMessage,
9
+ } from "../util/config";
6
10
  import { globLoader } from "../util/glob";
7
11
  import type { Initializer, InitializerSortKeys } from "./Initializer";
8
12
  import { Logger } from "./Logger";
@@ -69,6 +73,7 @@ export class API {
69
73
  this.initialized = false;
70
74
 
71
75
  await this.loadLocalConfig();
76
+ this.loadPluginConfig();
72
77
  await this.findInitializers();
73
78
  this.sortInitializers("loadPriority");
74
79
 
@@ -205,6 +210,19 @@ export class API {
205
210
  }
206
211
  }
207
212
 
213
+ /**
214
+ * Apply plugin config defaults using deepMergeDefaults so that
215
+ * user-set config values are never overwritten by plugin defaults.
216
+ */
217
+ private loadPluginConfig() {
218
+ for (const plugin of config.plugins) {
219
+ if (plugin.configDefaults) {
220
+ deepMergeDefaults(config, plugin.configDefaults);
221
+ this.logger.debug(`Merged config defaults from plugin ${plugin.name}`);
222
+ }
223
+ }
224
+ }
225
+
208
226
  private async findInitializers() {
209
227
  // Load framework initializers from the package directory
210
228
  const frameworkInitializers = await globLoader<Initializer>(
@@ -214,6 +232,17 @@ export class API {
214
232
  this.initializers.push(i);
215
233
  }
216
234
 
235
+ // Load plugin initializers
236
+ let pluginInitializerCount = 0;
237
+ for (const plugin of config.plugins) {
238
+ if (plugin.initializers) {
239
+ for (const InitializerClass of plugin.initializers) {
240
+ this.initializers.push(new InitializerClass());
241
+ pluginInitializerCount++;
242
+ }
243
+ }
244
+ }
245
+
217
246
  // Load user project initializers (if rootDir differs from packageDir)
218
247
  if (this.rootDir !== this.packageDir) {
219
248
  try {
@@ -227,6 +256,17 @@ export class API {
227
256
  // user project may not have initializers, that's fine
228
257
  }
229
258
  }
259
+
260
+ this.logger.info(
261
+ formatLoadedMessage("initializers", {
262
+ core: frameworkInitializers.length,
263
+ plugin: pluginInitializerCount,
264
+ user:
265
+ this.initializers.length -
266
+ frameworkInitializers.length -
267
+ pluginInitializerCount,
268
+ }),
269
+ );
230
270
  }
231
271
 
232
272
  private sortInitializers(key: InitializerSortKeys) {
@@ -0,0 +1,54 @@
1
+ import type { Action } from "./Action";
2
+ import type { Channel } from "./Channel";
3
+ import type { Initializer } from "./Initializer";
4
+ import type { Server } from "./Server";
5
+
6
+ /**
7
+ * A Keryx plugin manifest. Plugins provide class constructors that the framework
8
+ * instantiates during discovery, alongside any config defaults and generator extensions.
9
+ *
10
+ * First-party plugins use the `@keryxjs/*` npm scope (e.g., `@keryxjs/resque-admin`).
11
+ * Third-party plugins follow the `keryx-plugin-*` naming convention.
12
+ *
13
+ * Register plugins via config:
14
+ * ```typescript
15
+ * // config/plugins.ts
16
+ * import { myPlugin } from "@keryxjs/my-plugin";
17
+ * export default { plugins: [myPlugin] };
18
+ * ```
19
+ */
20
+ export interface KeryxPlugin {
21
+ /** Unique plugin name (e.g., `"resque-admin"`). */
22
+ name: string;
23
+ /** SemVer version string for the plugin. */
24
+ version: string;
25
+
26
+ /** Initializer class constructors. Instantiated and merged into the lifecycle alongside framework and user initializers. */
27
+ initializers?: Array<new () => Initializer>;
28
+ /** Action class constructors. Instantiated and registered alongside user actions. */
29
+ actions?: Array<new () => Action>;
30
+ /** Channel class constructors. Instantiated and registered alongside user channels. */
31
+ channels?: Array<new () => Channel>;
32
+ /** Server class constructors. Instantiated and registered alongside framework and user servers. */
33
+ servers?: Array<new () => Server<unknown>>;
34
+
35
+ /** Config defaults merged before user config, so user overrides take precedence. */
36
+ configDefaults?: Record<string, unknown>;
37
+
38
+ /** Custom generator types for the `keryx generate` CLI command. */
39
+ generators?: PluginGenerator[];
40
+ }
41
+
42
+ /**
43
+ * A custom generator type that a plugin registers with the `keryx generate` CLI command.
44
+ */
45
+ export interface PluginGenerator {
46
+ /** The generator type name (e.g., `"graphql-resolver"`). Used as `keryx generate <type> <name>`. */
47
+ type: string;
48
+ /** The output subdirectory name relative to the project root (e.g., `"resolvers"`). */
49
+ directory: string;
50
+ /** Absolute path to the Mustache template file for the generated component. */
51
+ templatePath: string;
52
+ /** Absolute path to the Mustache template file for the generated test. If omitted, the default test template is used. */
53
+ testTemplatePath?: string;
54
+ }
package/config/index.ts CHANGED
@@ -1,8 +1,10 @@
1
+ import type { KeryxPlugin } from "../classes/Plugin";
1
2
  import { configActions } from "./actions";
2
3
  import { configChannels } from "./channels";
3
4
  import { configDatabase } from "./database";
4
5
  import { configLogger } from "./logger";
5
6
  import { configObservability } from "./observability";
7
+ import { configPlugins } from "./plugins";
6
8
  import { configProcess } from "./process";
7
9
  import { configRateLimit } from "./rateLimit";
8
10
  import { configRedis } from "./redis";
@@ -13,6 +15,7 @@ import { configSession } from "./session";
13
15
  import { configTasks } from "./tasks";
14
16
 
15
17
  export const config = {
18
+ plugins: configPlugins,
16
19
  actions: configActions,
17
20
  channels: configChannels,
18
21
  process: configProcess,
@@ -39,6 +42,7 @@ export const config = {
39
42
  * ```
40
43
  */
41
44
  export interface KeryxConfig {
45
+ plugins: KeryxPlugin[];
42
46
  actions: typeof configActions;
43
47
  channels: typeof configChannels;
44
48
  process: typeof configProcess;
@@ -0,0 +1,3 @@
1
+ import type { KeryxPlugin } from "../classes/Plugin";
2
+
3
+ export const configPlugins: KeryxPlugin[] = [];
package/index.ts CHANGED
@@ -24,6 +24,7 @@ export type { ChannelMiddleware } from "./classes/Channel";
24
24
  export { CHANNEL_NAME_PATTERN } from "./classes/Channel";
25
25
  export { Connection } from "./classes/Connection";
26
26
  export { LogLevel } from "./classes/Logger";
27
+ export type { KeryxPlugin, PluginGenerator } from "./classes/Plugin";
27
28
  export { SSEResponse, StreamingResponse } from "./classes/StreamingResponse";
28
29
  export { ErrorStatusCodes, ErrorType, TypedError } from "./classes/TypedError";
29
30
  export type { KeryxConfig } from "./config";
@@ -32,7 +33,8 @@ export { checkRateLimit, RateLimitMiddleware } from "./middleware/rateLimit";
32
33
  export { TransactionMiddleware } from "./middleware/transaction";
33
34
  export type { WebServer } from "./servers/web";
34
35
  export { buildProgram } from "./util/cli";
35
- export { deepMerge, loadFromEnvIfSet } from "./util/config";
36
+ export { deepMerge, deepMergeDefaults, loadFromEnvIfSet } from "./util/config";
37
+ export { getValidTypes } from "./util/generate";
36
38
  export { globLoader } from "./util/glob";
37
39
  export { type PaginatedResult, paginate } from "./util/pagination";
38
40
  export { toMarkdown } from "./util/toMarkdown";
@@ -6,6 +6,7 @@ import { type Action, DEFAULT_QUEUE } from "../classes/Action";
6
6
  import { Initializer } from "../classes/Initializer";
7
7
  import { ErrorType, TypedError } from "../classes/TypedError";
8
8
  import { config } from "../config";
9
+ import { formatLoadedMessage } from "../util/config";
9
10
  import { globLoader } from "../util/glob";
10
11
 
11
12
  const namespace = "actions";
@@ -85,7 +86,9 @@ export class Actions extends Initializer {
85
86
  inputs: TaskInputs = {},
86
87
  queue?: string,
87
88
  ) => {
88
- const action = api.actions.actions.find((a) => a.name === actionName);
89
+ const action = api.actions.actions.find(
90
+ (a: Action) => a.name === actionName,
91
+ );
89
92
  if (!action) {
90
93
  throw new TypedError({
91
94
  message: `action ${actionName} not found`,
@@ -140,7 +143,9 @@ export class Actions extends Initializer {
140
143
  // Validate all action names up front
141
144
  const actionNames = new Set<string>();
142
145
  for (const job of jobs) {
143
- const action = api.actions.actions.find((a) => a.name === job.action);
146
+ const action = api.actions.actions.find(
147
+ (a: Action) => a.name === job.action,
148
+ );
144
149
  if (!action) {
145
150
  throw new TypedError({
146
151
  message: `action ${job.action} not found`,
@@ -152,7 +157,9 @@ export class Actions extends Initializer {
152
157
 
153
158
  // Resolve queue per job: explicit job.queue > action's task.queue > DEFAULT_QUEUE
154
159
  const resolvedJobs = jobs.map((job) => {
155
- const action = api.actions.actions.find((a) => a.name === job.action)!;
160
+ const action = api.actions.actions.find(
161
+ (a: Action) => a.name === job.action,
162
+ )!;
156
163
  const resolvedQueue = job.queue ?? action?.task?.queue ?? DEFAULT_QUEUE;
157
164
  return { ...job, queue: resolvedQueue, inputs: job.inputs ?? {} };
158
165
  });
@@ -250,8 +257,8 @@ export class Actions extends Initializer {
250
257
  total: parseInt(meta.total, 10) || 0,
251
258
  completed: parseInt(meta.completed, 10) || 0,
252
259
  failed: parseInt(meta.failed, 10) || 0,
253
- results: rawResults.map((r) => JSON.parse(r)),
254
- errors: rawErrors.map((e) => JSON.parse(e)),
260
+ results: rawResults.map((r: string) => JSON.parse(r)),
261
+ errors: rawErrors.map((e: string) => JSON.parse(e)),
255
262
  };
256
263
  };
257
264
 
@@ -565,7 +572,9 @@ export class Actions extends Initializer {
565
572
  * Will throw an error if redis cannot be reached.
566
573
  */
567
574
  stopRecurrentAction = async (actionName: string): Promise<number> => {
568
- const action = api.actions.actions.find((a) => a.name === actionName);
575
+ const action = api.actions.actions.find(
576
+ (a: Action) => a.name === actionName,
577
+ );
569
578
  if (!action) {
570
579
  throw new TypedError({
571
580
  message: `action ${actionName} not found`,
@@ -634,14 +643,34 @@ export class Actions extends Initializer {
634
643
  };
635
644
 
636
645
  async initialize() {
637
- const actions = await globLoader<Action>(path.join(api.rootDir, "actions"));
646
+ // Load plugin actions
647
+ const pluginActions: Action[] = [];
648
+ for (const plugin of config.plugins) {
649
+ if (plugin.actions) {
650
+ for (const ActionClass of plugin.actions) {
651
+ pluginActions.push(new ActionClass());
652
+ }
653
+ }
654
+ }
655
+
656
+ // Load user actions
657
+ const userActions = await globLoader<Action>(
658
+ path.join(api.rootDir, "actions"),
659
+ );
660
+
661
+ const actions = [...pluginActions, ...userActions];
638
662
 
639
663
  for (const a of actions) {
640
664
  if (!a.description) a.description = `An Action: ${a.name}`;
641
665
  a.mcp = { tool: true, ...a.mcp };
642
666
  }
643
667
 
644
- logger.info(`loaded ${Object.keys(actions).length} actions`);
668
+ logger.info(
669
+ formatLoadedMessage("actions", {
670
+ plugin: pluginActions.length,
671
+ user: userActions.length,
672
+ }),
673
+ );
645
674
 
646
675
  return {
647
676
  actions,
@@ -5,6 +5,7 @@ import type { Connection } from "../classes/Connection";
5
5
  import { Initializer } from "../classes/Initializer";
6
6
  import { ErrorType, TypedError } from "../classes/TypedError";
7
7
  import { config } from "../config";
8
+ import { formatLoadedMessage } from "../util/config";
8
9
  import { globLoader } from "../util/glob";
9
10
 
10
11
  const namespace = "channels";
@@ -35,7 +36,7 @@ export class Channels extends Initializer {
35
36
  * Returns undefined if no matching channel is found.
36
37
  */
37
38
  findChannel = (channelName: string): Channel | undefined => {
38
- return api.channels.channels.find((c) => c.matches(channelName));
39
+ return api.channels.channels.find((c: Channel) => c.matches(channelName));
39
40
  };
40
41
 
41
42
  /**
@@ -229,11 +230,22 @@ export class Channels extends Initializer {
229
230
  join(LUA_DIR, "refresh-presence.lua"),
230
231
  ).text();
231
232
 
232
- let channels: Channel[] = [];
233
+ // Load plugin channels
234
+ const pluginChannels: Channel[] = [];
235
+ for (const plugin of config.plugins) {
236
+ if (plugin.channels) {
237
+ for (const ChannelClass of plugin.channels) {
238
+ pluginChannels.push(new ChannelClass());
239
+ }
240
+ }
241
+ }
233
242
 
234
- // Channels are always user-defined, load from rootDir only
243
+ // Load user channels
244
+ let userChannels: Channel[] = [];
235
245
  try {
236
- channels = await globLoader<Channel>(path.join(api.rootDir, "channels"));
246
+ userChannels = await globLoader<Channel>(
247
+ path.join(api.rootDir, "channels"),
248
+ );
237
249
  } catch (e) {
238
250
  // channels directory may not exist, which is fine
239
251
  logger.debug(
@@ -241,11 +253,18 @@ export class Channels extends Initializer {
241
253
  );
242
254
  }
243
255
 
256
+ const channels = [...pluginChannels, ...userChannels];
257
+
244
258
  for (const c of channels) {
245
259
  if (!c.description) c.description = `A Channel: ${c.name}`;
246
260
  }
247
261
 
248
- logger.info(`loaded ${channels.length} channels`);
262
+ logger.info(
263
+ formatLoadedMessage("channels", {
264
+ plugin: pluginChannels.length,
265
+ user: userChannels.length,
266
+ }),
267
+ );
249
268
 
250
269
  return {
251
270
  channels,
@@ -7,6 +7,7 @@ import colors from "colors";
7
7
  import { randomUUID } from "crypto";
8
8
  import * as z4mini from "zod/v4-mini";
9
9
  import { api, logger } from "../api";
10
+ import type { Action } from "../classes/Action";
10
11
  import { MCP_RESPONSE_FORMAT } from "../classes/Action";
11
12
  import { Connection } from "../classes/Connection";
12
13
  import { Initializer } from "../classes/Initializer";
@@ -46,7 +47,7 @@ function formatToolName(actionName: string): string {
46
47
  function parseToolName(toolName: string): string {
47
48
  // Reverse lookup against registered actions
48
49
  const action = api.actions.actions.find(
49
- (a) => formatToolName(a.name) === toolName,
50
+ (a: Action) => formatToolName(a.name) === toolName,
50
51
  );
51
52
  return action ? action.name : toolName;
52
53
  }
@@ -1,4 +1,5 @@
1
1
  import {
2
+ type ErrorPayload,
2
3
  type Job,
3
4
  type ParsedJob,
4
5
  Queue,
@@ -61,7 +62,7 @@ export class Resque extends Initializer {
61
62
  api.resque.jobs,
62
63
  );
63
64
 
64
- api.resque.queue.on("error", (error) => {
65
+ api.resque.queue.on("error", (error: Error) => {
65
66
  logger.error(`[resque:queue] ${error}`);
66
67
  });
67
68
 
@@ -85,7 +86,7 @@ export class Resque extends Initializer {
85
86
  retryStuckJobs: config.tasks.retryStuckJobs,
86
87
  });
87
88
 
88
- api.resque.scheduler.on("error", (error) => {
89
+ api.resque.scheduler.on("error", (error: Error) => {
89
90
  logger.error(`[resque:scheduler] ${error}`);
90
91
  });
91
92
 
@@ -105,7 +106,7 @@ export class Resque extends Initializer {
105
106
  });
106
107
  api.resque.scheduler.on(
107
108
  "cleanStuckWorker",
108
- (workerName, errorPayload, delta) => {
109
+ (workerName: string, errorPayload: ErrorPayload, delta: number) => {
109
110
  logger.warn(
110
111
  `[resque:scheduler] cleaning stuck worker: ${workerName}, ${errorPayload}, ${delta}`,
111
112
  );
@@ -1,7 +1,9 @@
1
1
  import path from "path";
2
- import { api, RUN_MODE } from "../api";
2
+ import { api, logger, RUN_MODE } from "../api";
3
3
  import { Initializer } from "../classes/Initializer";
4
4
  import type { Server } from "../classes/Server";
5
+ import { config } from "../config";
6
+ import { formatLoadedMessage } from "../util/config";
5
7
  import { globLoader } from "../util/glob";
6
8
 
7
9
  const namespace = "servers";
@@ -27,6 +29,16 @@ export class Servers extends Initializer {
27
29
  path.join(api.packageDir, "servers"),
28
30
  );
29
31
 
32
+ // Load plugin servers
33
+ const pluginServers: Server<any>[] = [];
34
+ for (const plugin of config.plugins) {
35
+ if (plugin.servers) {
36
+ for (const ServerClass of plugin.servers) {
37
+ pluginServers.push(new ServerClass());
38
+ }
39
+ }
40
+ }
41
+
30
42
  // Load user project servers (if rootDir differs from packageDir)
31
43
  let userServers: Server<any>[] = [];
32
44
  if (api.rootDir !== api.packageDir) {
@@ -39,12 +51,20 @@ export class Servers extends Initializer {
39
51
  }
40
52
  }
41
53
 
42
- const servers = [...frameworkServers, ...userServers];
54
+ const servers = [...frameworkServers, ...pluginServers, ...userServers];
43
55
 
44
56
  for (const server of servers) {
45
57
  await server.initialize();
46
58
  }
47
59
 
60
+ logger.info(
61
+ formatLoadedMessage("servers", {
62
+ core: frameworkServers.length,
63
+ plugin: pluginServers.length,
64
+ user: userServers.length,
65
+ }),
66
+ );
67
+
48
68
  return { servers };
49
69
  }
50
70
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keryx",
3
- "version": "0.19.2",
3
+ "version": "0.20.1",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,15 @@
1
+ import type { KeryxPlugin } from "keryx";
2
+
3
+ export const {{className}}: KeryxPlugin = {
4
+ name: "{{name}}",
5
+ version: "0.1.0",
6
+
7
+ // initializers: [],
8
+ // actions: [],
9
+ // channels: [],
10
+ // servers: [],
11
+
12
+ // configDefaults: {},
13
+
14
+ // generators: [],
15
+ };
package/util/cli.ts CHANGED
@@ -5,7 +5,7 @@ import { Action, api, Connection, RUN_MODE } from "../api";
5
5
  import { ExitCode } from "./../classes/ExitCode";
6
6
  import { TypedError } from "./../classes/TypedError";
7
7
  import { config } from "../config";
8
- import { generateComponent } from "./generate";
8
+ import { generateComponent, getValidTypes } from "./generate";
9
9
  import { globLoader } from "./glob";
10
10
  import {
11
11
  interactiveScaffold,
@@ -100,13 +100,14 @@ export async function buildProgram(opts: {
100
100
  .alias("g")
101
101
  .summary("Generate a new component")
102
102
  .description(
103
- "Scaffold a new action, initializer, middleware, channel, or ops file.\n\n" +
103
+ `Scaffold a new component file.\n\nValid types: ${getValidTypes().join(", ")}\n\n` +
104
104
  "Examples:\n" +
105
105
  " keryx generate action user:delete\n" +
106
106
  " keryx generate initializer cache\n" +
107
107
  " keryx generate middleware auth\n" +
108
108
  " keryx generate channel notifications\n" +
109
109
  " keryx generate ops UserOps\n" +
110
+ " keryx generate plugin analytics\n" +
110
111
  " keryx g action hello",
111
112
  )
112
113
  .option("--dry-run", "Show what would be generated without writing files")
@@ -232,7 +233,7 @@ async function runActionViaCLI(options: Record<string, string>, command: any) {
232
233
 
233
234
  await api.initialize();
234
235
 
235
- const action = api.actions.actions.find((a) => a.name === actionName);
236
+ const action = api.actions.actions.find((a: Action) => a.name === actionName);
236
237
  if (!action) {
237
238
  exitWithError(`Action "${actionName}" not found`);
238
239
  }
package/util/config.ts CHANGED
@@ -27,6 +27,61 @@ export function deepMerge<T extends Record<string, any>>(
27
27
  return target;
28
28
  }
29
29
 
30
+ /**
31
+ * Like `deepMerge`, but only sets values that don't already exist in target.
32
+ * Useful for applying plugin config defaults without overwriting user-set values.
33
+ */
34
+ export function deepMergeDefaults<T extends Record<string, any>>(
35
+ target: T,
36
+ source: Record<string, any>,
37
+ ): T {
38
+ for (const key of Object.keys(source)) {
39
+ if (!(key in target)) {
40
+ (target as any)[key] = source[key];
41
+ } else {
42
+ const targetVal = target[key];
43
+ const sourceVal = source[key];
44
+
45
+ if (
46
+ targetVal &&
47
+ sourceVal &&
48
+ typeof targetVal === "object" &&
49
+ typeof sourceVal === "object" &&
50
+ !Array.isArray(targetVal) &&
51
+ !Array.isArray(sourceVal)
52
+ ) {
53
+ deepMergeDefaults(targetVal, sourceVal);
54
+ }
55
+ // If key exists in target and isn't a nested object, keep the target value
56
+ }
57
+ }
58
+
59
+ return target;
60
+ }
61
+
62
+ /**
63
+ * Build a human-readable summary of how many components were loaded and where
64
+ * they came from. Example: `"loaded 12 initializers (10 core, 1 from plugins, 1 user-defined)"`
65
+ *
66
+ * @param label - Plural noun for the component type (e.g. "actions", "servers").
67
+ * @param counts - Breakdown by source. All fields are optional; zero-value
68
+ * categories are omitted from the output.
69
+ */
70
+ export function formatLoadedMessage(
71
+ label: string,
72
+ counts: { core?: number; plugin?: number; user?: number },
73
+ ): string {
74
+ const total = (counts.core ?? 0) + (counts.plugin ?? 0) + (counts.user ?? 0);
75
+ const parts: string[] = [];
76
+ if (counts.core) parts.push(`${counts.core} core`);
77
+ if (counts.plugin) parts.push(`${counts.plugin} from plugins`);
78
+ if (counts.user) parts.push(`${counts.user} user-defined`);
79
+ if (parts.length > 0) {
80
+ return `loaded ${total} ${label} (${parts.join(", ")})`;
81
+ }
82
+ return `loaded ${total} ${label}`;
83
+ }
84
+
30
85
  /**
31
86
  Loads a value from the environment, if it's set, otherwise returns the default value.
32
87
  */
package/util/generate.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import fs from "fs";
2
2
  import Mustache from "mustache";
3
3
  import path from "path";
4
+ import type { PluginGenerator } from "../classes/Plugin";
5
+ import { config } from "../config";
4
6
 
5
7
  const VALID_TYPES = [
6
8
  "action",
@@ -8,9 +10,39 @@ const VALID_TYPES = [
8
10
  "middleware",
9
11
  "channel",
10
12
  "ops",
13
+ "plugin",
11
14
  ] as const;
12
15
  type GeneratorType = (typeof VALID_TYPES)[number];
13
16
 
17
+ /**
18
+ * Returns all valid generator types, including built-in types and
19
+ * any custom types registered by plugins.
20
+ */
21
+ export function getValidTypes(): string[] {
22
+ const types = [...VALID_TYPES] as string[];
23
+ for (const plugin of config.plugins) {
24
+ if (plugin.generators) {
25
+ for (const gen of plugin.generators) {
26
+ if (!types.includes(gen.type)) types.push(gen.type);
27
+ }
28
+ }
29
+ }
30
+ return types;
31
+ }
32
+
33
+ /**
34
+ * Find a plugin generator definition for a given type.
35
+ */
36
+ function findPluginGenerator(type: string): PluginGenerator | undefined {
37
+ for (const plugin of config.plugins) {
38
+ if (plugin.generators) {
39
+ const gen = plugin.generators.find((g) => g.type === type);
40
+ if (gen) return gen;
41
+ }
42
+ }
43
+ return undefined;
44
+ }
45
+
14
46
  export interface GenerateOptions {
15
47
  dryRun?: boolean;
16
48
  force?: boolean;
@@ -46,6 +78,7 @@ function resolveFilePath(type: GeneratorType, name: string): string {
46
78
  middleware: "middleware",
47
79
  channel: "channels",
48
80
  ops: "ops",
81
+ plugin: "plugins",
49
82
  };
50
83
 
51
84
  const baseDir = dirMap[type];
@@ -60,6 +93,27 @@ function resolveFilePath(type: GeneratorType, name: string): string {
60
93
  return path.join(baseDir, `${name}.ts`);
61
94
  }
62
95
 
96
+ /**
97
+ * Determine the directory and filename for a plugin generator type + name.
98
+ */
99
+ function resolvePluginFilePath(gen: PluginGenerator, name: string): string {
100
+ const segments = name.split(":");
101
+ if (segments.length > 1) {
102
+ const fileName = segments.pop()!;
103
+ return path.join(gen.directory, ...segments, `${fileName}.ts`);
104
+ }
105
+ return path.join(gen.directory, `${name}.ts`);
106
+ }
107
+
108
+ /**
109
+ * Determine the test file path for a plugin generator component.
110
+ */
111
+ function resolvePluginTestPath(gen: PluginGenerator, name: string): string {
112
+ const componentPath = resolvePluginFilePath(gen, name);
113
+ const parsed = path.parse(componentPath);
114
+ return path.join("__tests__", parsed.dir, `${parsed.name}.test.ts`);
115
+ }
116
+
63
117
  /**
64
118
  * Determine the test file path for a component.
65
119
  * "user:delete" action → "__tests__/actions/user/delete.test.ts"
@@ -92,14 +146,21 @@ export async function generateComponent(
92
146
  rootDir: string,
93
147
  options: GenerateOptions = {},
94
148
  ): Promise<string[]> {
95
- if (!VALID_TYPES.includes(type as GeneratorType)) {
149
+ const allValidTypes = getValidTypes();
150
+ if (!allValidTypes.includes(type)) {
96
151
  throw new Error(
97
- `Unknown generator type "${type}". Valid types: ${VALID_TYPES.join(", ")}`,
152
+ `Unknown generator type "${type}". Valid types: ${allValidTypes.join(", ")}`,
98
153
  );
99
154
  }
100
155
 
101
- const generatorType = type as GeneratorType;
102
- const filePath = resolveFilePath(generatorType, name);
156
+ // Check if this is a plugin-provided generator type
157
+ const pluginGen = VALID_TYPES.includes(type as GeneratorType)
158
+ ? undefined
159
+ : findPluginGenerator(type);
160
+
161
+ const filePath = pluginGen
162
+ ? resolvePluginFilePath(pluginGen, name)
163
+ : resolveFilePath(type as GeneratorType, name);
103
164
  const fullPath = path.join(rootDir, filePath);
104
165
  const createdFiles: string[] = [];
105
166
 
@@ -112,25 +173,32 @@ export async function generateComponent(
112
173
 
113
174
  // Build template view
114
175
  let className = toClassName(name);
115
- if (generatorType === "middleware") className += "Middleware";
116
- if (generatorType === "channel") className += "Channel";
176
+ if (type === "middleware") className += "Middleware";
177
+ if (type === "channel") className += "Channel";
178
+ if (type === "plugin") className += "Plugin";
117
179
 
118
180
  const view: Record<string, string> = { name, className };
119
- if (generatorType === "action") {
181
+ if (type === "action") {
120
182
  view.route = toRoute(name);
121
183
  }
122
184
 
123
- // Determine template
124
- const templateMap: Record<GeneratorType, string> = {
125
- action: "action.ts.mustache",
126
- initializer: "initializer.ts.mustache",
127
- middleware: "action-middleware.ts.mustache",
128
- channel: "channel.ts.mustache",
129
- ops: "ops.ts.mustache",
130
- };
131
-
132
- const template = await loadTemplate(templateMap[generatorType]);
133
- const content = Mustache.render(template, view);
185
+ // Load and render template
186
+ let content: string;
187
+ if (pluginGen) {
188
+ const templateStr = await Bun.file(pluginGen.templatePath).text();
189
+ content = Mustache.render(templateStr, view);
190
+ } else {
191
+ const templateMap: Record<GeneratorType, string> = {
192
+ action: "action.ts.mustache",
193
+ initializer: "initializer.ts.mustache",
194
+ middleware: "action-middleware.ts.mustache",
195
+ channel: "channel.ts.mustache",
196
+ ops: "ops.ts.mustache",
197
+ plugin: "plugin.ts.mustache",
198
+ };
199
+ const template = await loadTemplate(templateMap[type as GeneratorType]);
200
+ content = Mustache.render(template, view);
201
+ }
134
202
 
135
203
  if (options.dryRun) {
136
204
  console.log(`Would create: ${filePath}`);
@@ -145,14 +213,24 @@ export async function generateComponent(
145
213
 
146
214
  // Generate test file
147
215
  if (!options.noTest) {
148
- const testPath = resolveTestPath(generatorType, name);
216
+ const testPath = pluginGen
217
+ ? resolvePluginTestPath(pluginGen, name)
218
+ : resolveTestPath(type as GeneratorType, name);
149
219
  const testFullPath = path.join(rootDir, testPath);
150
220
 
151
221
  if (!options.force && fs.existsSync(testFullPath)) {
152
222
  // Silently skip test file if it already exists
153
223
  } else {
154
- const testTemplate = await loadTemplate("test.ts.mustache");
155
- const testContent = Mustache.render(testTemplate, view);
224
+ let testContent: string;
225
+ if (pluginGen?.testTemplatePath) {
226
+ const testTemplateStr = await Bun.file(
227
+ pluginGen.testTemplatePath,
228
+ ).text();
229
+ testContent = Mustache.render(testTemplateStr, view);
230
+ } else {
231
+ const testTemplate = await loadTemplate("test.ts.mustache");
232
+ testContent = Mustache.render(testTemplate, view);
233
+ }
156
234
 
157
235
  if (options.dryRun) {
158
236
  console.log(`Would create: ${testPath}`);
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from "crypto";
2
2
  import { api } from "../api";
3
- import type { OAuthActionResponse } from "../classes/Action";
3
+ import type { Action, OAuthActionResponse } from "../classes/Action";
4
4
  import { Connection } from "../classes/Connection";
5
5
  import { config } from "../config";
6
6
  import {
@@ -180,8 +180,10 @@ export function handleAuthorizeGet(
180
180
  };
181
181
 
182
182
  return renderAuthPage(params, templates, {
183
- loginAction: api.actions.actions.find((a) => a.mcp?.isLoginAction),
184
- signupAction: api.actions.actions.find((a) => a.mcp?.isSignupAction),
183
+ loginAction: api.actions.actions.find((a: Action) => a.mcp?.isLoginAction),
184
+ signupAction: api.actions.actions.find(
185
+ (a: Action) => a.mcp?.isSignupAction,
186
+ ),
185
187
  });
186
188
  }
187
189
 
@@ -227,8 +229,10 @@ export async function handleAuthorizePost(
227
229
  };
228
230
 
229
231
  const authActions = {
230
- loginAction: api.actions.actions.find((a) => a.mcp?.isLoginAction),
231
- signupAction: api.actions.actions.find((a) => a.mcp?.isSignupAction),
232
+ loginAction: api.actions.actions.find((a: Action) => a.mcp?.isLoginAction),
233
+ signupAction: api.actions.actions.find(
234
+ (a: Action) => a.mcp?.isSignupAction,
235
+ ),
232
236
  };
233
237
 
234
238
  // Validate client
@@ -263,7 +267,9 @@ export async function handleAuthorizePost(
263
267
  let userId: number;
264
268
 
265
269
  if (mode === "signup") {
266
- const signupAction = api.actions.actions.find((a) => a.mcp?.isSignupAction);
270
+ const signupAction = api.actions.actions.find(
271
+ (a: Action) => a.mcp?.isSignupAction,
272
+ );
267
273
  if (!signupAction) {
268
274
  oauthParams.error = "No signup action configured";
269
275
  return renderAuthPage(oauthParams, templates, authActions);
@@ -283,7 +289,9 @@ export async function handleAuthorizePost(
283
289
  connection.destroy();
284
290
  }
285
291
  } else {
286
- const loginAction = api.actions.actions.find((a) => a.mcp?.isLoginAction);
292
+ const loginAction = api.actions.actions.find(
293
+ (a: Action) => a.mcp?.isLoginAction,
294
+ );
287
295
  if (!loginAction) {
288
296
  oauthParams.error = "No login action configured";
289
297
  return renderAuthPage(oauthParams, templates, authActions);
package/util/scaffold.ts CHANGED
@@ -103,6 +103,10 @@ export async function generateConfigFileContents(): Promise<
103
103
  /from ["']\.\.\/classes\/Logger["']/g,
104
104
  'from "keryx"',
105
105
  );
106
+ content = content.replace(
107
+ /from ["']\.\.\/classes\/Plugin["']/g,
108
+ 'from "keryx"',
109
+ );
106
110
 
107
111
  // In index.ts, change `export const config` to `export default`
108
112
  // and remove the KeryxConfig interface export (it comes from the package)
@@ -112,6 +116,11 @@ export async function generateConfigFileContents(): Promise<
112
116
  /\nexport interface KeryxConfig \{[\s\S]*?\}\n/,
113
117
  "\n",
114
118
  );
119
+ // Remove the KeryxPlugin import (only used by the stripped KeryxConfig interface)
120
+ content = content.replace(
121
+ /import type \{ KeryxPlugin \} from ["']keryx["'];\n/,
122
+ "",
123
+ );
115
124
  }
116
125
 
117
126
  result.set(`config/${file}`, content);