kennel 0.2.42 → 0.2.43

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.
@@ -3,6 +3,7 @@ import { dirname, resolve } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  const USAGE = `Usage:
5
5
  kennel plugin add <git-repository-url-or-local-path> [--path <subdirectory>] [--install-skill]
6
+ kennel plugin remove <plugin-id>
6
7
  kennel plugin skill install <plugin-id> [--force]
7
8
  kennel plugin list
8
9
  kennel plugin new <id>`;
@@ -77,6 +78,15 @@ export async function plugin(args) {
77
78
  console.log(`Plugin declares skill ${entry.skill.name}; install it with \`kennel plugin skill install ${entry.id}\``);
78
79
  return;
79
80
  }
81
+ if (subcommand === 'remove') {
82
+ const [id] = rest;
83
+ if (!id || id.startsWith('-') || rest.length !== 1)
84
+ usageError();
85
+ const { removeExternalPlugin } = await import('../plugins/external.js');
86
+ await removeExternalPlugin(id);
87
+ console.log(`Removed plugin ${id}; source folders, caches, and installed skills were kept.`);
88
+ return;
89
+ }
80
90
  if (subcommand === 'skill' && rest[0] === 'install') {
81
91
  const [, id, ...flags] = rest;
82
92
  if (!id || id.startsWith('-') || flags.some((flag) => flag !== '--force'))
package/dist/cli/usage.js CHANGED
@@ -3,6 +3,7 @@ export function usage() {
3
3
  knl core [folder] [--host <host>] [--port <port>] [--api-only] [--web-root <dir>]
4
4
  kennel init [path] [--plugin <id>] [--force] [--list]
5
5
  kennel plugin add <git-repository-url-or-local-path> [--path <subdirectory>] [--install-skill]
6
+ kennel plugin remove <plugin-id>
6
7
  kennel plugin skill install <plugin-id> [--force]
7
8
  kennel plugin list
8
9
  kennel plugin new <id>
@@ -37,6 +38,7 @@ Without an interactive terminal, the viewer prints its URL without a menu.
37
38
 
38
39
  Installed plugin ids are commands, for example knl tasks or knl calendar.
39
40
  Plugins can declare extra aliases, for example knl mail for plugin id email.
41
+ Removing a plugin keeps its source folders, caches, and separately installed skills.
40
42
  Use --item <id> for a plugin detail view. Built-in commands take priority.
41
43
 
42
44
  knl llm prints only agent instructions, profiles, and the research write destination.
@@ -0,0 +1,9 @@
1
+ import type { FolderPluginDefinition, FolderPluginManifest } from "kennel-plugins/types";
2
+ /** Reads metadata only. Discovery must never import plugin code. */
3
+ export declare function readPluginDefinition(path: string): FolderPluginDefinition;
4
+ /** The host's display record has no authoring paths or component bindings. */
5
+ export declare function pluginDisplayManifest(plugin: FolderPluginDefinition): FolderPluginManifest;
6
+ /** Named imports let the compiler reject missing exports without running React. */
7
+ export declare function pluginClientSource(index: string, plugin: FolderPluginDefinition): string;
8
+ /** Compose trusted hooks with JSON metadata; hooks cannot change the identity. */
9
+ export declare function pluginServerSource(server: string, plugin: FolderPluginDefinition): string;
@@ -0,0 +1,62 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import { z } from "zod";
4
+ const schema = z.fromJSONSchema(JSON.parse(readFileSync(fileURLToPath(import.meta.resolve("kennel-plugins/schema.json")), "utf8")));
5
+ /** Reads metadata only. Discovery must never import plugin code. */
6
+ export function readPluginDefinition(path) {
7
+ let value;
8
+ try {
9
+ value = JSON.parse(readFileSync(path, "utf8"));
10
+ }
11
+ catch (error) {
12
+ throw new Error(`Cannot read plugin definition ${path}: ${error instanceof Error ? error.message : String(error)}`);
13
+ }
14
+ const result = schema.safeParse(value);
15
+ if (!result.success) {
16
+ throw new Error(`Invalid plugin definition ${path}:\n${result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("\n")}`);
17
+ }
18
+ const { plugin } = result.data;
19
+ const paths = plugin.views.map((view) => view.subpath);
20
+ if (new Set(paths).size !== paths.length || (paths.length > 1 && paths.includes(""))) {
21
+ throw new Error(`${path}: plugin.views must have unique subpaths; an empty subpath must be the only view`);
22
+ }
23
+ return plugin;
24
+ }
25
+ /** The host's display record has no authoring paths or component bindings. */
26
+ export function pluginDisplayManifest(plugin) {
27
+ const first = plugin.views[0];
28
+ const icon = first.subpath === "" ? first.icon ?? plugin.icon : plugin.icon;
29
+ return {
30
+ id: plugin.id,
31
+ label: first.subpath === "" ? first.label : plugin.label,
32
+ ...(icon ? { icon } : {}),
33
+ ...(first.subpath === "" ? {} : {
34
+ views: plugin.views.map(({ component: _component, ...view }) => view),
35
+ }),
36
+ };
37
+ }
38
+ /** Named imports let the compiler reject missing exports without running React. */
39
+ export function pluginClientSource(index, plugin) {
40
+ return `import { createElement } from "react";
41
+ import { ${plugin.views.map((view, i) => `${view.component} as Page${i}`).join(", ")} } from ${JSON.stringify(index)};
42
+ const views = [${plugin.views.map((view, i) => `{ subpath: ${JSON.stringify(view.subpath)}, component: Page${i} }`).join(", ")}];
43
+ function PluginPage(props) {
44
+ const route = props.subpath.split("?")[0];
45
+ const view = route ? views.find((view) => view.subpath === "" || view.subpath === route.split("/")[0]) : views[0];
46
+ return view
47
+ ? createElement(view.component, { ...props, key: props.folderId + ":" + props.subpath })
48
+ : createElement("p", { role: "alert" }, "That plugin view does not exist.");
49
+ }
50
+ export default { component: PluginPage };
51
+ `;
52
+ }
53
+ /** Compose trusted hooks with JSON metadata; hooks cannot change the identity. */
54
+ export function pluginServerSource(server, plugin) {
55
+ return `import entry from ${JSON.stringify(server)};
56
+ const manifest = ${JSON.stringify(pluginDisplayManifest(plugin))};
57
+ export default (...args) => {
58
+ const hooks = typeof entry === "function" ? entry(...args) : entry;
59
+ return { ...hooks, ...manifest, icon: manifest.icon, views: manifest.views };
60
+ };
61
+ `;
62
+ }
@@ -10,9 +10,9 @@ export type ExternalPluginEntry = {
10
10
  path?: string;
11
11
  trigger?: string;
12
12
  entries: {
13
- client: string;
13
+ index: string;
14
14
  server: string;
15
- manifest: string;
15
+ definition: string;
16
16
  api?: string;
17
17
  css?: string;
18
18
  };
@@ -40,6 +40,7 @@ export declare function readExternalPlugins(): ExternalPluginEntry[];
40
40
  export declare function installExternalPlugin(url: string, options?: {
41
41
  directory?: string;
42
42
  }): Promise<ExternalPluginEntry>;
43
+ export declare function removeExternalPlugin(id: string): Promise<void>;
43
44
  /** Installs a declared skill from local source or the pinned cached checkout. */
44
45
  export declare function installPluginSkill(id: string, options?: {
45
46
  force?: boolean;
@@ -15,8 +15,8 @@ import { pathToFileURL, fileURLToPath } from "node:url";
15
15
  import { spawnSync } from "node:child_process";
16
16
  import { z } from "zod";
17
17
  import { duplicateSkills, readPluginSkill, skillDestination, skillState, writeSkill } from "./skills.js";
18
+ import { readPluginDefinition, pluginDisplayManifest, pluginClientSource, pluginServerSource } from "./definition.js";
18
19
  const CONFIG_FILE = join("kennel", "config.json");
19
- const VALID_ID = /^[a-z][a-z0-9-]*$/u;
20
20
  const CORE_IDS = new Set(["agents", "notes"]);
21
21
  function xdgConfigHome() {
22
22
  return process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
@@ -162,7 +162,7 @@ function pluginRoot(repoRoot, directory) {
162
162
  return repoRoot;
163
163
  if (isAbsolute(directory) || directory.includes("\0"))
164
164
  throw new Error(`Unsafe plugin directory: ${directory}`);
165
- const target = resolve(repoRoot, directory);
165
+ const target = realpathSync(resolve(repoRoot, directory));
166
166
  const rel = relative(repoRoot, target);
167
167
  if (rel === "" || rel.startsWith("..") || isAbsolute(rel))
168
168
  throw new Error(`Unsafe plugin directory: ${directory}`);
@@ -179,9 +179,11 @@ function installPluginDependencies(root, dependencies) {
179
179
  function safeRelativeFile(root, value, label) {
180
180
  if (typeof value !== "string" || !value.trim())
181
181
  throw new Error(`Missing ${label}`);
182
- if (isAbsolute(value) || value.includes("\0"))
182
+ if (isAbsolute(value) || value.includes("\0") || value.includes("\\") || /^[a-z][a-z0-9+.-]*:/iu.test(value) || value.split("/").includes(".."))
183
183
  throw new Error(`Unsafe ${label}: ${value}`);
184
- const target = resolve(root, value);
184
+ if (!existsSync(resolve(root, value)))
185
+ throw new Error(`Missing ${label}: ${value}`);
186
+ const target = realpathSync(resolve(root, value));
185
187
  const rel = relative(root, target);
186
188
  if (rel === "" || rel.startsWith("..") || isAbsolute(rel))
187
189
  throw new Error(`Unsafe ${label}: ${value}`);
@@ -189,16 +191,11 @@ function safeRelativeFile(root, value, label) {
189
191
  throw new Error(`Missing ${label}: ${value}`);
190
192
  return target;
191
193
  }
192
- function entryByConvention(root, file, required) {
193
- const rootCandidate = join(root, file);
194
- const srcCandidate = join(root, "src", file);
195
- if (existsSync(rootCandidate) && statSync(rootCandidate).isFile())
196
- return rootCandidate;
197
- if (existsSync(srcCandidate) && statSync(srcCandidate).isFile())
198
- return srcCandidate;
199
- if (!required)
200
- return undefined;
201
- throw new Error(`Missing required plugin entry ${file} (expected ${file} or src/${file})`);
194
+ function pluginDefinition(entry) {
195
+ const definition = readPluginDefinition(entry.entries.definition);
196
+ if (definition.id !== entry.id)
197
+ throw new Error(`Plugin definition id changed: reinstall ${entry.id}`);
198
+ return definition;
202
199
  }
203
200
  // Generated files do not change the plugin source or trigger development rebuilds.
204
201
  export const PLUGIN_SOURCE_IGNORED = new Set([".git", "node_modules", ".cache", ".turbo", "dist", "coverage"]);
@@ -225,11 +222,6 @@ function contentHash(root) {
225
222
  visit(root);
226
223
  return digest.digest("hex");
227
224
  }
228
- function stringField(value, label) {
229
- if (typeof value !== "string" || !value.trim())
230
- throw new Error(`Missing ${label}`);
231
- return value.trim();
232
- }
233
225
  // Specifiers the host shares with plugin browser bundles at runtime. Keep in
234
226
  // sync with pluginHostModules in apps/web/src/plugin-host-modules.ts and
235
227
  // apps/desktop/src/renderer/plugin-host-modules.ts.
@@ -298,14 +290,16 @@ module.exports = host.modules[${JSON.stringify(args.path)}];`,
298
290
  },
299
291
  };
300
292
  }
301
- async function bundleForImport(entry, outfile, platform) {
293
+ async function bundleForImport(entry, outfile, platform, contents) {
302
294
  const esbuild = await import("esbuild");
303
295
  await esbuild.build({
304
296
  banner: platform === "node"
305
297
  ? { js: 'import { createRequire as __kennelCreateRequire } from "node:module"; const require = __kennelCreateRequire(import.meta.url);' }
306
298
  : undefined,
307
299
  bundle: true,
308
- entryPoints: [entry],
300
+ ...(contents === undefined ? { entryPoints: [entry] } : {
301
+ stdin: { contents, loader: "js", resolveDir: dirname(entry), sourcefile: "kennel-adapter.js" },
302
+ }),
309
303
  external: ["bun:ffi", "koffi"],
310
304
  format: "esm",
311
305
  jsx: "automatic",
@@ -318,67 +312,32 @@ async function bundleForImport(entry, outfile, platform) {
318
312
  target: platform === "node" ? "node20" : "es2022",
319
313
  });
320
314
  }
321
- function assertManifest(value, id, label) {
322
- if (!value || typeof value !== "object")
323
- throw new Error(`${label} is not an object`);
324
- if (value.id !== id)
325
- throw new Error(`${label} id mismatch`);
326
- if (typeof value.label !== "string" || !value.label.trim())
327
- throw new Error(`${label} missing label`);
328
- if (value.views !== undefined) {
329
- if (!Array.isArray(value.views) || value.views.length === 0)
330
- throw new Error(`${label} views must be a nonempty array`);
331
- const paths = new Set();
332
- for (const view of value.views) {
333
- if (!view || typeof view.subpath !== "string" || !VALID_ID.test(view.subpath) ||
334
- typeof view.label !== "string" || !view.label.trim() || paths.has(view.subpath)) {
335
- throw new Error(`${label} views need unique route segments and labels`);
336
- }
337
- paths.add(view.subpath);
338
- }
339
- }
340
- }
341
315
  async function validateImports(entry) {
342
316
  const dir = await mkdtemp(join(tmpdir(), "kennel-plugin-validate-"));
343
317
  try {
344
- const manifestOut = join(dir, "manifest.mjs");
318
+ const definition = pluginDefinition(entry);
345
319
  const serverOut = join(dir, "server.mjs");
346
- const clientOut = join(dir, "client.mjs");
347
320
  const apiOut = join(dir, "api.mjs");
348
- await bundleForImport(entry.entries.manifest, manifestOut, "browser");
349
321
  await bundleForImport(entry.entries.server, serverOut, "node");
350
- await bundleForImport(entry.entries.client, clientOut, "node");
322
+ await bundleForImport(entry.entries.index, join(dir, "client.mjs"), "browser", pluginClientSource(entry.entries.index, definition));
351
323
  if (entry.entries.api)
352
324
  await bundleForImport(entry.entries.api, apiOut, "node");
353
- const manifestModule = await import(__rewriteRelativeImportExtension(pathToFileURL(manifestOut).href));
354
325
  const serverModule = await import(__rewriteRelativeImportExtension(pathToFileURL(serverOut).href));
355
- const clientModule = await import(__rewriteRelativeImportExtension(pathToFileURL(clientOut).href));
356
- const manifest = manifestModule.default;
357
326
  const serverExport = serverModule.default;
358
327
  const server = typeof serverExport === "function" ? serverExport({}) : serverExport;
359
- const client = clientModule.default;
360
- assertManifest(manifest, entry.id, "manifest export");
361
- if (!server || typeof server !== "object" || server.id !== entry.id)
362
- throw new Error("server id mismatch");
328
+ if (!server || typeof server !== "object")
329
+ throw new Error("server default export must provide hooks or a hook factory");
363
330
  if (typeof server.detect !== "function" || typeof server.handle !== "function")
364
331
  throw new Error("server export missing detect/handle");
365
- if (server.llm !== undefined && typeof server.llm !== "function")
366
- throw new Error("server export llm hook must be a function");
367
- if (!client || typeof client !== "object" || client.id !== entry.id)
368
- throw new Error("client id mismatch");
369
- if (typeof client.component !== "function")
370
- throw new Error("client export missing component");
332
+ for (const hook of ["init", "llm"]) {
333
+ if (server[hook] !== undefined && typeof server[hook] !== "function")
334
+ throw new Error(`server export ${hook} hook must be a function`);
335
+ }
371
336
  if (entry.entries.api) {
372
337
  const apiModule = await import(__rewriteRelativeImportExtension(pathToFileURL(apiOut).href));
373
338
  if (typeof apiModule.default !== "function")
374
339
  throw new Error("API default export missing handler");
375
340
  }
376
- if (Array.isArray(manifest.views)) {
377
- entry.views = manifest.views.map((view) => ({
378
- subpath: view.subpath,
379
- label: view.label,
380
- }));
381
- }
382
341
  }
383
342
  finally {
384
343
  await rm(dir, { force: true, recursive: true });
@@ -392,40 +351,18 @@ export async function validateExternalPlugin(inputUrl, options = {}) {
392
351
  const root = pluginRoot(checkoutRoot, directory);
393
352
  const pkgPath = join(root, "package.json");
394
353
  const pkg = existsSync(pkgPath) ? JSON.parse(await readFile(pkgPath, "utf8")) : {};
395
- const metadataPath = join(root, "kennel.plugin.json");
396
- const fileMetadata = existsSync(metadataPath)
397
- ? JSON.parse(await readFile(metadataPath, "utf8"))
398
- : undefined;
399
- const metadata = (pkg.kennel?.plugin && typeof pkg.kennel.plugin === "object"
400
- ? pkg.kennel.plugin
401
- : fileMetadata && typeof fileMetadata === "object"
402
- ? fileMetadata
403
- : undefined);
404
- const id = metadata?.id === undefined ? basename(root) : stringField(metadata.id, "plugin id");
405
- if (!VALID_ID.test(id))
406
- throw new Error(`Invalid plugin id: ${id}`);
354
+ const definition = safeRelativeFile(root, "kennel.json", "plugin definition");
355
+ const metadata = readPluginDefinition(definition);
356
+ const { id, aliases, trigger } = metadata;
407
357
  if (CORE_IDS.has(id))
408
358
  throw new Error(`External plugin id collides with core plugin: ${id}`);
409
359
  const packageName = typeof pkg.name === "string" && pkg.name.trim() ? pkg.name.trim() : id;
410
- let aliases;
411
- if (metadata?.aliases !== undefined) {
412
- if (!Array.isArray(metadata.aliases))
413
- throw new Error("Plugin aliases must be an array of command names");
414
- aliases = metadata.aliases.map((alias) => stringField(alias, "plugin alias"));
415
- if (aliases.some((alias) => !VALID_ID.test(alias)) || new Set(aliases).size !== aliases.length) {
416
- throw new Error("Plugin aliases must be unique lowercase command names");
417
- }
418
- }
419
- const trigger = metadata?.trigger === undefined ? undefined : stringField(metadata.trigger, "trigger file");
420
- if (trigger && (trigger.includes("/") || trigger.includes("\\") || trigger === "." || trigger === ".."))
421
- throw new Error(`Invalid trigger file: ${trigger}`);
422
- const client = entryByConvention(root, "client.tsx", true);
423
- const server = entryByConvention(root, "server.ts", true);
424
- const manifest = entryByConvention(root, "manifest.ts", true);
425
- const api = entryByConvention(root, "api.ts", false);
426
- const cssValue = metadata?.exports?.css;
427
- const css = cssValue === undefined ? undefined : safeRelativeFile(root, cssValue, "css export path");
428
- const skill = metadata?.skill === undefined ? undefined : readPluginSkill(root, metadata.skill);
360
+ const index = safeRelativeFile(root, "src/index.ts", "browser entry");
361
+ const server = safeRelativeFile(root, "src/server.ts", "server entry");
362
+ const api = existsSync(join(root, "src/api.ts")) ? safeRelativeFile(root, "src/api.ts", "API entry") : undefined;
363
+ const css = metadata.css === undefined ? undefined : safeRelativeFile(root, metadata.css, "css export path");
364
+ const skill = metadata.skill === undefined ? undefined : readPluginSkill(root, metadata.skill);
365
+ const views = pluginDisplayManifest(metadata).views?.map(({ subpath, label }) => ({ subpath, label }));
429
366
  const entry = {
430
367
  url: source.url,
431
368
  ...(source.requestedRef ? { requestedRef: source.requestedRef } : {}),
@@ -437,13 +374,14 @@ export async function validateExternalPlugin(inputUrl, options = {}) {
437
374
  ...(source.path ? { path: source.path } : {}),
438
375
  ...(trigger ? { trigger } : {}),
439
376
  entries: {
440
- client,
377
+ index,
441
378
  server,
442
- manifest,
379
+ definition,
443
380
  ...(api ? { api } : {}),
444
381
  ...(css ? { css } : {}),
445
382
  },
446
383
  ...(skill ? { skill: { entry: skill.entry, name: skill.name } } : {}),
384
+ ...(views ? { views } : {}),
447
385
  };
448
386
  installPluginDependencies(root, pkg.dependencies);
449
387
  await validateImports(entry);
@@ -476,6 +414,18 @@ export async function installExternalPlugin(url, options = {}) {
476
414
  }
477
415
  return entry;
478
416
  }
417
+ export async function removeExternalPlugin(id) {
418
+ const path = kennelConfigPath();
419
+ const config = readJson(path);
420
+ const plugins = config.externalPlugins ?? [];
421
+ if (!plugins.some((plugin) => plugin.id === id))
422
+ throw new Error(`No installed plugin with id ${id}`);
423
+ const externalPlugins = plugins.filter((plugin) => plugin.id !== id);
424
+ const nextConfig = { ...config, externalPlugins };
425
+ assertUserConfig(nextConfig, path);
426
+ await ensurePluginBuild(externalPlugins);
427
+ writeJsonAtomic(path, nextConfig);
428
+ }
479
429
  /** Uses local source or checks out the pinned cached commit without fetching. */
480
430
  function pinnedPackageRoot(plugin) {
481
431
  if (plugin.path)
@@ -497,7 +447,7 @@ export function installPluginSkill(id, options = {}) {
497
447
  if (!plugin)
498
448
  throw new Error(`No installed plugin with id ${id}`);
499
449
  if (!plugin.skill)
500
- throw new Error(`Plugin ${id} declares no skill (kennel.plugin.skill or kennel.plugin.json > skill)`);
450
+ throw new Error(`Plugin ${id} declares no skill (kennel.json > plugin.skill)`);
501
451
  const skill = readPluginSkill(pinnedPackageRoot(plugin), plugin.skill.entry);
502
452
  if (skill.name !== plugin.skill.name)
503
453
  throw new Error(`Pinned skill name ${skill.name} does not match recorded name ${plugin.skill.name}`);
@@ -535,7 +485,7 @@ function cliRoot() {
535
485
  export function webRoot() {
536
486
  return resolve(cliRoot(), "web");
537
487
  }
538
- const BUILD_IDENTITY = "kennel-external-plugins-v1";
488
+ const BUILD_IDENTITY = "kennel-json-plugins-v2";
539
489
  function cliVersion() {
540
490
  return JSON.parse(readFileSync(join(cliRoot(), "package.json"), "utf8")).version;
541
491
  }
@@ -639,7 +589,7 @@ async function buildPluginStyles(plugin, outdir, theme) {
639
589
  import("@tailwindcss/oxide"),
640
590
  import("esbuild"),
641
591
  ]);
642
- const source = dirname(plugin.entries.client);
592
+ const source = dirname(plugin.entries.index);
643
593
  const input = [
644
594
  '@import "tailwindcss/theme.css" layer(theme);',
645
595
  `@import ${JSON.stringify(theme)};`,
@@ -719,8 +669,13 @@ async function buildPluginClient(plugin, outdir) {
719
669
  bundle: true,
720
670
  chunkNames: "chunks/[name]-[hash]",
721
671
  define: { "process.env.NODE_ENV": '"production"' },
722
- entryNames: "[name]",
723
- entryPoints: { client: plugin.entries.client },
672
+ entryNames: "client",
673
+ stdin: {
674
+ contents: pluginClientSource(plugin.entries.index, pluginDefinition(plugin)),
675
+ loader: "js",
676
+ resolveDir: dirname(plugin.entries.index),
677
+ sourcefile: "client.js",
678
+ },
724
679
  format: "esm",
725
680
  jsx: "automatic",
726
681
  loader: ASSET_LOADERS,
@@ -737,12 +692,6 @@ async function buildPluginClient(plugin, outdir) {
737
692
  const output = Object.entries(result.metafile.outputs).find(([, value]) => value.entryPoint)?.[1];
738
693
  return output?.cssBundle ? [relative(outdir, resolve(output.cssBundle))] : [];
739
694
  }
740
- async function pluginManifest(plugin, outfile) {
741
- await bundleForImport(plugin.entries.manifest, outfile, "browser");
742
- const manifest = (await import(__rewriteRelativeImportExtension(pathToFileURL(outfile).href))).default;
743
- assertManifest(manifest, plugin.id, "manifest export");
744
- return JSON.parse(JSON.stringify(manifest));
745
- }
746
695
  async function buildPlugins(temp, target, key, plugins, theme) {
747
696
  const serverEntries = [];
748
697
  const apiEntries = [];
@@ -752,11 +701,11 @@ async function buildPlugins(temp, target, key, plugins, theme) {
752
701
  const clientStyles = await buildPluginClient(plugin, clientDir);
753
702
  const styles = [await buildPluginStyles(plugin, clientDir, theme), ...clientStyles];
754
703
  registry.push({
755
- manifest: await pluginManifest(plugin, join(temp, "manifest", `${plugin.id}.js`)),
704
+ manifest: pluginDisplayManifest(pluginDefinition(plugin)),
756
705
  module: `/_kennel/plugins/${plugin.id}/client.js`,
757
706
  styles: styles.map((style) => `/_kennel/plugins/${plugin.id}/${style}`),
758
707
  });
759
- await bundleForImport(plugin.entries.server, join(temp, "server", `${plugin.id}.js`), "node");
708
+ await bundleForImport(plugin.entries.server, join(temp, "server", `${plugin.id}.js`), "node", pluginServerSource(plugin.entries.server, pluginDefinition(plugin)));
760
709
  serverEntries.push({ id: plugin.id, module: join(target, "server", `${plugin.id}.js`), symbol: "default", trigger: plugin.trigger });
761
710
  if (plugin.entries.api) {
762
711
  await bundleForImport(plugin.entries.api, join(temp, "server", `${plugin.id}-api.js`), "node");
@@ -8,7 +8,7 @@ export type SkillState = "missing" | "installed" | "conflict";
8
8
  export declare function agentSkillsRoot(): string;
9
9
  export declare function skillDestination(name: string): string;
10
10
  /**
11
- * Resolves `package.json > kennel.plugin.skill` inside a plugin package root.
11
+ * Resolves `kennel.json > plugin.skill` inside a plugin package root.
12
12
  * The entry must be a contained, non-symlinked `SKILL.md` with a valid name and description.
13
13
  */
14
14
  export declare function readPluginSkill(packageRoot: string, entry: unknown): PluginSkill;
@@ -38,12 +38,12 @@ function frontmatter(path) {
38
38
  return data;
39
39
  }
40
40
  /**
41
- * Resolves `package.json > kennel.plugin.skill` inside a plugin package root.
41
+ * Resolves `kennel.json > plugin.skill` inside a plugin package root.
42
42
  * The entry must be a contained, non-symlinked `SKILL.md` with a valid name and description.
43
43
  */
44
44
  export function readPluginSkill(packageRoot, entry) {
45
45
  if (typeof entry !== "string" || !entry.trim())
46
- throw new Error("kennel.plugin.skill must be a relative path to SKILL.md");
46
+ throw new Error("kennel.json > plugin.skill must be a relative path to SKILL.md");
47
47
  if (isAbsolute(entry) || entry.includes("\0") || entry.includes("\\"))
48
48
  throw new Error(`Unsafe skill path: ${entry}`);
49
49
  if (basename(entry) !== "SKILL.md")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kennel",
3
- "version": "0.2.42",
3
+ "version": "0.2.43",
4
4
  "description": "Open-source pop-up previews for AI output: markdown, HTML artifacts, email, images, code, tables, notes, and calendars, from the terminal or your agent session.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -69,7 +69,7 @@
69
69
  "effect": "^3.19.7",
70
70
  "esbuild": "^0.27.0",
71
71
  "kennel-mcp": "https://registry.npmjs.org/%40raggle-ai%2fkennel-mcp/-/kennel-mcp-0.1.8.tgz",
72
- "kennel-plugins": "0.1.20",
72
+ "kennel-plugins": "0.1.21",
73
73
  "koffi": "^3.3.1",
74
74
  "lucide-react": "^0.562.0",
75
75
  "radix-ui": "^1.6.7",
@@ -124,7 +124,7 @@
124
124
  }
125
125
  },
126
126
  "pluginSkill": {
127
- "description": "Agent skill declared by package.json > kennel.plugin.skill or kennel.plugin.json > skill. Managed by `kennel plugin add` and `kennel plugin skill install`.",
127
+ "description": "Agent skill declared by kennel.json > plugin.skill. Managed by `kennel plugin add` and `kennel plugin skill install`.",
128
128
  "type": "object",
129
129
  "additionalProperties": false,
130
130
  "required": ["entry", "name"],
@@ -155,22 +155,22 @@
155
155
  "entriesWithoutApi": {
156
156
  "type": "object",
157
157
  "additionalProperties": false,
158
- "required": ["client", "server", "manifest"],
158
+ "required": ["index", "server", "definition"],
159
159
  "properties": {
160
- "client": { "$ref": "#/$defs/absolutePath" },
160
+ "index": { "$ref": "#/$defs/absolutePath" },
161
161
  "server": { "$ref": "#/$defs/absolutePath" },
162
- "manifest": { "$ref": "#/$defs/absolutePath" },
162
+ "definition": { "$ref": "#/$defs/absolutePath" },
163
163
  "css": { "$ref": "#/$defs/absolutePath" }
164
164
  }
165
165
  },
166
166
  "entriesWithApi": {
167
167
  "type": "object",
168
168
  "additionalProperties": false,
169
- "required": ["client", "server", "manifest", "api"],
169
+ "required": ["index", "server", "definition", "api"],
170
170
  "properties": {
171
- "client": { "$ref": "#/$defs/absolutePath" },
171
+ "index": { "$ref": "#/$defs/absolutePath" },
172
172
  "server": { "$ref": "#/$defs/absolutePath" },
173
- "manifest": { "$ref": "#/$defs/absolutePath" },
173
+ "definition": { "$ref": "#/$defs/absolutePath" },
174
174
  "api": { "$ref": "#/$defs/absolutePath" },
175
175
  "css": { "$ref": "#/$defs/absolutePath" }
176
176
  }
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema": "https://raggle.co/kennel/plugin.json",
3
+ "plugin": {
4
+ "id": "__PLUGIN_ID__",
5
+ "label": "__PLUGIN_ID__",
6
+ "views": [
7
+ {
8
+ "subpath": "",
9
+ "label": "__PLUGIN_ID__",
10
+ "component": "PluginPage"
11
+ }
12
+ ]
13
+ }
14
+ }
@@ -0,0 +1 @@
1
+ export { PluginPage } from "./page.tsx";
@@ -0,0 +1,5 @@
1
+ import type { FolderPluginViewProps } from "kennel-plugins/client";
2
+
3
+ export function PluginPage(_props: FolderPluginViewProps) {
4
+ return <p>__PLUGIN_ID__ plugin</p>;
5
+ }
@@ -1,8 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import type { FolderPluginServer } from "kennel-plugins/server";
4
-
5
- import manifest from "./manifest";
3
+ import type { FolderPluginServerHooks } from "kennel-plugins/server";
6
4
 
7
5
  // Example content file read by the `llm` hook below: a JSON array of items
8
6
  // with a string `title`, for example [{ "title": "First item" }].
@@ -25,12 +23,11 @@ async function readItems(folder: string) {
25
23
  }
26
24
 
27
25
  export default {
28
- ...manifest,
29
26
  async detect() {
30
27
  return { status: "available" };
31
28
  },
32
29
  async handle() {
33
- return { data: { items: [] } };
30
+ return { kind: "available", data: { items: [] } };
34
31
  },
35
32
  // Optional `knl llm` context. Read only: no writes, network calls, or other
36
33
  // side effects. Return [] when the folder has no content for this plugin.
@@ -7,5 +7,5 @@
7
7
  "target": "ES2022",
8
8
  "types": ["node"]
9
9
  },
10
- "include": ["*.ts", "*.tsx"]
10
+ "include": ["src/**/*.ts", "src/**/*.tsx"]
11
11
  }
@@ -1,12 +0,0 @@
1
- import type { FolderPluginClient } from "kennel-plugins/client";
2
-
3
- import manifest from "./manifest";
4
-
5
- function ExamplePluginView() {
6
- return <div>{manifest.label} plugin</div>;
7
- }
8
-
9
- export default {
10
- ...manifest,
11
- component: ExamplePluginView,
12
- } satisfies FolderPluginClient;
@@ -1,3 +0,0 @@
1
- {
2
- "id": "__PLUGIN_ID__"
3
- }
@@ -1,6 +0,0 @@
1
- import type { FolderPluginManifest } from "kennel-plugins";
2
-
3
- export default {
4
- id: "__PLUGIN_ID__",
5
- label: "Example",
6
- } satisfies FolderPluginManifest;