kibi-cli 0.11.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +36 -0
- package/dist/commands/skills.d.ts +14 -0
- package/dist/commands/skills.d.ts.map +1 -0
- package/dist/commands/skills.js +86 -0
- package/dist/public/skills/kibi-usage/SKILL.md +125 -0
- package/dist/public/skills/kibi-usage/resources/fact-lanes.md +45 -0
- package/dist/public/skills/kibi-usage/resources/relationship-directions.md +89 -0
- package/dist/public/skills/kibi-usage/resources/workflows.md +35 -0
- package/dist/public/skills.d.ts +42 -0
- package/dist/public/skills.d.ts.map +1 -0
- package/dist/public/skills.js +262 -0
- package/package.json +6 -2
- package/src/public/skills/kibi-usage/SKILL.md +125 -0
- package/src/public/skills/kibi-usage/resources/fact-lanes.md +45 -0
- package/src/public/skills/kibi-usage/resources/relationship-directions.md +89 -0
- package/src/public/skills/kibi-usage/resources/workflows.md +35 -0
- package/src/public/skills.ts +359 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
readFileSync,
|
|
4
|
+
readdirSync,
|
|
5
|
+
realpathSync,
|
|
6
|
+
statSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import {
|
|
9
|
+
dirname,
|
|
10
|
+
isAbsolute,
|
|
11
|
+
join,
|
|
12
|
+
normalize,
|
|
13
|
+
relative,
|
|
14
|
+
resolve,
|
|
15
|
+
} from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import matter from "gray-matter";
|
|
18
|
+
|
|
19
|
+
const SKILL_MARKDOWN_MAX_BYTES = 256 * 1024;
|
|
20
|
+
const RESOURCE_MAX_BYTES = 128 * 1024;
|
|
21
|
+
const SKILL_FILE_NAME = "SKILL.md";
|
|
22
|
+
|
|
23
|
+
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
let bundledSkillsDir = resolve(moduleDir, "skills");
|
|
25
|
+
const defaultBundledSkillsDir = bundledSkillsDir;
|
|
26
|
+
|
|
27
|
+
export function setBundledSkillsDir(dir: string): void {
|
|
28
|
+
bundledSkillsDir = dir;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function resetBundledSkillsDir(): void {
|
|
32
|
+
bundledSkillsDir = defaultBundledSkillsDir;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SkillManifest {
|
|
36
|
+
id: string;
|
|
37
|
+
name: string;
|
|
38
|
+
description: string;
|
|
39
|
+
version: string;
|
|
40
|
+
kibiCompatibility: string;
|
|
41
|
+
tags?: string[];
|
|
42
|
+
resources?: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SkillBundle {
|
|
46
|
+
manifest: SkillManifest;
|
|
47
|
+
body: string;
|
|
48
|
+
rootDir: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class SkillNotFoundError extends Error {
|
|
52
|
+
constructor(id: string) {
|
|
53
|
+
super(`Skill not found: ${id}`);
|
|
54
|
+
this.name = "SkillNotFoundError";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class SkillResourceNotFoundError extends Error {
|
|
59
|
+
constructor(id: string, resourcePath: string) {
|
|
60
|
+
super(`Skill resource not found: ${id}/${resourcePath}`);
|
|
61
|
+
this.name = "SkillResourceNotFoundError";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class SkillResourceOutOfBoundsError extends Error {
|
|
66
|
+
constructor(id: string, resourcePath: string) {
|
|
67
|
+
super(`Skill resource escapes bundle root: ${id}/${resourcePath}`);
|
|
68
|
+
this.name = "SkillResourceOutOfBoundsError";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class SkillValidationError extends Error {
|
|
73
|
+
readonly field: string;
|
|
74
|
+
|
|
75
|
+
constructor(field: string, message: string) {
|
|
76
|
+
super(message);
|
|
77
|
+
this.name = "SkillValidationError";
|
|
78
|
+
this.field = field;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export class SkillOversizeError extends Error {
|
|
83
|
+
readonly maxBytes: number;
|
|
84
|
+
readonly actualBytes: number;
|
|
85
|
+
|
|
86
|
+
constructor(pathLike: string, maxBytes: number, actualBytes: number) {
|
|
87
|
+
super(`Skill file exceeds ${maxBytes} bytes: ${pathLike} (${actualBytes} bytes)`);
|
|
88
|
+
this.name = "SkillOversizeError";
|
|
89
|
+
this.maxBytes = maxBytes;
|
|
90
|
+
this.actualBytes = actualBytes;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function listBundledSkills(): SkillManifest[] { // implements REQ-001
|
|
95
|
+
if (!existsSync(bundledSkillsDir)) {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return readdirSync(bundledSkillsDir, { withFileTypes: true })
|
|
100
|
+
.filter((entry) => entry.isDirectory())
|
|
101
|
+
.map((entry) => join(bundledSkillsDir, entry.name))
|
|
102
|
+
.filter((rootDir) => existsSync(join(rootDir, SKILL_FILE_NAME)))
|
|
103
|
+
.map((rootDir) => parseSkillBundle(rootDir).manifest)
|
|
104
|
+
.sort((left, right) => left.id.localeCompare(right.id));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function loadBundledSkill(id: string): SkillBundle { // implements REQ-001
|
|
108
|
+
const rootDir = findBundledSkillRoot(id);
|
|
109
|
+
if (!rootDir) {
|
|
110
|
+
throw new SkillNotFoundError(id);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return parseSkillBundle(rootDir);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function readBundledSkillResource(
|
|
117
|
+
id: string,
|
|
118
|
+
resourcePath: string,
|
|
119
|
+
): string { // implements REQ-001
|
|
120
|
+
const bundle = loadBundledSkill(id);
|
|
121
|
+
const declaredResource = normalizeResourcePath(resourcePath);
|
|
122
|
+
|
|
123
|
+
if (!declaredResource || isPathOutOfBounds(resourcePath)) {
|
|
124
|
+
throw new SkillResourceOutOfBoundsError(id, resourcePath);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!isDeclaredResource(bundle.manifest, declaredResource)) {
|
|
128
|
+
throw new SkillResourceNotFoundError(id, resourcePath);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const candidatePath = resolve(bundle.rootDir, declaredResource);
|
|
132
|
+
let realResourcePath: string;
|
|
133
|
+
let realRootDir: string;
|
|
134
|
+
try {
|
|
135
|
+
realResourcePath = realpathSync(candidatePath);
|
|
136
|
+
realRootDir = realpathSync(bundle.rootDir);
|
|
137
|
+
} catch {
|
|
138
|
+
throw new SkillResourceNotFoundError(id, resourcePath);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!isWithinRoot(realRootDir, realResourcePath)) {
|
|
142
|
+
throw new SkillResourceOutOfBoundsError(id, resourcePath);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
assertMaxBytes(candidatePath, RESOURCE_MAX_BYTES);
|
|
146
|
+
return readFileSync(candidatePath, "utf8");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function validateSkillBundle(
|
|
150
|
+
pathLike: string,
|
|
151
|
+
): { valid: boolean; errors: SkillValidationError[] } { // implements REQ-001
|
|
152
|
+
const skillFilePath = resolveSkillFilePath(pathLike);
|
|
153
|
+
const errors: SkillValidationError[] = [];
|
|
154
|
+
|
|
155
|
+
if (!existsSync(skillFilePath)) {
|
|
156
|
+
errors.push(new SkillValidationError("SKILL.md", `Missing ${SKILL_FILE_NAME}`));
|
|
157
|
+
return { valid: false, errors };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const parsed = matter(readFileSync(skillFilePath, "utf8"));
|
|
161
|
+
errors.push(...validateManifestData(parsed.data));
|
|
162
|
+
|
|
163
|
+
if (errors.length === 0) {
|
|
164
|
+
validateBundleContents(skillFilePath, coerceManifest(parsed.data), errors);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return { valid: errors.length === 0, errors };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function validateBundleContents(
|
|
171
|
+
skillFilePath: string,
|
|
172
|
+
manifest: SkillManifest,
|
|
173
|
+
errors: SkillValidationError[],
|
|
174
|
+
): void {
|
|
175
|
+
try {
|
|
176
|
+
assertMaxBytes(skillFilePath, SKILL_MARKDOWN_MAX_BYTES);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
errors.push(new SkillValidationError("SKILL.md", error instanceof Error ? error.message : String(error)));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const rootDir = dirname(skillFilePath);
|
|
182
|
+
let realRootDir: string;
|
|
183
|
+
try {
|
|
184
|
+
realRootDir = realpathSync(rootDir);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
errors.push(new SkillValidationError("SKILL.md", error instanceof Error ? error.message : String(error)));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
for (const resource of manifest.resources ?? []) {
|
|
191
|
+
const resourcePath = resolve(rootDir, resource);
|
|
192
|
+
try {
|
|
193
|
+
if (!existsSync(resourcePath)) {
|
|
194
|
+
errors.push(new SkillValidationError("resources", `Missing skill resource: ${resource}`));
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const realResourcePath = realpathSync(resourcePath);
|
|
199
|
+
if (!isWithinRoot(realRootDir, realResourcePath)) {
|
|
200
|
+
errors.push(new SkillValidationError("resources", `Skill resource escapes bundle root: ${resource}`));
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
assertMaxBytes(resourcePath, RESOURCE_MAX_BYTES);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
errors.push(new SkillValidationError("resources", error instanceof Error ? error.message : String(error)));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function findBundledSkillRoot(id: string): string | undefined {
|
|
212
|
+
if (!existsSync(bundledSkillsDir)) {
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
for (const entry of readdirSync(bundledSkillsDir, { withFileTypes: true })) {
|
|
217
|
+
if (!entry.isDirectory()) {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const rootDir = join(bundledSkillsDir, entry.name);
|
|
222
|
+
const skillFilePath = join(rootDir, SKILL_FILE_NAME);
|
|
223
|
+
if (!existsSync(skillFilePath)) {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const manifest = parseSkillBundle(rootDir).manifest;
|
|
228
|
+
if (manifest.id === id) {
|
|
229
|
+
return rootDir;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function parseSkillBundle(rootDir: string): SkillBundle {
|
|
237
|
+
const skillFilePath = join(rootDir, SKILL_FILE_NAME);
|
|
238
|
+
assertMaxBytes(skillFilePath, SKILL_MARKDOWN_MAX_BYTES);
|
|
239
|
+
|
|
240
|
+
const parsed = matter(readFileSync(skillFilePath, "utf8"));
|
|
241
|
+
const errors = validateManifestData(parsed.data);
|
|
242
|
+
if (errors.length > 0) {
|
|
243
|
+
throw errors[0] as SkillValidationError;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
manifest: coerceManifest(parsed.data),
|
|
248
|
+
body: parsed.content,
|
|
249
|
+
rootDir: resolve(rootDir),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function validateManifestData(data: Record<string, unknown>): SkillValidationError[] {
|
|
254
|
+
const errors: SkillValidationError[] = [];
|
|
255
|
+
const requiredFields = [
|
|
256
|
+
"id",
|
|
257
|
+
"name",
|
|
258
|
+
"description",
|
|
259
|
+
"version",
|
|
260
|
+
"kibiCompatibility",
|
|
261
|
+
] as const;
|
|
262
|
+
|
|
263
|
+
for (const field of requiredFields) {
|
|
264
|
+
if (typeof data[field] !== "string" || data[field].trim() === "") {
|
|
265
|
+
errors.push(new SkillValidationError(field, `Missing required skill field: ${field}`));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (typeof data.version === "string" && !/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(data.version)) {
|
|
270
|
+
errors.push(new SkillValidationError("version", `Invalid skill version: ${data.version}`));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (data.tags !== undefined && !isStringArray(data.tags)) {
|
|
274
|
+
errors.push(new SkillValidationError("tags", "Skill tags must be strings"));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (data.resources !== undefined) {
|
|
278
|
+
if (!isStringArray(data.resources)) {
|
|
279
|
+
errors.push(new SkillValidationError("resources", "Skill resources must be strings"));
|
|
280
|
+
} else {
|
|
281
|
+
for (const resource of data.resources) {
|
|
282
|
+
const normalized = normalizeResourcePath(resource);
|
|
283
|
+
if (!normalized || isPathOutOfBounds(resource)) {
|
|
284
|
+
errors.push(
|
|
285
|
+
new SkillValidationError("resources", `Invalid skill resource: ${resource}`),
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return errors;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function coerceManifest(data: Record<string, unknown>): SkillManifest {
|
|
296
|
+
const manifest: SkillManifest = {
|
|
297
|
+
id: String(data.id),
|
|
298
|
+
name: String(data.name),
|
|
299
|
+
description: String(data.description),
|
|
300
|
+
version: String(data.version),
|
|
301
|
+
kibiCompatibility: String(data.kibiCompatibility),
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
if (isStringArray(data.tags)) {
|
|
305
|
+
manifest.tags = data.tags;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (isStringArray(data.resources)) {
|
|
309
|
+
manifest.resources = data.resources.map((resource) => normalizeResourcePath(resource));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return manifest;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function isStringArray(value: unknown): value is string[] {
|
|
316
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function normalizeResourcePath(pathLike: string): string {
|
|
320
|
+
return normalize(pathLike).replaceAll("\\", "/");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function isPathOutOfBounds(pathLike: string): boolean {
|
|
324
|
+
const normalized = normalizeResourcePath(pathLike);
|
|
325
|
+
return (
|
|
326
|
+
isAbsolute(pathLike) ||
|
|
327
|
+
normalized === ".." ||
|
|
328
|
+
normalized.startsWith("../") ||
|
|
329
|
+
normalized.includes("/../")
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function isDeclaredResource(manifest: SkillManifest, resourcePath: string): boolean {
|
|
334
|
+
return (manifest.resources ?? []).some(
|
|
335
|
+
(declared) => normalizeResourcePath(declared) === resourcePath,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function isWithinRoot(rootDir: string, candidatePath: string): boolean {
|
|
340
|
+
const relativePath = relative(rootDir, candidatePath);
|
|
341
|
+
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function assertMaxBytes(pathLike: string, maxBytes: number): void {
|
|
345
|
+
const size = statSync(pathLike).size;
|
|
346
|
+
if (size > maxBytes) {
|
|
347
|
+
throw new SkillOversizeError(pathLike, maxBytes, size);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function resolveSkillFilePath(pathLike: string): string {
|
|
352
|
+
const resolved = resolve(pathLike);
|
|
353
|
+
if (existsSync(resolved) && statSync(resolved).isDirectory()) {
|
|
354
|
+
return join(resolved, SKILL_FILE_NAME);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return resolved.endsWith(SKILL_FILE_NAME) ? resolved : join(resolved, SKILL_FILE_NAME);
|
|
358
|
+
}
|
|
359
|
+
|