Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 39 additions & 21 deletions core/config/markdown/loadMarkdownSkills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,47 @@ import {
parseMarkdownRule,
} from "@continuedev/config-yaml";
import z from "zod";
import { IDE, Skill } from "../..";
import type { IDE, Skill } from "../..";
import { walkDir } from "../../indexing/walkDir";
import { localPathToUri } from "../../util/pathToUri";
import { getGlobalFolderWithName } from "../../util/paths";
import { localPathToUri } from "../../util/pathToUri";
import { findUriInDirs, joinPathsToUri } from "../../util/uri";
import { getAllDotContinueDefinitionFiles } from "../loadLocalAssistants";
import { getDotContinueSubDirs } from "../loadLocalAssistants";

const skillFrontmatterSchema = z.object({
name: z.string().min(1),
description: z.string().min(1),
});

const SKILLS_DIR = "skills";
const DIRECTORY_FILE_TYPE = 2;
const SYMBOLIC_LINK_FILE_TYPE = 64;

async function getSkillFilesFromDir(dir: string, ide: IDE): Promise<string[]> {
const exists = await ide.fileExists(dir);
if (!exists) {
return [];
}

const entries = await ide.listDir(dir);
return (
await Promise.all(
entries.map(async ([name, type]) => {
if (type !== DIRECTORY_FILE_TYPE && type !== SYMBOLIC_LINK_FILE_TYPE) {
return null;
}

const skillDirUri = joinPathsToUri(dir, name);
const skillFileUri = joinPathsToUri(skillDirUri, "SKILL.md");
return (await ide.fileExists(skillFileUri)) ? skillFileUri : null;
}),
)
).filter((skillFileUri): skillFileUri is string => Boolean(skillFileUri));
}

async function getSkillFilesFromDirs(dirs: string[], ide: IDE): Promise<string[]> {
return (await Promise.all(dirs.map((dir) => getSkillFilesFromDir(dir, ide)))).flat();
}

