kennel 0.2.23 → 0.2.24
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/dispatch.js +4 -52
- package/dist/cli/plugin.d.ts +1 -0
- package/dist/cli/plugin.js +103 -0
- package/dist/cli/usage.js +3 -1
- package/dist/plugins/external.d.ts +32 -0
- package/dist/plugins/external.js +60 -1
- package/dist/plugins/skills.d.ts +30 -0
- package/dist/plugins/skills.js +175 -0
- package/dist/site/folder-icons.d.ts +0 -7
- package/dist/site/folder-icons.js +3 -21
- package/package.json +1 -1
- package/schemas/config.schema.json +30 -0
package/dist/cli/dispatch.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { dirname, resolve } from 'node:path';
|
|
3
|
-
import { fileURLToPath } from 'node:url';
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
4
2
|
import { usage } from './usage.js';
|
|
5
3
|
export async function dispatch(argv) {
|
|
6
4
|
const [, , command, ...args] = argv;
|
|
@@ -13,55 +11,9 @@ export async function dispatch(argv) {
|
|
|
13
11
|
await viewer([]);
|
|
14
12
|
return;
|
|
15
13
|
}
|
|
16
|
-
if (command === 'plugin'
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
for (let index = 2; index < args.length; index += 1) {
|
|
20
|
-
const arg = args[index];
|
|
21
|
-
if (arg === '--path' || arg === '--directory') {
|
|
22
|
-
directory = args[index + 1];
|
|
23
|
-
if (!directory)
|
|
24
|
-
throw new Error(`${arg} requires a plugin subdirectory`);
|
|
25
|
-
index += 1;
|
|
26
|
-
}
|
|
27
|
-
else {
|
|
28
|
-
throw new Error('Usage: kennel plugin add <git-repository-url-or-local-path> [--path <subdirectory>]');
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
if (!url)
|
|
32
|
-
throw new Error('Usage: kennel plugin add <git-repository-url-or-local-path> [--path <subdirectory>]');
|
|
33
|
-
const { installExternalPlugin } = await import('../plugins/external.js');
|
|
34
|
-
const plugin = await installExternalPlugin(url, { directory });
|
|
35
|
-
const source = plugin.path ?? plugin.url;
|
|
36
|
-
console.log(`Installed plugin ${plugin.id} from ${source} at ${plugin.commit}`);
|
|
37
|
-
return;
|
|
38
|
-
}
|
|
39
|
-
if (command === 'plugin' && args[0] === 'new') {
|
|
40
|
-
const id = args[1];
|
|
41
|
-
if (!id || args.length > 2)
|
|
42
|
-
throw new Error('Usage: kennel plugin new <id>');
|
|
43
|
-
if (!/^[a-z][a-z0-9-]*$/u.test(id))
|
|
44
|
-
throw new Error(`Invalid plugin id: ${id}`);
|
|
45
|
-
const target = resolve(id);
|
|
46
|
-
if (existsSync(target))
|
|
47
|
-
throw new Error(`Refusing to overwrite existing path: ${target}`);
|
|
48
|
-
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
|
49
|
-
const template = resolve(root, 'templates/plugin');
|
|
50
|
-
mkdirSync(target, { recursive: true });
|
|
51
|
-
cpSync(template, target, { recursive: true });
|
|
52
|
-
const replacePlaceholders = (folder) => {
|
|
53
|
-
for (const name of readdirSync(folder)) {
|
|
54
|
-
const path = resolve(folder, name);
|
|
55
|
-
if (statSync(path).isDirectory()) {
|
|
56
|
-
replacePlaceholders(path);
|
|
57
|
-
continue;
|
|
58
|
-
}
|
|
59
|
-
const content = readFileSync(path, 'utf8').replaceAll('__PLUGIN_ID__', id);
|
|
60
|
-
writeFileSync(path, content);
|
|
61
|
-
}
|
|
62
|
-
};
|
|
63
|
-
replacePlaceholders(target);
|
|
64
|
-
console.log(`Created Kennel plugin ${id} in ${target}`);
|
|
14
|
+
if (command === 'plugin') {
|
|
15
|
+
const { plugin } = await import('./plugin.js');
|
|
16
|
+
await plugin(args);
|
|
65
17
|
return;
|
|
66
18
|
}
|
|
67
19
|
if (command === 'core') {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function plugin(args: string[]): Promise<void>;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
const USAGE = `Usage:
|
|
5
|
+
kennel plugin add <git-repository-url-or-local-path> [--path <subdirectory>] [--install-skill]
|
|
6
|
+
kennel plugin skill install <plugin-id> [--force]
|
|
7
|
+
kennel plugin list
|
|
8
|
+
kennel plugin new <id>`;
|
|
9
|
+
function usageError() {
|
|
10
|
+
throw new Error(USAGE);
|
|
11
|
+
}
|
|
12
|
+
async function installSkill(id, force) {
|
|
13
|
+
const { installPluginSkill } = await import('../plugins/external.js');
|
|
14
|
+
const skill = installPluginSkill(id, { force });
|
|
15
|
+
console.log(`Installed skill ${skill.name} at ${skill.path} (commit ${skill.commit})`);
|
|
16
|
+
for (const duplicate of skill.duplicates) {
|
|
17
|
+
console.warn(`Warning: another skill named ${skill.name} exists at ${duplicate}; Kennel did not change it.`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export async function plugin(args) {
|
|
21
|
+
const [subcommand, ...rest] = args;
|
|
22
|
+
if (subcommand === 'new') {
|
|
23
|
+
const id = rest[0];
|
|
24
|
+
if (!id || rest.length > 1)
|
|
25
|
+
throw new Error('Usage: kennel plugin new <id>');
|
|
26
|
+
if (!/^[a-z][a-z0-9-]*$/u.test(id))
|
|
27
|
+
throw new Error(`Invalid plugin id: ${id}`);
|
|
28
|
+
const target = resolve(id);
|
|
29
|
+
if (existsSync(target))
|
|
30
|
+
throw new Error(`Refusing to overwrite existing path: ${target}`);
|
|
31
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
|
32
|
+
const template = resolve(root, 'templates/plugin');
|
|
33
|
+
mkdirSync(target, { recursive: true });
|
|
34
|
+
cpSync(template, target, { recursive: true });
|
|
35
|
+
const replacePlaceholders = (folder) => {
|
|
36
|
+
for (const name of readdirSync(folder)) {
|
|
37
|
+
const path = resolve(folder, name);
|
|
38
|
+
if (statSync(path).isDirectory()) {
|
|
39
|
+
replacePlaceholders(path);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const content = readFileSync(path, 'utf8').replaceAll('__PLUGIN_ID__', id);
|
|
43
|
+
writeFileSync(path, content);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
replacePlaceholders(target);
|
|
47
|
+
console.log(`Created Kennel plugin ${id} in ${target}`);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (subcommand === 'add') {
|
|
51
|
+
const [url, ...flags] = rest;
|
|
52
|
+
let directory;
|
|
53
|
+
let withSkill = false;
|
|
54
|
+
for (let index = 0; index < flags.length; index += 1) {
|
|
55
|
+
const arg = flags[index];
|
|
56
|
+
if (arg === '--path' || arg === '--directory') {
|
|
57
|
+
directory = flags[index + 1];
|
|
58
|
+
if (!directory)
|
|
59
|
+
throw new Error(`${arg} requires a plugin subdirectory`);
|
|
60
|
+
index += 1;
|
|
61
|
+
}
|
|
62
|
+
else if (arg === '--install-skill') {
|
|
63
|
+
withSkill = true;
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
usageError();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (!url || url.startsWith('-'))
|
|
70
|
+
usageError();
|
|
71
|
+
const { installExternalPlugin } = await import('../plugins/external.js');
|
|
72
|
+
const entry = await installExternalPlugin(url, { directory });
|
|
73
|
+
console.log(`Installed plugin ${entry.id} from ${entry.path ?? entry.url} at ${entry.commit}`);
|
|
74
|
+
if (withSkill)
|
|
75
|
+
await installSkill(entry.id, false);
|
|
76
|
+
else if (entry.skill)
|
|
77
|
+
console.log(`Plugin declares skill ${entry.skill.name}; install it with \`kennel plugin skill install ${entry.id}\``);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (subcommand === 'skill' && rest[0] === 'install') {
|
|
81
|
+
const [, id, ...flags] = rest;
|
|
82
|
+
if (!id || id.startsWith('-') || flags.some((flag) => flag !== '--force'))
|
|
83
|
+
usageError();
|
|
84
|
+
await installSkill(id, flags.includes('--force'));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (subcommand === 'list' && rest.length === 0) {
|
|
88
|
+
const { listPluginStatus } = await import('../plugins/external.js');
|
|
89
|
+
const plugins = listPluginStatus();
|
|
90
|
+
if (plugins.length === 0) {
|
|
91
|
+
console.log('No external plugins installed.');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
for (const item of plugins) {
|
|
95
|
+
const skill = item.skill.name
|
|
96
|
+
? `skill ${item.skill.name}: ${item.skill.state} (${item.skill.path})`
|
|
97
|
+
: 'skill: missing (none declared)';
|
|
98
|
+
console.log(`${item.id}\t${item.packageName}@${item.commit.slice(0, 12)}\t${skill}`);
|
|
99
|
+
}
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
usageError();
|
|
103
|
+
}
|
package/dist/cli/usage.js
CHANGED
|
@@ -2,7 +2,9 @@ export function usage() {
|
|
|
2
2
|
console.log(`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
|
-
kennel plugin add <git-repository-url-or-local-path> [--path <subdirectory>]
|
|
5
|
+
kennel plugin add <git-repository-url-or-local-path> [--path <subdirectory>] [--install-skill]
|
|
6
|
+
kennel plugin skill install <plugin-id> [--force]
|
|
7
|
+
kennel plugin list
|
|
6
8
|
kennel plugin new <id>
|
|
7
9
|
kennel core-folders [path] [--json]
|
|
8
10
|
kennel [folder] [--host <host>] [--port <port>] [--lan|--tailscale]
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type SkillState } from "./skills.js";
|
|
1
2
|
export type ExternalPluginEntry = {
|
|
2
3
|
url: string;
|
|
3
4
|
requestedRef?: string;
|
|
@@ -15,6 +16,15 @@ export type ExternalPluginEntry = {
|
|
|
15
16
|
api?: string;
|
|
16
17
|
css?: string;
|
|
17
18
|
};
|
|
19
|
+
skill?: {
|
|
20
|
+
entry: string;
|
|
21
|
+
name: string;
|
|
22
|
+
installed?: {
|
|
23
|
+
path: string;
|
|
24
|
+
commit: string;
|
|
25
|
+
digest: string;
|
|
26
|
+
};
|
|
27
|
+
};
|
|
18
28
|
};
|
|
19
29
|
export declare function kennelConfigPath(): string;
|
|
20
30
|
export declare const PLUGIN_SOURCE_IGNORED: Set<string>;
|
|
@@ -25,6 +35,28 @@ export declare function readExternalPlugins(): ExternalPluginEntry[];
|
|
|
25
35
|
export declare function installExternalPlugin(url: string, options?: {
|
|
26
36
|
directory?: string;
|
|
27
37
|
}): Promise<ExternalPluginEntry>;
|
|
38
|
+
/** Installs a declared skill from local source or the pinned cached checkout. */
|
|
39
|
+
export declare function installPluginSkill(id: string, options?: {
|
|
40
|
+
force?: boolean;
|
|
41
|
+
}): {
|
|
42
|
+
duplicates: string[];
|
|
43
|
+
path: string;
|
|
44
|
+
commit: string;
|
|
45
|
+
digest: string;
|
|
46
|
+
name: string;
|
|
47
|
+
};
|
|
48
|
+
export type PluginStatus = {
|
|
49
|
+
id: string;
|
|
50
|
+
packageName: string;
|
|
51
|
+
commit: string;
|
|
52
|
+
skill: {
|
|
53
|
+
name?: string;
|
|
54
|
+
state: SkillState;
|
|
55
|
+
path?: string;
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
/** Reports installed plugins and the real state of each declared skill. */
|
|
59
|
+
export declare function listPluginStatus(): PluginStatus[];
|
|
28
60
|
/** The compiled host web app this CLI serves. */
|
|
29
61
|
export declare function webRoot(): string;
|
|
30
62
|
/**
|
package/dist/plugins/external.js
CHANGED
|
@@ -14,6 +14,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve, } from "node:pa
|
|
|
14
14
|
import { pathToFileURL, fileURLToPath } from "node:url";
|
|
15
15
|
import { spawnSync } from "node:child_process";
|
|
16
16
|
import { z } from "zod";
|
|
17
|
+
import { duplicateSkills, readPluginSkill, skillDestination, skillState, writeSkill } from "./skills.js";
|
|
17
18
|
const CONFIG_FILE = join("kennel", "config.json");
|
|
18
19
|
const VALID_ID = /^[a-z][a-z0-9-]*$/u;
|
|
19
20
|
const CORE_IDS = new Set(["agents", "notes"]);
|
|
@@ -142,9 +143,13 @@ function resolveCommit(url, requestedRef) {
|
|
|
142
143
|
throw new Error(`Could not resolve ref ${requestedRef} for ${url}`);
|
|
143
144
|
return commit;
|
|
144
145
|
}
|
|
145
|
-
function
|
|
146
|
+
function sourceDir(url) {
|
|
146
147
|
const dir = join(cacheRoot(), "plugins", "sources", hash(url), "repo");
|
|
147
148
|
assertUnderCache(dir);
|
|
149
|
+
return dir;
|
|
150
|
+
}
|
|
151
|
+
function sourceCheckout(url, commit) {
|
|
152
|
+
const dir = sourceDir(url);
|
|
148
153
|
mkdirSync(dirname(dir), { recursive: true });
|
|
149
154
|
if (!existsSync(join(dir, ".git")))
|
|
150
155
|
run("git", ["clone", "--no-checkout", url, dir]);
|
|
@@ -400,6 +405,7 @@ export async function validateExternalPlugin(inputUrl, options = {}) {
|
|
|
400
405
|
const api = entryByConvention(root, "api.ts", false);
|
|
401
406
|
const cssValue = metadata?.exports?.css;
|
|
402
407
|
const css = cssValue === undefined ? undefined : safeRelativeFile(root, cssValue, "css export path");
|
|
408
|
+
const skill = metadata?.skill === undefined ? undefined : readPluginSkill(root, metadata.skill);
|
|
403
409
|
const entry = {
|
|
404
410
|
url: source.url,
|
|
405
411
|
...(source.requestedRef ? { requestedRef: source.requestedRef } : {}),
|
|
@@ -417,6 +423,7 @@ export async function validateExternalPlugin(inputUrl, options = {}) {
|
|
|
417
423
|
...(api ? { api } : {}),
|
|
418
424
|
...(css ? { css } : {}),
|
|
419
425
|
},
|
|
426
|
+
...(skill ? { skill: { entry: skill.entry, name: skill.name } } : {}),
|
|
420
427
|
};
|
|
421
428
|
installPluginDependencies(root, pkg.dependencies);
|
|
422
429
|
await validateImports(entry);
|
|
@@ -449,6 +456,58 @@ export async function installExternalPlugin(url, options = {}) {
|
|
|
449
456
|
}
|
|
450
457
|
return entry;
|
|
451
458
|
}
|
|
459
|
+
/** Uses local source or checks out the pinned cached commit without fetching. */
|
|
460
|
+
function pinnedPackageRoot(plugin) {
|
|
461
|
+
if (plugin.path)
|
|
462
|
+
return pluginRoot(realpathSync(plugin.path), plugin.directory);
|
|
463
|
+
const dir = sourceDir(plugin.url);
|
|
464
|
+
if (!existsSync(join(dir, ".git")))
|
|
465
|
+
throw new Error(`Cached source for ${plugin.id} is missing; run \`kennel plugin add\` again`);
|
|
466
|
+
run("git", ["checkout", "--force", "--quiet", plugin.commit], { cwd: dir });
|
|
467
|
+
if (run("git", ["rev-parse", "HEAD"], { cwd: dir }) !== plugin.commit)
|
|
468
|
+
throw new Error(`Cached source for ${plugin.id} is not at ${plugin.commit}`);
|
|
469
|
+
return pluginRoot(realpathSync(dir), plugin.directory);
|
|
470
|
+
}
|
|
471
|
+
/** Installs a declared skill from local source or the pinned cached checkout. */
|
|
472
|
+
export function installPluginSkill(id, options = {}) {
|
|
473
|
+
const path = kennelConfigPath();
|
|
474
|
+
const config = readJson(path);
|
|
475
|
+
const plugins = config.externalPlugins ?? [];
|
|
476
|
+
const plugin = plugins.find((item) => item.id === id);
|
|
477
|
+
if (!plugin)
|
|
478
|
+
throw new Error(`No installed plugin with id ${id}`);
|
|
479
|
+
if (!plugin.skill)
|
|
480
|
+
throw new Error(`Plugin ${id} declares no skill (kennel.plugin.skill or kennel.plugin.json > skill)`);
|
|
481
|
+
const skill = readPluginSkill(pinnedPackageRoot(plugin), plugin.skill.entry);
|
|
482
|
+
if (skill.name !== plugin.skill.name)
|
|
483
|
+
throw new Error(`Pinned skill name ${skill.name} does not match recorded name ${plugin.skill.name}`);
|
|
484
|
+
const duplicates = duplicateSkills(skill.name);
|
|
485
|
+
const commit = plugin.path ? contentHash(pluginRoot(plugin.path, plugin.directory)) : plugin.commit;
|
|
486
|
+
const written = writeSkill(skill, options);
|
|
487
|
+
const installed = { path: written.path, commit, digest: written.digest };
|
|
488
|
+
const nextConfig = {
|
|
489
|
+
...config,
|
|
490
|
+
externalPlugins: plugins.map((item) => item.id === id ? { ...item, skill: { entry: skill.entry, name: skill.name, installed } } : item),
|
|
491
|
+
};
|
|
492
|
+
assertUserConfig(nextConfig, path);
|
|
493
|
+
writeJsonAtomic(path, nextConfig);
|
|
494
|
+
return { name: skill.name, ...installed, duplicates };
|
|
495
|
+
}
|
|
496
|
+
/** Reports installed plugins and the real state of each declared skill. */
|
|
497
|
+
export function listPluginStatus() {
|
|
498
|
+
return readExternalPlugins().map((plugin) => ({
|
|
499
|
+
id: plugin.id,
|
|
500
|
+
packageName: plugin.packageName,
|
|
501
|
+
commit: plugin.commit,
|
|
502
|
+
skill: plugin.skill
|
|
503
|
+
? {
|
|
504
|
+
name: plugin.skill.name,
|
|
505
|
+
state: skillState(plugin.skill.name, plugin.skill.installed?.digest),
|
|
506
|
+
path: skillDestination(plugin.skill.name),
|
|
507
|
+
}
|
|
508
|
+
: { state: "missing" },
|
|
509
|
+
}));
|
|
510
|
+
}
|
|
452
511
|
function cliRoot() {
|
|
453
512
|
return resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
454
513
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type PluginSkill = {
|
|
2
|
+
entry: string;
|
|
3
|
+
name: string;
|
|
4
|
+
directory: string;
|
|
5
|
+
};
|
|
6
|
+
export type SkillState = "missing" | "installed" | "conflict";
|
|
7
|
+
/** Canonical global agent store: `${AGENTS_HOME:-$HOME/.agents}/skills`. */
|
|
8
|
+
export declare function agentSkillsRoot(): string;
|
|
9
|
+
export declare function skillDestination(name: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* Resolves `package.json > kennel.plugin.skill` inside a plugin package root.
|
|
12
|
+
* The entry must be a contained, non-symlinked `SKILL.md` with a valid name and description.
|
|
13
|
+
*/
|
|
14
|
+
export declare function readPluginSkill(packageRoot: string, entry: unknown): PluginSkill;
|
|
15
|
+
/** Content digest of a skill directory: relative paths plus file bytes. */
|
|
16
|
+
export declare function skillDigest(directory: string): string;
|
|
17
|
+
/** Reports the installed skill state at the canonical destination. */
|
|
18
|
+
export declare function skillState(name: string, digest?: string): SkillState;
|
|
19
|
+
/** Same-name skills in known agent stores other than the canonical one. Read only. */
|
|
20
|
+
export declare function duplicateSkills(name: string): string[];
|
|
21
|
+
/**
|
|
22
|
+
* Copies a resolved skill directory into the canonical agent store.
|
|
23
|
+
* An existing destination is a conflict unless `force` is set.
|
|
24
|
+
*/
|
|
25
|
+
export declare function writeSkill(skill: PluginSkill, options?: {
|
|
26
|
+
force?: boolean;
|
|
27
|
+
}): {
|
|
28
|
+
path: string;
|
|
29
|
+
digest: string;
|
|
30
|
+
};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { parse as parseYaml } from "yaml";
|
|
6
|
+
/** Agent skill folder names follow the Agent Skills naming rule. */
|
|
7
|
+
const VALID_SKILL_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/u;
|
|
8
|
+
/** Never copy repository metadata when a skill lives at a repository root. */
|
|
9
|
+
const SKIPPED_ENTRIES = new Set([".git"]);
|
|
10
|
+
function isInside(root, target) {
|
|
11
|
+
const rel = relative(root, target);
|
|
12
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
13
|
+
}
|
|
14
|
+
/** Canonical global agent store: `${AGENTS_HOME:-$HOME/.agents}/skills`. */
|
|
15
|
+
export function agentSkillsRoot() {
|
|
16
|
+
const home = process.env.AGENTS_HOME?.trim() || join(homedir(), ".agents");
|
|
17
|
+
if (!isAbsolute(home))
|
|
18
|
+
throw new Error(`AGENTS_HOME must be an absolute path: ${home}`);
|
|
19
|
+
return resolve(home, "skills");
|
|
20
|
+
}
|
|
21
|
+
export function skillDestination(name) {
|
|
22
|
+
if (!VALID_SKILL_NAME.test(name))
|
|
23
|
+
throw new Error(`Invalid skill name: ${name}`);
|
|
24
|
+
const root = agentSkillsRoot();
|
|
25
|
+
const target = resolve(root, name);
|
|
26
|
+
if (dirname(target) !== root)
|
|
27
|
+
throw new Error(`Refusing skill path outside ${root}: ${name}`);
|
|
28
|
+
return target;
|
|
29
|
+
}
|
|
30
|
+
function frontmatter(path) {
|
|
31
|
+
const text = readFileSync(path, "utf8");
|
|
32
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u.exec(text);
|
|
33
|
+
if (!match)
|
|
34
|
+
throw new Error(`Skill ${path} has no YAML frontmatter`);
|
|
35
|
+
const data = parseYaml(match[1] ?? "");
|
|
36
|
+
if (!data || typeof data !== "object" || Array.isArray(data))
|
|
37
|
+
throw new Error(`Skill ${path} frontmatter is not a mapping`);
|
|
38
|
+
return data;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Resolves `package.json > kennel.plugin.skill` inside a plugin package root.
|
|
42
|
+
* The entry must be a contained, non-symlinked `SKILL.md` with a valid name and description.
|
|
43
|
+
*/
|
|
44
|
+
export function readPluginSkill(packageRoot, entry) {
|
|
45
|
+
if (typeof entry !== "string" || !entry.trim())
|
|
46
|
+
throw new Error("kennel.plugin.skill must be a relative path to SKILL.md");
|
|
47
|
+
if (isAbsolute(entry) || entry.includes("\0") || entry.includes("\\"))
|
|
48
|
+
throw new Error(`Unsafe skill path: ${entry}`);
|
|
49
|
+
if (basename(entry) !== "SKILL.md")
|
|
50
|
+
throw new Error(`Skill path must name a SKILL.md file: ${entry}`);
|
|
51
|
+
const root = realpathSync(packageRoot);
|
|
52
|
+
const file = resolve(root, entry);
|
|
53
|
+
if (!isInside(root, file) || file === root)
|
|
54
|
+
throw new Error(`Unsafe skill path: ${entry}`);
|
|
55
|
+
if (!existsSync(file))
|
|
56
|
+
throw new Error(`Missing skill file: ${entry}`);
|
|
57
|
+
const directory = realpathSync(dirname(file));
|
|
58
|
+
if (!isInside(root, directory))
|
|
59
|
+
throw new Error(`Skill directory escapes the plugin package: ${entry}`);
|
|
60
|
+
const stat = lstatSync(join(directory, "SKILL.md"));
|
|
61
|
+
if (!stat.isFile())
|
|
62
|
+
throw new Error(`Skill path is not a regular file: ${entry}`);
|
|
63
|
+
const data = frontmatter(join(directory, "SKILL.md"));
|
|
64
|
+
const name = typeof data.name === "string" ? data.name.trim() : "";
|
|
65
|
+
const description = typeof data.description === "string" ? data.description.trim() : "";
|
|
66
|
+
if (!name)
|
|
67
|
+
throw new Error(`Skill ${entry} frontmatter needs a nonempty name`);
|
|
68
|
+
if (!description)
|
|
69
|
+
throw new Error(`Skill ${entry} frontmatter needs a nonempty description`);
|
|
70
|
+
if (!VALID_SKILL_NAME.test(name))
|
|
71
|
+
throw new Error(`Skill ${entry} frontmatter name must match ${VALID_SKILL_NAME}: ${name}`);
|
|
72
|
+
return { entry: relative(root, join(directory, "SKILL.md")).split(sep).join("/"), name, directory };
|
|
73
|
+
}
|
|
74
|
+
/** Lists regular files under a skill directory. Symlinks and special files are refused. */
|
|
75
|
+
function skillFiles(directory, prefix = "") {
|
|
76
|
+
return readdirSync(join(directory, prefix), { withFileTypes: true })
|
|
77
|
+
.filter((item) => !SKIPPED_ENTRIES.has(item.name))
|
|
78
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
79
|
+
.flatMap((item) => {
|
|
80
|
+
const rel = prefix ? join(prefix, item.name) : item.name;
|
|
81
|
+
if (item.isDirectory())
|
|
82
|
+
return skillFiles(directory, rel);
|
|
83
|
+
if (item.isFile())
|
|
84
|
+
return [rel];
|
|
85
|
+
throw new Error(`Skill contains an unsupported entry (symlink or special file): ${join(directory, rel)}`);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
/** Content digest of a skill directory: relative paths plus file bytes. */
|
|
89
|
+
export function skillDigest(directory) {
|
|
90
|
+
const hash = createHash("sha256");
|
|
91
|
+
for (const file of skillFiles(directory)) {
|
|
92
|
+
hash.update(file.split(sep).join("/")).update("\0").update(readFileSync(join(directory, file))).update("\0");
|
|
93
|
+
}
|
|
94
|
+
return hash.digest("hex");
|
|
95
|
+
}
|
|
96
|
+
/** Reports the installed skill state at the canonical destination. */
|
|
97
|
+
export function skillState(name, digest) {
|
|
98
|
+
const target = skillDestination(name);
|
|
99
|
+
if (!existsSync(target) && !isSymlink(target))
|
|
100
|
+
return "missing";
|
|
101
|
+
try {
|
|
102
|
+
if (!lstatSync(target).isDirectory())
|
|
103
|
+
return "conflict";
|
|
104
|
+
return digest && skillDigest(target) === digest ? "installed" : "conflict";
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return "conflict";
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function isSymlink(path) {
|
|
111
|
+
try {
|
|
112
|
+
return lstatSync(path).isSymbolicLink();
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** Same-name skills in known agent stores other than the canonical one. Read only. */
|
|
119
|
+
export function duplicateSkills(name) {
|
|
120
|
+
const canonical = agentSkillsRoot();
|
|
121
|
+
const roots = [
|
|
122
|
+
process.env.CODEX_HOME?.trim() ? join(process.env.CODEX_HOME.trim(), "skills") : undefined,
|
|
123
|
+
join(homedir(), ".codex", "skills"),
|
|
124
|
+
join(homedir(), ".claude", "skills"),
|
|
125
|
+
].filter((root) => Boolean(root));
|
|
126
|
+
const canonicalReal = existsSync(canonical) ? realpathSync(canonical) : canonical;
|
|
127
|
+
const seen = new Set();
|
|
128
|
+
return roots.flatMap((root) => {
|
|
129
|
+
const real = existsSync(root) ? realpathSync(root) : resolve(root);
|
|
130
|
+
if (real === canonicalReal || seen.has(real))
|
|
131
|
+
return [];
|
|
132
|
+
seen.add(real);
|
|
133
|
+
const path = join(root, name);
|
|
134
|
+
return existsSync(path) || isSymlink(path) ? [path] : [];
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Copies a resolved skill directory into the canonical agent store.
|
|
139
|
+
* An existing destination is a conflict unless `force` is set.
|
|
140
|
+
*/
|
|
141
|
+
export function writeSkill(skill, options = {}) {
|
|
142
|
+
const root = agentSkillsRoot();
|
|
143
|
+
const target = skillDestination(skill.name);
|
|
144
|
+
const exists = existsSync(target) || isSymlink(target);
|
|
145
|
+
if (exists && !options.force) {
|
|
146
|
+
throw new Error(`Skill conflict: ${target} already exists. Re-run with --force to replace it.`);
|
|
147
|
+
}
|
|
148
|
+
const files = skillFiles(skill.directory);
|
|
149
|
+
mkdirSync(root, { recursive: true });
|
|
150
|
+
const temp = join(root, `.${skill.name}.kennel-${process.pid}-${Date.now()}`);
|
|
151
|
+
try {
|
|
152
|
+
for (const file of files) {
|
|
153
|
+
const from = join(skill.directory, file);
|
|
154
|
+
const to = join(temp, file);
|
|
155
|
+
if (!isInside(skill.directory, from) || !isInside(temp, to))
|
|
156
|
+
throw new Error(`Unsafe skill file: ${file}`);
|
|
157
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
158
|
+
cpSync(from, to, { errorOnExist: true, force: false, preserveTimestamps: true });
|
|
159
|
+
}
|
|
160
|
+
const digest = skillDigest(temp);
|
|
161
|
+
if (exists) {
|
|
162
|
+
const previous = `${temp}.previous`;
|
|
163
|
+
renameSync(target, previous);
|
|
164
|
+
renameSync(temp, target);
|
|
165
|
+
rmSync(previous, { force: true, recursive: true });
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
renameSync(temp, target);
|
|
169
|
+
}
|
|
170
|
+
return { path: target, digest };
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
rmSync(temp, { force: true, recursive: true });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -20,13 +20,6 @@ export declare function assetKey(value: Buffer): string;
|
|
|
20
20
|
export declare function encodeBase64Url(value: string): string;
|
|
21
21
|
export declare function decodeBase64Url(value: string): string;
|
|
22
22
|
export declare function iconUrl(folder: string, hash: string, mediaType: IconMediaType): string;
|
|
23
|
-
/**
|
|
24
|
-
* Nearest ancestor icon for a folder, walking up parent directories so
|
|
25
|
-
* nested folders inherit the closest icon above them. The bundled
|
|
26
|
-
* `@raggle-ai/local` does not export the ancestor-aware discovery helper
|
|
27
|
-
* yet, so the walk is implemented here over `discoverProjectIcon`.
|
|
28
|
-
*/
|
|
29
|
-
export declare function discoverAncestorIcon(folder: string): string | undefined;
|
|
30
23
|
export declare function folderIcon(folder: string, assets: Record<string, IconAsset>, assetFiles: Map<string, {
|
|
31
24
|
mediaType: IconMediaType;
|
|
32
25
|
path: string;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { readFile, stat } from 'node:fs/promises';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { extname } from 'node:path';
|
|
4
|
+
import { discoverFolderIcon } from '@raggle-ai/local';
|
|
5
5
|
/**
|
|
6
6
|
* Folder icons are served as immutable, content-addressed assets, so the
|
|
7
7
|
* cap is generous: it only guards against pathological files, not normal
|
|
@@ -34,26 +34,8 @@ export function decodeBase64Url(value) {
|
|
|
34
34
|
export function iconUrl(folder, hash, mediaType) {
|
|
35
35
|
return `/api/folders/${encodeBase64Url(folder)}/assets/${hash}.${iconExtensions.get(mediaType)}`;
|
|
36
36
|
}
|
|
37
|
-
/**
|
|
38
|
-
* Nearest ancestor icon for a folder, walking up parent directories so
|
|
39
|
-
* nested folders inherit the closest icon above them. The bundled
|
|
40
|
-
* `@raggle-ai/local` does not export the ancestor-aware discovery helper
|
|
41
|
-
* yet, so the walk is implemented here over `discoverProjectIcon`.
|
|
42
|
-
*/
|
|
43
|
-
export function discoverAncestorIcon(folder) {
|
|
44
|
-
let current = folder;
|
|
45
|
-
while (true) {
|
|
46
|
-
const icon = discoverProjectIcon(current);
|
|
47
|
-
if (icon)
|
|
48
|
-
return icon;
|
|
49
|
-
const parent = dirname(current);
|
|
50
|
-
if (parent === current)
|
|
51
|
-
return undefined;
|
|
52
|
-
current = parent;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
37
|
export async function folderIcon(folder, assets, assetFiles, fallback) {
|
|
56
|
-
const icon =
|
|
38
|
+
const icon = discoverFolderIcon(folder);
|
|
57
39
|
const mediaType = icon ? iconTypes.get(extname(icon).toLowerCase()) : null;
|
|
58
40
|
const details = icon && mediaType ? await stat(icon).catch(() => undefined) : undefined;
|
|
59
41
|
if (!icon || !mediaType || !details?.size || details.size > maxIconBytes) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kennel",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.24",
|
|
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",
|
|
@@ -79,6 +79,7 @@
|
|
|
79
79
|
"type": "string",
|
|
80
80
|
"pattern": "^(?!\\.{1,2}$)[^/\\\\]+$"
|
|
81
81
|
},
|
|
82
|
+
"skill": { "$ref": "#/$defs/pluginSkill" },
|
|
82
83
|
"entries": {
|
|
83
84
|
"description": "Resolved absolute convention entry files. api is optional.",
|
|
84
85
|
"oneOf": [
|
|
@@ -88,6 +89,35 @@
|
|
|
88
89
|
}
|
|
89
90
|
}
|
|
90
91
|
},
|
|
92
|
+
"pluginSkill": {
|
|
93
|
+
"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`.",
|
|
94
|
+
"type": "object",
|
|
95
|
+
"additionalProperties": false,
|
|
96
|
+
"required": ["entry", "name"],
|
|
97
|
+
"properties": {
|
|
98
|
+
"entry": {
|
|
99
|
+
"description": "SKILL.md path relative to the plugin package root.",
|
|
100
|
+
"type": "string",
|
|
101
|
+
"pattern": "^(?!/)(?:[^\\\\]*/)?SKILL\\.md$"
|
|
102
|
+
},
|
|
103
|
+
"name": {
|
|
104
|
+
"description": "Skill frontmatter name; the agent-store folder name.",
|
|
105
|
+
"type": "string",
|
|
106
|
+
"pattern": "^[a-z0-9][a-z0-9-]{0,63}$"
|
|
107
|
+
},
|
|
108
|
+
"installed": {
|
|
109
|
+
"description": "Last install into ${AGENTS_HOME:-$HOME/.agents}/skills.",
|
|
110
|
+
"type": "object",
|
|
111
|
+
"additionalProperties": false,
|
|
112
|
+
"required": ["path", "commit", "digest"],
|
|
113
|
+
"properties": {
|
|
114
|
+
"path": { "$ref": "#/$defs/absolutePath" },
|
|
115
|
+
"commit": { "description": "Pinned Git commit or local source SHA-256.", "type": "string", "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$" },
|
|
116
|
+
"digest": { "description": "SHA-256 of the installed skill files.", "type": "string", "pattern": "^[0-9a-f]{64}$" }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
},
|
|
91
121
|
"entriesWithoutApi": {
|
|
92
122
|
"type": "object",
|
|
93
123
|
"additionalProperties": false,
|