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.
- package/dist/cli/plugin.js +10 -0
- package/dist/cli/usage.js +2 -0
- package/dist/plugins/definition.d.ts +9 -0
- package/dist/plugins/definition.js +62 -0
- package/dist/plugins/external.d.ts +3 -2
- package/dist/plugins/external.js +59 -110
- package/dist/plugins/skills.d.ts +1 -1
- package/dist/plugins/skills.js +2 -2
- package/package.json +2 -2
- package/schemas/config.schema.json +7 -7
- package/templates/plugin/kennel.json +14 -0
- package/templates/plugin/src/index.ts +1 -0
- package/templates/plugin/src/page.tsx +5 -0
- package/templates/plugin/{server.ts → src/server.ts} +2 -5
- package/templates/plugin/tsconfig.json +1 -1
- package/templates/plugin/client.tsx +0 -12
- package/templates/plugin/kennel.plugin.json +0 -3
- package/templates/plugin/manifest.ts +0 -6
package/dist/cli/plugin.js
CHANGED
|
@@ -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
|
-
|
|
13
|
+
index: string;
|
|
14
14
|
server: string;
|
|
15
|
-
|
|
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;
|
package/dist/plugins/external.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
360
|
-
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
|
396
|
-
const
|
|
397
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
377
|
+
index,
|
|
441
378
|
server,
|
|
442
|
-
|
|
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.
|
|
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-
|
|
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.
|
|
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: "
|
|
723
|
-
|
|
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:
|
|
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");
|
package/dist/plugins/skills.d.ts
CHANGED
|
@@ -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 `
|
|
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;
|
package/dist/plugins/skills.js
CHANGED
|
@@ -38,12 +38,12 @@ function frontmatter(path) {
|
|
|
38
38
|
return data;
|
|
39
39
|
}
|
|
40
40
|
/**
|
|
41
|
-
* Resolves `
|
|
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.
|
|
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.
|
|
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
|
|
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": ["
|
|
158
|
+
"required": ["index", "server", "definition"],
|
|
159
159
|
"properties": {
|
|
160
|
-
"
|
|
160
|
+
"index": { "$ref": "#/$defs/absolutePath" },
|
|
161
161
|
"server": { "$ref": "#/$defs/absolutePath" },
|
|
162
|
-
"
|
|
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": ["
|
|
169
|
+
"required": ["index", "server", "definition", "api"],
|
|
170
170
|
"properties": {
|
|
171
|
-
"
|
|
171
|
+
"index": { "$ref": "#/$defs/absolutePath" },
|
|
172
172
|
"server": { "$ref": "#/$defs/absolutePath" },
|
|
173
|
-
"
|
|
173
|
+
"definition": { "$ref": "#/$defs/absolutePath" },
|
|
174
174
|
"api": { "$ref": "#/$defs/absolutePath" },
|
|
175
175
|
"css": { "$ref": "#/$defs/absolutePath" }
|
|
176
176
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { PluginPage } from "./page.tsx";
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import type {
|
|
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.
|
|
@@ -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;
|