Skip to content
Merged
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
2 changes: 2 additions & 0 deletions extensions/cli/src/tools/allBuiltIns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -28,6 +29,7 @@ export const ALL_BUILT_IN_TOOLS = [
searchCodeTool,
statusTool,
SUBAGENT_TOOL_META,
SKILLS_TOOL_META,
uploadArtifactTool,
viewDiffTool,
writeChecklistTool,
Expand Down
3 changes: 3 additions & 0 deletions extensions/cli/src/tools/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -127,6 +128,8 @@ export async function getAllAvailableTools(
tools.push(await subagentTool());
}

tools.push(await skillsTool());

const mcpState = await serviceContainer.get<MCPServiceState>(
SERVICE_NAMES.MCP,
);
Expand Down
87 changes: 87 additions & 0 deletions extensions/cli/src/tools/skills.test.ts
Original file line number Diff line number Diff line change
@@ -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("<skill_name>test-skill</skill_name>");
expect(result).toContain(
"<skill_description>A test skill</skill_description>",
);
expect(result).toContain(
"<skill_content>Skill content here</skill_content>",
);
});

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("<skill_files>");
expect(result).toContain("/path/to/file1.ts");
expect(result).toContain("<other_instructions>");
});

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");
});
});
});
82 changes: 82 additions & 0 deletions extensions/cli/src/tools/skills.ts
Original file line number Diff line number Diff line change
@@ -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<Tool> => {
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.name}</skill_name>`,
`<skill_description>${skill.description}</skill_description>`,
`<skill_content>${skill.content}</skill_content>`,
];

if (skill.files.length > 0) {
content.push(
`<skill_files>${skill.files.join(",")}</skill_files>`,
`<other_instructions>Use the read file tool to access skill files as needed.</other_instructions>`,
);
}

return content.join("\n");
},
};
};
124 changes: 124 additions & 0 deletions extensions/cli/src/util/loadMarkdownSkills.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("./loadMarkdownSkills.js", async (importOriginal) => {
const mod = await importOriginal<typeof import("./loadMarkdownSkills.js")>();
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"]);
});
});
Loading
Loading