From 3a93696537cc7b090e9050a9a47d2e921938345a Mon Sep 17 00:00:00 2001 From: Yanzhen Lu Date: Fri, 3 Apr 2026 16:48:23 +0800 Subject: [PATCH] fix(skills): load symlinked markdown skills --- core/config/markdown/loadMarkdownSkills.ts | 60 +++++++++++------- .../markdown/loadMarkdownSkills.vitest.ts | 63 +++++++++++++++++++ .../cli/src/util/loadMarkdownSkills.test.ts | 37 +++++++++++ extensions/cli/src/util/loadMarkdownSkills.ts | 38 ++++++++--- 4 files changed, 170 insertions(+), 28 deletions(-) create mode 100644 core/config/markdown/loadMarkdownSkills.vitest.ts diff --git a/core/config/markdown/loadMarkdownSkills.ts b/core/config/markdown/loadMarkdownSkills.ts index a4681daf22d..c1d54c698f0 100644 --- a/core/config/markdown/loadMarkdownSkills.ts +++ b/core/config/markdown/loadMarkdownSkills.ts @@ -3,12 +3,12 @@ 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), @@ -16,6 +16,34 @@ const skillFrontmatterSchema = z.object({ }); const SKILLS_DIR = "skills"; +const DIRECTORY_FILE_TYPE = 2; +const SYMBOLIC_LINK_FILE_TYPE = 64; + +async function getSkillFilesFromDir(dir: string, ide: IDE): Promise { + 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 { + return (await Promise.all(dirs.map((dir) => getSkillFilesFromDir(dir, ide)))).flat(); +} /** * Get skills from .claude/skills directory @@ -27,19 +55,7 @@ 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) { @@ -47,18 +63,21 @@ export async function loadMarkdownSkills(ide: IDE) { 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)), ]; @@ -66,7 +85,6 @@ export async function loadMarkdownSkills(ide: IDE) { path.endsWith("SKILL.md"), ); - const workspaceDirs = await ide.getWorkspaceDirs(); for (const fileUri of skillFiles) { try { const content = await ide.readFile(fileUri); diff --git a/core/config/markdown/loadMarkdownSkills.vitest.ts b/core/config/markdown/loadMarkdownSkills.vitest.ts new file mode 100644 index 00000000000..1106722da7d --- /dev/null +++ b/core/config/markdown/loadMarkdownSkills.vitest.ts @@ -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"); + }); +}); diff --git a/extensions/cli/src/util/loadMarkdownSkills.test.ts b/extensions/cli/src/util/loadMarkdownSkills.test.ts index a516c11ded6..a660aa8f1d2 100644 --- a/extensions/cli/src/util/loadMarkdownSkills.test.ts +++ b/extensions/cli/src/util/loadMarkdownSkills.test.ts @@ -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 }); diff --git a/extensions/cli/src/util/loadMarkdownSkills.ts b/extensions/cli/src/util/loadMarkdownSkills.ts index cd74dd8496d..fda0dd77bad 100644 --- a/extensions/cli/src/util/loadMarkdownSkills.ts +++ b/extensions/cli/src/util/loadMarkdownSkills.ts @@ -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 { - // check if dirPath exists +async function getSkillDirectoriesFromDir(dirPath: string): Promise { 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 { + const skillDirs = await getSkillDirectoriesFromDir(dirPath); return ( await Promise.all( @@ -82,7 +106,7 @@ async function getSkillFilesFromDir(dirPath: string): Promise { } }), ) - ).filter((path) => typeof path === "string"); + ).filter((skillFilePath): skillFilePath is string => Boolean(skillFilePath)); } export async function loadMarkdownSkills(): Promise {