kennel 0.2.41 → 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.
@@ -242,7 +242,7 @@ function repositoryName(folder) {
242
242
  return;
243
243
  }
244
244
  }
245
- function previewTerminal(url, target, startShare) {
245
+ function previewTerminal(url, target, startShare, views) {
246
246
  url = new URL(url).href;
247
247
  const input = process.stdin;
248
248
  const output = process.stdout;
@@ -253,8 +253,9 @@ function previewTerminal(url, target, startShare) {
253
253
  const repository = interactive ? repositoryName(target.root) : undefined;
254
254
  const color = terminal && process.env.NO_COLOR === undefined && output.hasColors();
255
255
  const accent = (text) => color ? `\x1B[32m${text}\x1B[39m` : text;
256
- const link = (label) => terminal ? `\x1B]8;;${url}\x1B\\${accent(label)}\x1B]8;;\x1B\\` : `${label}
257
- ${url}`;
256
+ const linkTo = (label, href, plain = `${label}
257
+ ${href}`) => terminal ? `\x1B]8;;${href}\x07${accent(label)}\x1B]8;;\x07` : plain;
258
+ const link = (label) => linkTo(label, url);
258
259
  const wasRaw = Boolean(input.isRaw);
259
260
  const wasFlowing = input.readableFlowing === true;
260
261
  let resolveClosed;
@@ -271,27 +272,28 @@ function previewTerminal(url, target, startShare) {
271
272
  return;
272
273
  cursorTo(output, 0, 0);
273
274
  clearScreenDown(output);
274
- const width = Math.max(1, (output.columns || 80) - 5);
275
+ const width = Math.max(1, (output.columns || 80) - 1);
275
276
  const fit = (text) => {
276
277
  const characters = Array.from(terminalText(text));
277
278
  return characters.length > width ? characters.slice(0, width - 1).join("") + "…" : characters.join("");
278
279
  };
279
- const count = Math.max(1, Math.min(8, (output.rows || 24) - 12));
280
- const start = Math.max(0, Math.min(selected - Math.floor(count / 2), actions.length - count));
281
- const choices = actions.slice(start, start + count).map((item, index) => {
280
+ const choices = [];
281
+ actions.forEach((item, index) => {
282
282
  const label = fit(item.shortcut ? `[${item.shortcut}] ${item.label}` : item.label);
283
- return start + index === selected ? accent(` › ${label}`) : ` ${label}`;
283
+ let text = index === selected ? accent(`› ${label}`) : ` ${label}`;
284
+ if (item.view !== undefined)
285
+ text = linkTo(text, item.view, accent(text));
286
+ choices.push(text);
284
287
  });
285
288
  output.write([
286
- ` \x1B[1m${link(fit(title))}\x1B[22m`,
287
- ...repository ? [` ${color ? "\x1B[2m" : ""}${fit(repository)}${color ? "\x1B[22m" : ""}`] : [],
288
- ...target.target ? [` File: ${fit(name)}`] : [],
289
+ `\x1B[1m${link(fit(title))}\x1B[22m`,
290
+ ...repository ? [`${color ? "\x1B[2m" : ""}${fit(repository)}${color ? "\x1B[22m" : ""}`] : [],
291
+ ...target.target ? [`File: ${fit(name)}`] : [],
289
292
  "",
290
293
  ...choices,
291
294
  "",
292
- " ↑ ↓ to move · Enter to choose · Esc or Ctrl+C to exit",
293
- ...actions.length > count ? [` ${selected + 1} / ${actions.length}`] : [],
294
- ...status ? ["", ` ${status}`] : [],
295
+ "↑ ↓ to move · Enter to choose · Esc or Ctrl+C to exit",
296
+ ...status ? ["", status] : [],
295
297
  ""
296
298
  ].join(`
297
299
  `));
@@ -356,6 +358,10 @@ function previewTerminal(url, target, startShare) {
356
358
  resolveClosed();
357
359
  };
358
360
  const actions = [
361
+ ...(views ?? []).map((view) => ({ label: view.id, run: () => {
362
+ openPreview(view.url);
363
+ status = "Browser open requested.";
364
+ }, view: view.url })),
359
365
  { label: "Open in browser", shortcut: "o", run: () => {
360
366
  openPreview(url);
361
367
  status = "Browser open requested.";
@@ -415,7 +421,7 @@ function previewTerminal(url, target, startShare) {
415
421
  render();
416
422
  else
417
423
  console.log(`
418
- ${link(name)}
424
+ ${link(name)}
419
425
  `);
420
426
  return { close, closed, interactive };
421
427
  }
@@ -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
  };
@@ -25,6 +25,11 @@ export type ExternalPluginEntry = {
25
25
  digest: string;
26
26
  };
27
27
  };
28
+ /** Manifest-declared subviews, recorded at install time. */
29
+ views?: {
30
+ subpath: string;
31
+ label: string;
32
+ }[];
28
33
  };
29
34
  export declare function kennelConfigPath(): string;
30
35
  export declare const PLUGIN_SOURCE_IGNORED: Set<string>;
@@ -35,6 +40,7 @@ export declare function readExternalPlugins(): ExternalPluginEntry[];
35
40
  export declare function installExternalPlugin(url: string, options?: {
36
41
  directory?: string;
37
42
  }): Promise<ExternalPluginEntry>;
43
+ export declare function removeExternalPlugin(id: string): Promise<void>;
38
44
  /** Installs a declared skill from local source or the pinned cached checkout. */
39
45
  export declare function installPluginSkill(id: string, options?: {
40
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,56 +312,27 @@ 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")
@@ -386,40 +351,18 @@ export async function validateExternalPlugin(inputUrl, options = {}) {
386
351
  const root = pluginRoot(checkoutRoot, directory);
387
352
  const pkgPath = join(root, "package.json");
388
353
  const pkg = existsSync(pkgPath) ? JSON.parse(await readFile(pkgPath, "utf8")) : {};
389
- const metadataPath = join(root, "kennel.plugin.json");
390
- const fileMetadata = existsSync(metadataPath)
391
- ? JSON.parse(await readFile(metadataPath, "utf8"))
392
- : undefined;
393
- const metadata = (pkg.kennel?.plugin && typeof pkg.kennel.plugin === "object"
394
- ? pkg.kennel.plugin
395
- : fileMetadata && typeof fileMetadata === "object"
396
- ? fileMetadata
397
- : undefined);
398
- const id = metadata?.id === undefined ? basename(root) : stringField(metadata.id, "plugin id");
399
- if (!VALID_ID.test(id))
400
- 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;
401
357
  if (CORE_IDS.has(id))
402
358
  throw new Error(`External plugin id collides with core plugin: ${id}`);
403
359
  const packageName = typeof pkg.name === "string" && pkg.name.trim() ? pkg.name.trim() : id;
404
- let aliases;
405
- if (metadata?.aliases !== undefined) {
406
- if (!Array.isArray(metadata.aliases))
407
- throw new Error("Plugin aliases must be an array of command names");
408
- aliases = metadata.aliases.map((alias) => stringField(alias, "plugin alias"));
409
- if (aliases.some((alias) => !VALID_ID.test(alias)) || new Set(aliases).size !== aliases.length) {
410
- throw new Error("Plugin aliases must be unique lowercase command names");
411
- }
412
- }
413
- const trigger = metadata?.trigger === undefined ? undefined : stringField(metadata.trigger, "trigger file");
414
- if (trigger && (trigger.includes("/") || trigger.includes("\\") || trigger === "." || trigger === ".."))
415
- throw new Error(`Invalid trigger file: ${trigger}`);
416
- const client = entryByConvention(root, "client.tsx", true);
417
- const server = entryByConvention(root, "server.ts", true);
418
- const manifest = entryByConvention(root, "manifest.ts", true);
419
- const api = entryByConvention(root, "api.ts", false);
420
- const cssValue = metadata?.exports?.css;
421
- const css = cssValue === undefined ? undefined : safeRelativeFile(root, cssValue, "css export path");
422
- 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 }));
423
366
  const entry = {
424
367
  url: source.url,
425
368
  ...(source.requestedRef ? { requestedRef: source.requestedRef } : {}),
@@ -431,13 +374,14 @@ export async function validateExternalPlugin(inputUrl, options = {}) {
431
374
  ...(source.path ? { path: source.path } : {}),
432
375
  ...(trigger ? { trigger } : {}),
433
376
  entries: {
434
- client,
377
+ index,
435
378
  server,
436
- manifest,
379
+ definition,
437
380
  ...(api ? { api } : {}),
438
381
  ...(css ? { css } : {}),
439
382
  },
440
383
  ...(skill ? { skill: { entry: skill.entry, name: skill.name } } : {}),
384
+ ...(views ? { views } : {}),
441
385
  };
442
386
  installPluginDependencies(root, pkg.dependencies);
443
387
  await validateImports(entry);
@@ -470,6 +414,18 @@ export async function installExternalPlugin(url, options = {}) {
470
414
  }
471
415
  return entry;
472
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
+ }
473
429
  /** Uses local source or checks out the pinned cached commit without fetching. */
474
430
  function pinnedPackageRoot(plugin) {
475
431
  if (plugin.path)
@@ -491,7 +447,7 @@ export function installPluginSkill(id, options = {}) {
491
447
  if (!plugin)
492
448
  throw new Error(`No installed plugin with id ${id}`);
493
449
  if (!plugin.skill)
494
- 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)`);
495
451
  const skill = readPluginSkill(pinnedPackageRoot(plugin), plugin.skill.entry);
496
452
  if (skill.name !== plugin.skill.name)
497
453
  throw new Error(`Pinned skill name ${skill.name} does not match recorded name ${plugin.skill.name}`);
@@ -529,7 +485,7 @@ function cliRoot() {
529
485
  export function webRoot() {
530
486
  return resolve(cliRoot(), "web");
531
487
  }
532
- const BUILD_IDENTITY = "kennel-external-plugins-v1";
488
+ const BUILD_IDENTITY = "kennel-json-plugins-v2";
533
489
  function cliVersion() {
534
490
  return JSON.parse(readFileSync(join(cliRoot(), "package.json"), "utf8")).version;
535
491
  }
@@ -633,7 +589,7 @@ async function buildPluginStyles(plugin, outdir, theme) {
633
589
  import("@tailwindcss/oxide"),
634
590
  import("esbuild"),
635
591
  ]);
636
- const source = dirname(plugin.entries.client);
592
+ const source = dirname(plugin.entries.index);
637
593
  const input = [
638
594
  '@import "tailwindcss/theme.css" layer(theme);',
639
595
  `@import ${JSON.stringify(theme)};`,
@@ -713,8 +669,13 @@ async function buildPluginClient(plugin, outdir) {
713
669
  bundle: true,
714
670
  chunkNames: "chunks/[name]-[hash]",
715
671
  define: { "process.env.NODE_ENV": '"production"' },
716
- entryNames: "[name]",
717
- 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
+ },
718
679
  format: "esm",
719
680
  jsx: "automatic",
720
681
  loader: ASSET_LOADERS,
@@ -731,12 +692,6 @@ async function buildPluginClient(plugin, outdir) {
731
692
  const output = Object.entries(result.metafile.outputs).find(([, value]) => value.entryPoint)?.[1];
732
693
  return output?.cssBundle ? [relative(outdir, resolve(output.cssBundle))] : [];
733
694
  }
734
- async function pluginManifest(plugin, outfile) {
735
- await bundleForImport(plugin.entries.manifest, outfile, "browser");
736
- const manifest = (await import(__rewriteRelativeImportExtension(pathToFileURL(outfile).href))).default;
737
- assertManifest(manifest, plugin.id, "manifest export");
738
- return JSON.parse(JSON.stringify(manifest));
739
- }
740
695
  async function buildPlugins(temp, target, key, plugins, theme) {
741
696
  const serverEntries = [];
742
697
  const apiEntries = [];
@@ -746,11 +701,11 @@ async function buildPlugins(temp, target, key, plugins, theme) {
746
701
  const clientStyles = await buildPluginClient(plugin, clientDir);
747
702
  const styles = [await buildPluginStyles(plugin, clientDir, theme), ...clientStyles];
748
703
  registry.push({
749
- manifest: await pluginManifest(plugin, join(temp, "manifest", `${plugin.id}.js`)),
704
+ manifest: pluginDisplayManifest(pluginDefinition(plugin)),
750
705
  module: `/_kennel/plugins/${plugin.id}/client.js`,
751
706
  styles: styles.map((style) => `/_kennel/plugins/${plugin.id}/${style}`),
752
707
  });
753
- 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)));
754
709
  serverEntries.push({ id: plugin.id, module: join(target, "server", `${plugin.id}.js`), symbol: "default", trigger: plugin.trigger });
755
710
  if (plugin.entries.api) {
756
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")
@@ -6,7 +6,7 @@ import { createConnection } from 'node:net';
6
6
  import { tmpdir } from 'node:os';
7
7
  import { dirname, relative, resolve } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
- import { popUrl } from './viewer.js';
9
+ import { popUrl, pluginViewLinks } from './viewer.js';
10
10
  import { createKennelClient } from '../core/api-client.js';
11
11
  import { pluginSetKey } from '../plugins/external.js';
12
12
  import { openPreview, previewTerminal } from './terminal.js';
@@ -179,7 +179,7 @@ export async function hotViewer(options, restarting = false) {
179
179
  return hotViewer(options, true);
180
180
  throw new Error(stale);
181
181
  }
182
- const terminal = previewTerminal(viewerUrl, options);
182
+ const terminal = previewTerminal(viewerUrl, options, pluginViewLinks(viewerUrl, options.root));
183
183
  if (options.open)
184
184
  openPreview(viewerUrl);
185
185
  try {
@@ -245,7 +245,7 @@ export async function hotViewer(options, restarting = false) {
245
245
  },
246
246
  stdio: ['ignore', 'inherit', 'inherit'],
247
247
  });
248
- const terminal = previewTerminal(viewerUrl, options);
248
+ const terminal = previewTerminal(viewerUrl, options, pluginViewLinks(viewerUrl, options.root));
249
249
  void terminal.closed.then(() => {
250
250
  if (child.exitCode === null && child.signalCode === null)
251
251
  child.kill('SIGTERM');
@@ -1,8 +1,13 @@
1
1
  export { openPreview } from "kennel-preview/terminal";
2
+ /** Extra list entries rendered as hyperlinked plugin view links. */
3
+ export type PluginView = {
4
+ id: string;
5
+ url: string;
6
+ };
2
7
  export declare function previewTerminal(url: string, target: {
3
8
  root: string;
4
9
  target?: string;
5
- }): {
10
+ }, views: PluginView[]): {
6
11
  close: () => void;
7
12
  closed: Promise<void>;
8
13
  interactive: boolean;
@@ -31,7 +31,7 @@ function repositoryName(folder) {
31
31
  return;
32
32
  }
33
33
  }
34
- function previewTerminal(url, target, startShare) {
34
+ function previewTerminal(url, target, startShare, views) {
35
35
  url = new URL(url).href;
36
36
  const input = process.stdin;
37
37
  const output = process.stdout;
@@ -42,8 +42,9 @@ function previewTerminal(url, target, startShare) {
42
42
  const repository = interactive ? repositoryName(target.root) : undefined;
43
43
  const color = terminal && process.env.NO_COLOR === undefined && output.hasColors();
44
44
  const accent = (text) => color ? `\x1B[32m${text}\x1B[39m` : text;
45
- const link = (label) => terminal ? `\x1B]8;;${url}\x1B\\${accent(label)}\x1B]8;;\x1B\\` : `${label}
46
- ${url}`;
45
+ const linkTo = (label, href, plain = `${label}
46
+ ${href}`) => terminal ? `\x1B]8;;${href}\x07${accent(label)}\x1B]8;;\x07` : plain;
47
+ const link = (label) => linkTo(label, url);
47
48
  const wasRaw = Boolean(input.isRaw);
48
49
  const wasFlowing = input.readableFlowing === true;
49
50
  let resolveClosed;
@@ -60,27 +61,28 @@ function previewTerminal(url, target, startShare) {
60
61
  return;
61
62
  cursorTo(output, 0, 0);
62
63
  clearScreenDown(output);
63
- const width = Math.max(1, (output.columns || 80) - 5);
64
+ const width = Math.max(1, (output.columns || 80) - 1);
64
65
  const fit = (text) => {
65
66
  const characters = Array.from(terminalText(text));
66
67
  return characters.length > width ? characters.slice(0, width - 1).join("") + "…" : characters.join("");
67
68
  };
68
- const count = Math.max(1, Math.min(8, (output.rows || 24) - 12));
69
- const start = Math.max(0, Math.min(selected - Math.floor(count / 2), actions.length - count));
70
- const choices = actions.slice(start, start + count).map((item, index) => {
69
+ const choices = [];
70
+ actions.forEach((item, index) => {
71
71
  const label = fit(item.shortcut ? `[${item.shortcut}] ${item.label}` : item.label);
72
- return start + index === selected ? accent(` › ${label}`) : ` ${label}`;
72
+ let text = index === selected ? accent(`› ${label}`) : ` ${label}`;
73
+ if (item.view !== undefined)
74
+ text = linkTo(text, item.view, accent(text));
75
+ choices.push(text);
73
76
  });
74
77
  output.write([
75
- ` \x1B[1m${link(fit(title))}\x1B[22m`,
76
- ...repository ? [` ${color ? "\x1B[2m" : ""}${fit(repository)}${color ? "\x1B[22m" : ""}`] : [],
77
- ...target.target ? [` File: ${fit(name)}`] : [],
78
+ `\x1B[1m${link(fit(title))}\x1B[22m`,
79
+ ...repository ? [`${color ? "\x1B[2m" : ""}${fit(repository)}${color ? "\x1B[22m" : ""}`] : [],
80
+ ...target.target ? [`File: ${fit(name)}`] : [],
78
81
  "",
79
82
  ...choices,
80
83
  "",
81
- " ↑ ↓ to move · Enter to choose · Esc or Ctrl+C to exit",
82
- ...actions.length > count ? [` ${selected + 1} / ${actions.length}`] : [],
83
- ...status ? ["", ` ${status}`] : [],
84
+ "↑ ↓ to move · Enter to choose · Esc or Ctrl+C to exit",
85
+ ...status ? ["", status] : [],
84
86
  ""
85
87
  ].join(`
86
88
  `));
@@ -145,6 +147,10 @@ function previewTerminal(url, target, startShare) {
145
147
  resolveClosed();
146
148
  };
147
149
  const actions = [
150
+ ...(views ?? []).map((view) => ({ label: view.id, run: () => {
151
+ openPreview(view.url);
152
+ status = "Browser open requested.";
153
+ }, view: view.url })),
148
154
  { label: "Open in browser", shortcut: "o", run: () => {
149
155
  openPreview(url);
150
156
  status = "Browser open requested.";
@@ -204,17 +210,17 @@ function previewTerminal(url, target, startShare) {
204
210
  render();
205
211
  else
206
212
  console.log(`
207
- ${link(name)}
213
+ ${link(name)}
208
214
  `);
209
215
  return { close, closed, interactive };
210
216
  }
211
217
 
212
218
  // packages/cli/src/site/terminal.ts
213
- function previewTerminal2(url, target) {
219
+ function previewTerminal2(url, target, views) {
214
220
  return previewTerminal(url, target, async (password = false) => {
215
221
  const { startPreviewShare } = await import(new URL("../package/preview.js", import.meta.url).href);
216
222
  return startPreviewShare({ ...target, ...password ? { password: true } : {} });
217
- });
223
+ }, views);
218
224
  }
219
225
  export {
220
226
  openPreview,
@@ -1,3 +1,6 @@
1
+ import { type PluginView } from "./terminal.js";
2
+ /** Installed-plugin view links for the terminal menu; each is hyperlinked. */
3
+ export declare function pluginViewLinks(viewerUrl: string, root: string): PluginView[];
1
4
  export type ViewerOptions = {
2
5
  dryRun: boolean;
3
6
  host: string;
@@ -2,8 +2,25 @@ import { execFileSync } from "node:child_process";
2
2
  import { existsSync, realpathSync, statSync } from "node:fs";
3
3
  import { createServer } from "node:net";
4
4
  import { dirname, isAbsolute, relative, resolve } from "node:path";
5
+ import { readExternalPlugins } from "../plugins/external.js";
5
6
  import { previewKind } from "./kinds.js";
6
7
  import { openPreview, previewTerminal } from "./terminal.js";
8
+ /** Installed-plugin view links for the terminal menu; each is hyperlinked. */
9
+ export function pluginViewLinks(viewerUrl, root) {
10
+ return readExternalPlugins().flatMap((plugin) => {
11
+ const view = (subpath) => {
12
+ const url = new URL(viewerUrl);
13
+ url.pathname = `/folder/${encodeBase64Url(root)}/views/${encodeURIComponent(plugin.id)}${subpath ? `/${encodeURIComponent(subpath)}` : ""}`;
14
+ // A query `folder` makes the route parser ignore the path and select Notes.
15
+ url.searchParams.delete("folder");
16
+ return url.href;
17
+ };
18
+ const views = [{ id: plugin.id, url: view() }];
19
+ for (const item of plugin.views ?? [])
20
+ views.push({ id: `${plugin.id} · ${item.label}`, url: view(item.subpath) });
21
+ return views;
22
+ });
23
+ }
7
24
  export function viewerFolder(root, value) {
8
25
  return resolve(value ?? root);
9
26
  }
@@ -312,7 +329,7 @@ export async function viewer(input, plugin) {
312
329
  existing.kernelKey === plugins.key &&
313
330
  isSameOrDescendant(existing.root, options.root)))) {
314
331
  const viewerUrl = popUrl(displayHost, options.port, options);
315
- const terminal = previewTerminal(viewerUrl, options);
332
+ const terminal = previewTerminal(viewerUrl, options, pluginViewLinks(viewerUrl, options.root));
316
333
  if (options.open)
317
334
  openPreview(viewerUrl);
318
335
  try {
@@ -342,7 +359,7 @@ export async function viewer(input, plugin) {
342
359
  execFileSync("tailscale", ["serve", "--bg", "--yes", options.port]);
343
360
  }
344
361
  const viewerUrl = popUrl(displayHost, options.port, options);
345
- const terminal = previewTerminal(viewerUrl, options);
362
+ const terminal = previewTerminal(viewerUrl, options, pluginViewLinks(viewerUrl, options.root));
346
363
  if (options.open)
347
364
  openPreview(viewerUrl);
348
365
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kennel",
3
- "version": "0.2.41",
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",
@@ -100,6 +100,20 @@
100
100
  "pattern": "^(?!\\.{1,2}$)[^/\\\\]+$"
101
101
  },
102
102
  "skill": { "$ref": "#/$defs/pluginSkill" },
103
+ "views": {
104
+ "description": "Undirected views declared by the plugin manifest, recorded at install time.",
105
+ "type": "array",
106
+ "uniqueItems": true,
107
+ "items": {
108
+ "type": "object",
109
+ "additionalProperties": false,
110
+ "required": ["subpath", "label"],
111
+ "properties": {
112
+ "subpath": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" },
113
+ "label": { "type": "string", "minLength": 1 }
114
+ }
115
+ }
116
+ },
103
117
  "entries": {
104
118
  "description": "Resolved absolute convention entry files. api is optional.",
105
119
  "oneOf": [
@@ -110,7 +124,7 @@
110
124
  }
111
125
  },
112
126
  "pluginSkill": {
113
- "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`.",
114
128
  "type": "object",
115
129
  "additionalProperties": false,
116
130
  "required": ["entry", "name"],
@@ -141,22 +155,22 @@
141
155
  "entriesWithoutApi": {
142
156
  "type": "object",
143
157
  "additionalProperties": false,
144
- "required": ["client", "server", "manifest"],
158
+ "required": ["index", "server", "definition"],
145
159
  "properties": {
146
- "client": { "$ref": "#/$defs/absolutePath" },
160
+ "index": { "$ref": "#/$defs/absolutePath" },
147
161
  "server": { "$ref": "#/$defs/absolutePath" },
148
- "manifest": { "$ref": "#/$defs/absolutePath" },
162
+ "definition": { "$ref": "#/$defs/absolutePath" },
149
163
  "css": { "$ref": "#/$defs/absolutePath" }
150
164
  }
151
165
  },
152
166
  "entriesWithApi": {
153
167
  "type": "object",
154
168
  "additionalProperties": false,
155
- "required": ["client", "server", "manifest", "api"],
169
+ "required": ["index", "server", "definition", "api"],
156
170
  "properties": {
157
- "client": { "$ref": "#/$defs/absolutePath" },
171
+ "index": { "$ref": "#/$defs/absolutePath" },
158
172
  "server": { "$ref": "#/$defs/absolutePath" },
159
- "manifest": { "$ref": "#/$defs/absolutePath" },
173
+ "definition": { "$ref": "#/$defs/absolutePath" },
160
174
  "api": { "$ref": "#/$defs/absolutePath" },
161
175
  "css": { "$ref": "#/$defs/absolutePath" }
162
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;