diff --git a/extensions/cli/src/tools/allBuiltIns.ts b/extensions/cli/src/tools/allBuiltIns.ts index 4751cf3e9d1..c5d054e6ff3 100644 --- a/extensions/cli/src/tools/allBuiltIns.ts +++ b/extensions/cli/src/tools/allBuiltIns.ts @@ -9,6 +9,7 @@ import { readFileTool } from "./readFile.js"; import { reportFailureTool } from "./reportFailure.js"; import { runTerminalCommandTool } from "./runTerminalCommand.js"; import { searchCodeTool } from "./searchCode.js"; +import { SKILLS_TOOL_META } from "./skills.js"; import { statusTool } from "./status.js"; import { uploadArtifactTool } from "./uploadArtifact.js"; import { viewDiffTool } from "./viewDiff.js"; @@ -28,6 +29,7 @@ export const ALL_BUILT_IN_TOOLS = [ searchCodeTool, statusTool, SUBAGENT_TOOL_META, + SKILLS_TOOL_META, uploadArtifactTool, viewDiffTool, writeChecklistTool, diff --git a/extensions/cli/src/tools/index.tsx b/extensions/cli/src/tools/index.tsx index b6db812344c..7b8a14d754b 100644 --- a/extensions/cli/src/tools/index.tsx +++ b/extensions/cli/src/tools/index.tsx @@ -28,6 +28,7 @@ import { readFileTool } from "./readFile.js"; import { reportFailureTool } from "./reportFailure.js"; import { runTerminalCommandTool } from "./runTerminalCommand.js"; import { checkIfRipgrepIsInstalled, searchCodeTool } from "./searchCode.js"; +import { skillsTool } from "./skills.js"; import { subagentTool } from "./subagent.js"; import { isBetaSubagentToolEnabled, @@ -127,6 +128,8 @@ export async function getAllAvailableTools( tools.push(await subagentTool()); } + tools.push(await skillsTool()); + const mcpState = await serviceContainer.get( SERVICE_NAMES.MCP, ); diff --git a/extensions/cli/src/tools/skills.test.ts b/extensions/cli/src/tools/skills.test.ts new file mode 100644 index 00000000000..3c87014dd90 --- /dev/null +++ b/extensions/cli/src/tools/skills.test.ts @@ -0,0 +1,87 @@ +import { ContinueError, ContinueErrorReason } from "core/util/errors.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { Skill } from "../util/loadMarkdownSkills.js"; + +import { skillsTool } from "./skills.js"; + +vi.mock("../util/loadMarkdownSkills.js"); +vi.mock("../util/logger.js"); + +const mockSkills: Skill[] = [ + { + name: "test-skill", + description: "A test skill", + path: "/path/to/skill", + content: "Skill content here", + files: [], + }, + { + name: "skill-with-files", + description: "Skill with extra files", + path: "/path/to/skill2", + content: "Another skill", + files: ["/path/to/file1.ts", "/path/to/file2.ts"], + }, +]; + +describe("skillsTool", () => { + beforeEach(async () => { + vi.clearAllMocks(); + const { loadMarkdownSkills } = await import( + "../util/loadMarkdownSkills.js" + ); + vi.mocked(loadMarkdownSkills).mockResolvedValue({ + skills: mockSkills, + errors: [], + }); + }); + + it("should include skill names in description", async () => { + const tool = await skillsTool(); + expect(tool.description).toContain("test-skill"); + expect(tool.description).toContain("skill-with-files"); + }); + + describe("preprocess", () => { + it("should return preview with skill name", async () => { + const tool = await skillsTool(); + const result = await tool.preprocess!({ skill_name: "test-skill" }); + expect(result.preview).toEqual([ + { type: "text", content: "Reading skill: test-skill" }, + ]); + }); + }); + + describe("run", () => { + it("should return skill content when found", async () => { + const tool = await skillsTool(); + const result = await tool.run({ skill_name: "test-skill" }); + expect(result).toContain("test-skill"); + expect(result).toContain( + "A test skill", + ); + expect(result).toContain( + "Skill content here", + ); + }); + + it("should include files when skill has files", async () => { + const tool = await skillsTool(); + const result = await tool.run({ skill_name: "skill-with-files" }); + expect(result).toContain(""); + expect(result).toContain("/path/to/file1.ts"); + expect(result).toContain(""); + }); + + it("should throw ContinueError when skill not found", async () => { + const tool = await skillsTool(); + const error = await tool + .run({ skill_name: "nonexistent" }) + .catch((e) => e); + expect(error).toBeInstanceOf(ContinueError); + expect(error.reason).toBe(ContinueErrorReason.SkillNotFound); + expect(error.message).toContain("nonexistent"); + }); + }); +}); diff --git a/extensions/cli/src/tools/skills.ts b/extensions/cli/src/tools/skills.ts new file mode 100644 index 00000000000..51e7671bdde --- /dev/null +++ b/extensions/cli/src/tools/skills.ts @@ -0,0 +1,82 @@ +import { ContinueError, ContinueErrorReason } from "core/util/errors.js"; + +import { loadMarkdownSkills } from "../util/loadMarkdownSkills.js"; +import { logger } from "../util/logger.js"; + +import { Tool } from "./types.js"; + +export const SKILLS_TOOL_META: Tool = { + name: "Skills", + displayName: "Skills", + description: + "Use this tool to read the content of a skill by its name. Skills contain detailed instructions for specific tasks.", + readonly: false, + isBuiltIn: true, + parameters: { + type: "object", + required: ["skill_name"], + properties: { + skill_name: { + type: "string", + description: + "The name of the skill to read. This should match the name from the available skills.", + }, + }, + }, + run: async () => "", +}; + +export const skillsTool = async (): Promise => { + const { skills } = await loadMarkdownSkills(); + + return { + ...SKILLS_TOOL_META, + + description: `Use this tool to read the content of a skill by its name. Skills contain detailed instructions for specific tasks. The skill name should match one of the available skills listed below: +${skills.map((skill) => `\nname: ${skill.name}\ndescription: ${skill.description}\n`)}`, + + preprocess: async (args: any) => { + const { skill_name } = args; + + return { + args, + preview: [ + { + type: "text", + content: `Reading skill: ${skill_name}`, + }, + ], + }; + }, + + run: async (args: any, context?: { toolCallId: string }) => { + const { skill_name } = args; + + logger.debug("skill args", { args, context }); + + const skill = skills.find((s) => s.name === skill_name); + if (!skill) { + const availableSkills = skills.map((s) => s.name).join(", "); + throw new ContinueError( + ContinueErrorReason.SkillNotFound, + `Skill "${skill_name}" not found. Available skills: ${availableSkills || "none"}`, + ); + } + + const content = [ + `${skill.name}`, + `${skill.description}`, + `${skill.content}`, + ]; + + if (skill.files.length > 0) { + content.push( + `${skill.files.join(",")}`, + `Use the read file tool to access skill files as needed.`, + ); + } + + return content.join("\n"); + }, + }; +}; diff --git a/extensions/cli/src/util/loadMarkdownSkills.test.ts b/extensions/cli/src/util/loadMarkdownSkills.test.ts new file mode 100644 index 00000000000..a516c11ded6 --- /dev/null +++ b/extensions/cli/src/util/loadMarkdownSkills.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./loadMarkdownSkills.js", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + }; +}); + +vi.mock("../env.js", () => ({ + env: { + continueHome: "/mock/home/.continue", + }, +})); + +describe("loadMarkdownSkills", () => { + let tmpDir: string; + let originalCwd: string; + let fs: typeof import("fs"); + let path: typeof import("path"); + let loadMarkdownSkills: typeof import("./loadMarkdownSkills.js").loadMarkdownSkills; + + beforeEach(async () => { + fs = await import("fs"); + path = await import("path"); + const mod = await import("./loadMarkdownSkills.js"); + loadMarkdownSkills = mod.loadMarkdownSkills; + + originalCwd = process.cwd(); + const os = await import("os"); + tmpDir = path.join(os.tmpdir(), "skills-test"); + if (fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + fs.mkdirSync(tmpDir, { recursive: true }); + process.chdir(tmpDir); + }); + + afterEach(() => { + process.chdir(originalCwd); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("returns empty skills when no skills directories exist", async () => { + const result = await loadMarkdownSkills(); + expect(result.skills).toEqual([]); + expect(result.errors).toEqual([]); + }); + + it("loads a valid skill with files from .continue/skills", async () => { + const skillDir = path.join(tmpDir, ".continue", "skills", "my-skill"); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync( + path.join(skillDir, "SKILL.md"), + `--- +name: Test Skill +description: A test skill +--- + +# Test Skill Content + +This is the skill body. +`, + ); + fs.writeFileSync(path.join(skillDir, "helper.ts"), "// helper code"); + fs.writeFileSync(path.join(skillDir, "data.json"), "{}"); + + const result = await loadMarkdownSkills(); + expect(result.errors).toEqual([]); + expect(result.skills).toHaveLength(1); + expect(result.skills[0].name).toBe("Test Skill"); + expect(result.skills[0].description).toBe("A test skill"); + expect(result.skills[0].content).toContain("Test Skill Content"); + expect(result.skills[0].files).toHaveLength(2); + expect(result.skills[0].files).not.toContain( + expect.stringContaining("SKILL.md"), + ); + }); + + it("returns error for invalid frontmatter", async () => { + const skillDir = path.join(tmpDir, ".continue", "skills", "bad-skill"); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync( + path.join(skillDir, "SKILL.md"), + `--- +name: "" +--- +Missing description +`, + ); + + const result = await loadMarkdownSkills(); + expect(result.skills).toEqual([]); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].fatal).toBe(false); + }); + + it("loads multiple skills from different directories", async () => { + const skill1Dir = path.join(tmpDir, ".continue", "skills", "skill-1"); + const skill2Dir = path.join(tmpDir, ".continue", "skills", "skill-2"); + fs.mkdirSync(skill1Dir, { recursive: true }); + fs.mkdirSync(skill2Dir, { recursive: true }); + + const skillContent = (name: string) => `--- +name: ${name} +description: Description for ${name} +--- +Content +`; + fs.writeFileSync( + path.join(skill1Dir, "SKILL.md"), + skillContent("Skill One"), + ); + fs.writeFileSync( + path.join(skill2Dir, "SKILL.md"), + skillContent("Skill Two"), + ); + + const result = await loadMarkdownSkills(); + expect(result.skills).toHaveLength(2); + const names = result.skills.map((s) => s.name).sort(); + expect(names).toEqual(["Skill One", "Skill Two"]); + }); +}); diff --git a/extensions/cli/src/util/loadMarkdownSkills.ts b/extensions/cli/src/util/loadMarkdownSkills.ts new file mode 100644 index 00000000000..a6b06ff936e --- /dev/null +++ b/extensions/cli/src/util/loadMarkdownSkills.ts @@ -0,0 +1,146 @@ +import * as fs from "fs"; +import fsPromises from "fs/promises"; +import * as path from "path"; + +import { parseMarkdownRule } from "@continuedev/config-yaml"; +import { WalkerSync } from "ignore-walk"; +import { z } from "zod"; + +import { env } from "../env.js"; + +export interface Skill { + name: string; + description: string; + path: string; + content: string; + files: string[]; +} + +export interface LoadSkillsResult { + skills: Skill[]; + errors: { fatal: boolean; message: string }[]; +} + +const skillFrontmatterSchema = z.object({ + name: z.string().min(1), + description: z.string().min(1), +}); + +const SKILLS_DIR = "skills"; + +/**get the relative path if the filePath is within the current working directory + * otherwise return the absolute path + */ +function getRelativePath(cwd: string, filePath: string) { + return filePath.startsWith(cwd) + ? path.join(".", path.relative(cwd, filePath)) + : filePath; +} + +function getFilePathsInSkillDirectory( + cwd: string, + skillFilePath: string, +): string[] { + const skillDir = path.dirname(skillFilePath); + if (!fs.existsSync(skillDir)) return []; + + const walker = new WalkerSync({ + path: skillDir, + includeEmpty: false, + follow: false, + }); + + const files = walker.start().result as string[]; + return files + .map((filePath) => path.join(skillDir, filePath)) + .filter((filePath) => !filePath.endsWith("SKILL.md")) + .map((filePath) => getRelativePath(cwd, filePath)); +} + +/**get the SKILL.md files from the given directory */ +async function getSkillFilesFromDir(dirPath: string): Promise { + // check if dirPath exists + 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)); + + return ( + await Promise.all( + skillDirs.map(async (skillDir) => { + try { + const skillFilePath = path.join(skillDir, "SKILL.md"); + await fsPromises.stat(skillFilePath); + return skillFilePath; + } catch { + return null; + } + }), + ) + ).filter((path) => typeof path === "string"); +} + +export async function loadMarkdownSkills(): Promise { + const errors: { fatal: boolean; message: string }[] = []; + const skills: Skill[] = []; + + const cwd = process.cwd(); + + try { + const skillsDirs = [ + path.join(cwd, ".continue", SKILLS_DIR), + path.join(cwd, ".claude", SKILLS_DIR), + path.join(env.continueHome, SKILLS_DIR), + ]; + + const skillFilePaths = ( + await Promise.all( + skillsDirs.map((skillDir) => getSkillFilesFromDir(skillDir)), + ) + ).flat(); + + await Promise.all( + skillFilePaths.map(async (skillFilePath) => { + try { + const content = await fsPromises.readFile(skillFilePath, "utf-8"); + const { frontmatter, markdown } = parseMarkdownRule(content) as { + frontmatter: { name?: string; description?: string }; + markdown: string; + }; + + const validatedFrontmatter = + skillFrontmatterSchema.parse(frontmatter); + + const filesInSkillsDirectory = getFilePathsInSkillDirectory( + cwd, + skillFilePath, + ); + + skills.push({ + ...validatedFrontmatter, + content: markdown, + path: getRelativePath(cwd, skillFilePath), + files: filesInSkillsDirectory, + }); + } catch (error) { + errors.push({ + fatal: false, + message: `Failed to parse markdown skill file: ${error instanceof Error ? error.message : error}`, + }); + } + }), + ); + } catch (err) { + errors.push({ + fatal: false, + message: `Error loading markdown skill files: ${err instanceof Error ? err.message : err}`, + }); + } + + return { skills, errors }; +}