summaryrefslogtreecommitdiffhomepage
path: root/packages/skills/src/load-skill.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/skills/src/load-skill.ts')
-rw-r--r--packages/skills/src/load-skill.ts268
1 files changed, 134 insertions, 134 deletions
diff --git a/packages/skills/src/load-skill.ts b/packages/skills/src/load-skill.ts
index ef7fc23..3a6173c 100644
--- a/packages/skills/src/load-skill.ts
+++ b/packages/skills/src/load-skill.ts
@@ -2,16 +2,16 @@ import { readdir, readFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import type { ToolContract, ToolExecuteContext, ToolResult } from "@dispatch/kernel";
import {
- isPathWithinDir,
- isValidSkillName,
- parseSkillMeta,
- type SkillEntry,
- stripLoadedBody,
+ isPathWithinDir,
+ isValidSkillName,
+ parseSkillMeta,
+ type SkillEntry,
+ stripLoadedBody,
} from "./pure.js";
export interface SkillsDeps {
- readonly homeDir: string;
- readonly workdir: string;
+ readonly homeDir: string;
+ readonly workdir: string;
}
/**
@@ -22,36 +22,36 @@ export interface SkillsDeps {
* Returns an empty array on any error (fail-open).
*/
export async function scanSkillsDir(dir: string): Promise<readonly SkillEntry[]> {
- async function scan(d: string): Promise<SkillEntry[]> {
- try {
- const entries = await readdir(d, { encoding: "utf8", withFileTypes: true });
- const skills: SkillEntry[] = [];
- for (const entry of entries) {
- if (entry.isDirectory()) {
- const subSkills = await scan(join(d, entry.name));
- for (const sub of subSkills) {
- if (!skills.some((s) => s.name === sub.name)) {
- skills.push(sub);
- }
- }
- } else if (entry.isFile() && entry.name.endsWith(".md")) {
- const name = entry.name.slice(0, -3);
- if (skills.some((s) => s.name === name)) continue;
- try {
- const content = await readFile(join(d, entry.name), "utf8");
- const meta = parseSkillMeta(content);
- skills.push({ name, summary: meta.hasMeta ? meta.summary : undefined });
- } catch {
- skills.push({ name });
- }
- }
- }
- return skills;
- } catch {
- return [];
- }
- }
- return scan(dir);
+ async function scan(d: string): Promise<SkillEntry[]> {
+ try {
+ const entries = await readdir(d, { encoding: "utf8", withFileTypes: true });
+ const skills: SkillEntry[] = [];
+ for (const entry of entries) {
+ if (entry.isDirectory()) {
+ const subSkills = await scan(join(d, entry.name));
+ for (const sub of subSkills) {
+ if (!skills.some((s) => s.name === sub.name)) {
+ skills.push(sub);
+ }
+ }
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
+ const name = entry.name.slice(0, -3);
+ if (skills.some((s) => s.name === name)) continue;
+ try {
+ const content = await readFile(join(d, entry.name), "utf8");
+ const meta = parseSkillMeta(content);
+ skills.push({ name, summary: meta.hasMeta ? meta.summary : undefined });
+ } catch {
+ skills.push({ name });
+ }
+ }
+ }
+ return skills;
+ } catch {
+ return [];
+ }
+ }
+ return scan(dir);
}
/**
@@ -60,26 +60,26 @@ export async function scanSkillsDir(dir: string): Promise<readonly SkillEntry[]>
* Top-level files are found before nested ones (readdir order within each level).
*/
async function findSkillFile(dir: string, name: string): Promise<string | null> {
- async function search(d: string): Promise<string | null> {
- try {
- const entries = await readdir(d, { encoding: "utf8", withFileTypes: true });
- for (const entry of entries) {
- if (entry.isFile() && entry.name === `${name}.md`) {
- return join(d, entry.name);
- }
- }
- for (const entry of entries) {
- if (entry.isDirectory()) {
- const found = await search(join(d, entry.name));
- if (found !== null) return found;
- }
- }
- return null;
- } catch {
- return null;
- }
- }
- return search(dir);
+ async function search(d: string): Promise<string | null> {
+ try {
+ const entries = await readdir(d, { encoding: "utf8", withFileTypes: true });
+ for (const entry of entries) {
+ if (entry.isFile() && entry.name === `${name}.md`) {
+ return join(d, entry.name);
+ }
+ }
+ for (const entry of entries) {
+ if (entry.isDirectory()) {
+ const found = await search(join(d, entry.name));
+ if (found !== null) return found;
+ }
+ }
+ return null;
+ } catch {
+ return null;
+ }
+ }
+ return search(dir);
}
/**
@@ -87,81 +87,81 @@ async function findSkillFile(dir: string, name: string): Promise<string | null>
* The tool reads a skill file from disk on execute (uncached).
*/
export function createLoadSkillTool(deps: SkillsDeps): ToolContract {
- const { homeDir, workdir } = deps;
-
- return {
- name: "load_skill",
- description: "Load a skill by name. No skills are currently available.",
- parameters: {
- type: "object",
- properties: {
- name: {
- type: "string",
- description: "The name of the skill to load.",
- },
- },
- required: ["name"],
- },
- concurrencySafe: true,
- async execute(args: unknown, ctx: ToolExecuteContext): Promise<ToolResult> {
- const obj = args as Record<string, unknown>;
- const rawName = obj?.name;
-
- if (typeof rawName !== "string") {
- return { content: 'Error: Missing or invalid "name" parameter.', isError: true };
- }
-
- if (!isValidSkillName(rawName)) {
- return {
- content: `Error: Invalid skill name "${rawName}". Name must not contain path separators or "..".`,
- isError: true,
- };
- }
-
- const effectiveBase = ctx.cwd ? resolve(ctx.cwd) : resolve(workdir);
- const cwdSkillsDir = join(effectiveBase, ".skills");
- const homeSkillsDir = join(resolve(homeDir), ".skills");
-
- let filePath: string | null = null;
-
- try {
- filePath = await findSkillFile(cwdSkillsDir, rawName);
- } catch {
- // Cwd miss — try home
- }
-
- if (filePath === null) {
- try {
- filePath = await findSkillFile(homeSkillsDir, rawName);
- } catch {
- // Both miss
- }
- }
-
- if (filePath === null) {
- return { content: `Error: unknown skill: ${rawName}`, isError: true };
- }
-
- const resolvedPath = resolve(filePath);
- if (
- !isPathWithinDir(resolvedPath, cwdSkillsDir) &&
- !isPathWithinDir(resolvedPath, homeSkillsDir)
- ) {
- return { content: "Error: Invalid skill path.", isError: true };
- }
-
- let content: string;
-
- try {
- content = await readFile(resolvedPath, "utf8");
- } catch {
- return { content: `Error: unknown skill: ${rawName}`, isError: true };
- }
-
- const meta = parseSkillMeta(content);
- const body = stripLoadedBody(content, meta.hasMeta);
-
- return { content: body };
- },
- };
+ const { homeDir, workdir } = deps;
+
+ return {
+ name: "load_skill",
+ description: "Load a skill by name. No skills are currently available.",
+ parameters: {
+ type: "object",
+ properties: {
+ name: {
+ type: "string",
+ description: "The name of the skill to load.",
+ },
+ },
+ required: ["name"],
+ },
+ concurrencySafe: true,
+ async execute(args: unknown, ctx: ToolExecuteContext): Promise<ToolResult> {
+ const obj = args as Record<string, unknown>;
+ const rawName = obj?.name;
+
+ if (typeof rawName !== "string") {
+ return { content: 'Error: Missing or invalid "name" parameter.', isError: true };
+ }
+
+ if (!isValidSkillName(rawName)) {
+ return {
+ content: `Error: Invalid skill name "${rawName}". Name must not contain path separators or "..".`,
+ isError: true,
+ };
+ }
+
+ const effectiveBase = ctx.cwd ? resolve(ctx.cwd) : resolve(workdir);
+ const cwdSkillsDir = join(effectiveBase, ".skills");
+ const homeSkillsDir = join(resolve(homeDir), ".skills");
+
+ let filePath: string | null = null;
+
+ try {
+ filePath = await findSkillFile(cwdSkillsDir, rawName);
+ } catch {
+ // Cwd miss — try home
+ }
+
+ if (filePath === null) {
+ try {
+ filePath = await findSkillFile(homeSkillsDir, rawName);
+ } catch {
+ // Both miss
+ }
+ }
+
+ if (filePath === null) {
+ return { content: `Error: unknown skill: ${rawName}`, isError: true };
+ }
+
+ const resolvedPath = resolve(filePath);
+ if (
+ !isPathWithinDir(resolvedPath, cwdSkillsDir) &&
+ !isPathWithinDir(resolvedPath, homeSkillsDir)
+ ) {
+ return { content: "Error: Invalid skill path.", isError: true };
+ }
+
+ let content: string;
+
+ try {
+ content = await readFile(resolvedPath, "utf8");
+ } catch {
+ return { content: `Error: unknown skill: ${rawName}`, isError: true };
+ }
+
+ const meta = parseSkillMeta(content);
+ const body = stripLoadedBody(content, meta.hasMeta);
+
+ return { content: body };
+ },
+ };
}