/**
* Get skills from .claude/skills directory
Expand All @@ -27,46 +55,36 @@ async function getClaudeSkillsDir(ide: IDE) {

fullDirs.push(localPathToUri(getGlobalFolderWithName(SKILLS_DIR)));

return (
await Promise.all(
fullDirs.map(async (dir) => {
const exists = await ide.fileExists(dir);
if (!exists) return [];
const uris = await walkDir(dir, ide, {
source: "get .claude skills files",
});
// filter markdown files only
return uris.filter((uri) => uri.endsWith(".md"));
}),
)
).flat();
return getSkillFilesFromDirs(fullDirs, ide);
}

export async function loadMarkdownSkills(ide: IDE) {
const errors: ConfigValidationError[] = [];
const skills: Skill[] = [];

try {
const workspaceDirs = await ide.getWorkspaceDirs();
const yamlAndMarkdownFileUris = [
...(
await getAllDotContinueDefinitionFiles(
...(await getSkillFilesFromDirs(
getDotContinueSubDirs(
ide,
{
includeGlobal: true,
includeWorkspace: true,
fileExtType: "markdown",
},
workspaceDirs,
SKILLS_DIR,
)
).map((file) => file.path),
),
ide,
)),
...(await getClaudeSkillsDir(ide)),
];

const skillFiles = yamlAndMarkdownFileUris.filter((path) =>
path.endsWith("SKILL.md"),
);

const workspaceDirs = await ide.getWorkspaceDirs();
for (const fileUri of skillFiles) {
try {
const content = await ide.readFile(fileUri);
Expand Down
63 changes: 63 additions & 0 deletions core/config/markdown/loadMarkdownSkills.vitest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import fs from "fs";
import path from "path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { loadMarkdownSkills } from "./loadMarkdownSkills";
import { readSkillImpl } from "../../tools/implementations/readSkill";
import { testIde } from "../../test/fixtures";
import { setUpTestDir, tearDownTestDir, TEST_DIR_PATH } from "../../test/testDir";

function createDirectorySymlink(target: string, linkPath: string) {
fs.symlinkSync(target, linkPath, process.platform === "win32" ? "junction" : "dir");
}

describe("loadMarkdownSkills", () => {
beforeEach(() => {
setUpTestDir();
});

afterEach(() => {
tearDownTestDir();
});

it("loads symlinked project skills and makes them readable through read_skill", async () => {
const targetSkillDir = path.join(TEST_DIR_PATH, "shared-skill-target");
fs.mkdirSync(targetSkillDir, { recursive: true });
fs.writeFileSync(
path.join(targetSkillDir, "SKILL.md"),
`---
name: Skill Creator
description: Creates skills
---

# Skill Creator
`,
);
fs.writeFileSync(
path.join(targetSkillDir, "helper.ts"),
"// helper code\n",
);

const skillsDir = path.join(TEST_DIR_PATH, ".continue", "skills");
fs.mkdirSync(skillsDir, { recursive: true });
createDirectorySymlink(targetSkillDir, path.join(skillsDir, "skill-creator"));

const result = await loadMarkdownSkills(testIde);

expect(result.errors).toEqual([]);
expect(result.skills).toHaveLength(1);
expect(result.skills[0].name).toBe("Skill Creator");
expect(result.skills[0].path).toBe(".continue/skills/skill-creator/SKILL.md");
expect(
result.skills[0].files.some((file) => file.endsWith("helper.ts")),
).toBe(true);

const readResult = await readSkillImpl(
{ skillName: "Skill Creator" },
{ ide: testIde } as any,
);

expect(readResult[0]?.name).toBe("Skill: Skill Creator");
expect(readResult[0]?.content).toContain("# Skill Creator");
expect(readResult[0]?.content).toContain("helper.ts");
});
});
37 changes: 37 additions & 0 deletions extensions/cli/src/util/loadMarkdownSkills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,43 @@ This is the skill body.
);
});

it("loads a skill from a symlinked project skill directory", async () => {
const targetSkillDir = path.join(tmpDir, "shared-skill-target");
fs.mkdirSync(targetSkillDir, { recursive: true });
fs.writeFileSync(
path.join(targetSkillDir, "SKILL.md"),
`---
name: Skill Creator
description: Creates skills
---

# Skill Creator
`,
);
fs.writeFileSync(path.join(targetSkillDir, "helper.ts"), "// helper code");

const skillsDir = path.join(tmpDir, ".continue", "skills");
fs.mkdirSync(skillsDir, { recursive: true });
const symlinkPath = path.join(skillsDir, "skill-creator");
fs.symlinkSync(
targetSkillDir,
symlinkPath,
process.platform === "win32" ? "junction" : "dir",
);

const result = await loadMarkdownSkills();

expect(result.errors).toEqual([]);
expect(result.skills).toHaveLength(1);
expect(result.skills[0].name).toBe("Skill Creator");
expect(result.skills[0].path).toBe(
path.join(".", ".continue", "skills", "skill-creator", "SKILL.md"),
);
expect(result.skills[0].files).toContain(
path.join(".", ".continue", "skills", "skill-creator", "helper.ts"),
);
});

it("returns error for invalid frontmatter", async () => {
const skillDir = path.join(tmpDir, ".continue", "skills", "bad-skill");
fs.mkdirSync(skillDir, { recursive: true });
Expand Down
38 changes: 31 additions & 7 deletions extensions/cli/src/util/loadMarkdownSkills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,18 +57,42 @@ function getFilePathsInSkillDirectory(
.map((filePath) => getRelativePath(cwd, filePath));
}

/**get the SKILL.md files from the given directory */
async function getSkillFilesFromDir(dirPath: string): Promise<string[]> {
// check if dirPath exists
async function getSkillDirectoriesFromDir(dirPath: string): Promise<string[]> {
try {
await fsPromises.stat(dirPath);
} catch {
return [];
}

const skillDirs = (await fsPromises.readdir(dirPath, { withFileTypes: true }))
.filter((dir) => dir.isDirectory())
.map((dir) => path.join(dirPath, dir.name));
const dirEntries = await fsPromises.readdir(dirPath, { withFileTypes: true });

return (
await Promise.all(
dirEntries.map(async (dirEntry) => {
const candidatePath = path.join(dirPath, dirEntry.name);

if (dirEntry.isDirectory()) {
return candidatePath;
}

if (!dirEntry.isSymbolicLink()) {
return null;
}

try {
const stats = await fsPromises.stat(candidatePath);
return stats.isDirectory() ? candidatePath : null;
} catch {
return null;
}
}),
)
).filter((candidatePath): candidatePath is string => Boolean(candidatePath));
}

/**get the SKILL.md files from the given directory */
async function getSkillFilesFromDir(dirPath: string): Promise<string[]> {
const skillDirs = await getSkillDirectoriesFromDir(dirPath);

return (
await Promise.all(
Expand All @@ -82,7 +106,7 @@ async function getSkillFilesFromDir(dirPath: string): Promise<string[]> {
}
}),
)
).filter((path) => typeof path === "string");
).filter((skillFilePath): skillFilePath is string => Boolean(skillFilePath));
}

export async function loadMarkdownSkills(): Promise<LoadSkillsResult> {
Expand Down
Loading