Skip to content
112 changes: 112 additions & 0 deletions core/config/markdown/loadMarkdownSkills.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import {
ConfigValidationError,
parseMarkdownRule,
} from "@continuedev/config-yaml";
import z from "zod";
import { IDE, Skill } from "../..";
import { walkDir } from "../../indexing/walkDir";
import { localPathToUri } from "../../util/pathToUri";
import { getGlobalFolderWithName } from "../../util/paths";
import { findUriInDirs, joinPathsToUri } from "../../util/uri";
import { getAllDotContinueDefinitionFiles } from "../loadLocalAssistants";

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

const SKILLS_DIR = "skills";

/**
* Get skills from .claude/skills directory
*/
async function getClaudeSkillsDir(ide: IDE) {
const fullDirs = (await ide.getWorkspaceDirs()).map((dir) =>
joinPathsToUri(dir, ".claude", SKILLS_DIR),
);

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();
}

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

try {
const yamlAndMarkdownFileUris = [
...(
await getAllDotContinueDefinitionFiles(
ide,
{
includeGlobal: true,
includeWorkspace: true,
fileExtType: "markdown",
},
SKILLS_DIR,
)
).map((file) => file.path),
...(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);
const { frontmatter, markdown } = parseMarkdownRule(
content,
) as unknown as { frontmatter: Skill; markdown: string };

const validatedFrontmatter = skillFrontmatterSchema.parse(frontmatter);

const filesInSkillsDirectory = (
await walkDir(fileUri.substring(0, fileUri.lastIndexOf("/")), ide, {
source: "get skill files",
})
)
// do not include SKILL.md as it is already in content
.filter((file) => !file.endsWith("SKILL.md"));

const foundRelativeUri = findUriInDirs(fileUri, workspaceDirs);

skills.push({
...validatedFrontmatter,
content: markdown,
path: foundRelativeUri.foundInDir
? foundRelativeUri.relativePathOrBasename
: fileUri,
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 };
}
6 changes: 4 additions & 2 deletions core/config/profile/doLoadConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ async function loadRules(ide: IDE) {

return { rules, errors };
}

export default async function doLoadConfig(options: {
ide: IDE;
controlPlaneClient: ControlPlaneClient;
Expand Down Expand Up @@ -299,14 +300,15 @@ export default async function doLoadConfig(options: {
}

newConfig.tools.push(
...getConfigDependentToolDefinitions({
...(await getConfigDependentToolDefinitions({
rules: newConfig.rules,
enableExperimentalTools:
newConfig.experimental?.enableExperimentalTools ?? false,
isSignedIn,
isRemote: await ide.isWorkspaceRemote(),
modelName: newConfig.selectedModelByRole.chat?.model,
}),
ide,
})),
);

// Detect duplicate tool names
Expand Down
12 changes: 11 additions & 1 deletion core/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1152,9 +1152,10 @@ export interface ConfigDependentToolParams {
isSignedIn: boolean;
isRemote: boolean;
modelName: string | undefined;
ide: IDE;
}

export type GetTool = (params: ConfigDependentToolParams) => Tool;
export type GetTool = (params: ConfigDependentToolParams) => Promise<Tool>;

export interface BaseCompletionOptions {
temperature?: number;
Expand Down Expand Up @@ -1895,6 +1896,15 @@ export interface RuleWithSource extends RuleMetadata {
rule: string;
}

export interface Skill {
name: string;
description: string;
path: string;
content: string;
files: string[];
license?: string;
}

export interface CompleteOnboardingPayload {
mode: OnboardingModes;
provider?: string;
Expand Down
1 change: 1 addition & 0 deletions core/tools/builtIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export enum BuiltInToolNames {
RequestRule = "request_rule",
FetchUrlContent = "fetch_url_content",
CodebaseTool = "codebase",
ReadSkill = "read_skill",

// excluded from allTools for now
ViewRepoMap = "view_repo_map",
Expand Down
3 changes: 3 additions & 0 deletions core/tools/callTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { readCurrentlyOpenFileImpl } from "./implementations/readCurrentlyOpenFi
import { readFileImpl } from "./implementations/readFile";

import { readFileRangeImpl } from "./implementations/readFileRange";
import { readSkillImpl } from "./implementations/readSkill";
import { requestRuleImpl } from "./implementations/requestRule";
import { runTerminalCommandImpl } from "./implementations/runTerminalCommand";
import { searchWebImpl } from "./implementations/searchWeb";
Expand Down Expand Up @@ -179,6 +180,8 @@ export async function callBuiltInTool(
return await requestRuleImpl(args, extras);
case BuiltInToolNames.CodebaseTool:
return await codebaseToolImpl(args, extras);
case BuiltInToolNames.ReadSkill:
return await readSkillImpl(args, extras);
case BuiltInToolNames.ViewRepoMap:
return await viewRepoMapImpl(args, extras);
case BuiltInToolNames.ViewSubdirectory:
Expand Down
1 change: 1 addition & 0 deletions core/tools/definitions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export { readCurrentlyOpenFileTool } from "./readCurrentlyOpenFile";
export { readFileTool } from "./readFile";

export { readFileRangeTool } from "./readFileRange";
export { readSkillTool } from "./readSkill";
export { requestRuleTool } from "./requestRule";
export { runTerminalCommandTool } from "./runTerminalCommand";
export { searchWebTool } from "./searchWeb";
Expand Down
34 changes: 34 additions & 0 deletions core/tools/definitions/readSkill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { GetTool } from "../..";
import { loadMarkdownSkills } from "../../config/markdown/loadMarkdownSkills";
import { BUILT_IN_GROUP_NAME, BuiltInToolNames } from "../builtIn";

export const readSkillTool: GetTool = async (params) => {
const { skills } = await loadMarkdownSkills(params.ide);
return {
type: "function",
displayTitle: "Read Skill",
wouldLikeTo: "read skill {{{ skillName }}}",
isCurrently: "reading skill {{{ skillName }}}",
hasAlready: "read skill {{{ skillName }}}",
readonly: true,
isInstant: true,
group: BUILT_IN_GROUP_NAME,
function: {
name: BuiltInToolNames.ReadSkill,
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`)}`,
parameters: {
type: "object",
required: ["skillName"],
properties: {
skillName: {
type: "string",
description:
"The name of the skill to read. This should match the name from the available skills.",
},
},
},
},
};
};
2 changes: 1 addition & 1 deletion core/tools/definitions/requestRule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ function getRequestRuleSystemMessageDescription(
return prefix + availableRules + suffix;
}

export const requestRuleTool: GetTool = ({ rules }) => ({
export const requestRuleTool: GetTool = async ({ rules }) => ({
type: "function",
displayTitle: "Request Rules",
wouldLikeTo: "request rule {{{ name }}}",
Expand Down
13 changes: 8 additions & 5 deletions core/tools/definitions/toolDefinitions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,24 @@ describe("Tool Definitions", () => {
isSignedIn: false,
isRemote: false,
modelName: "a model",
ide: {} as any,
};

// Helper function to get the actual tool object
const getToolObject = (toolDefinition: Tool | GetTool): Tool => {
const getToolObject = async (
toolDefinition: Tool | GetTool,
): Promise<Tool> => {
if (typeof toolDefinition === "function") {
return toolDefinition(mockParams);
}
return toolDefinition;
};

it("should have all required parameters defined in properties for each tool", () => {
it("should have all required parameters defined in properties for each tool", async () => {
const exportedTools = Object.values(toolDefinitions);

exportedTools.forEach((toolDefinition) => {
const tool = getToolObject(toolDefinition);
for (const toolDefinition of exportedTools) {
const tool = await getToolObject(toolDefinition);

// Each tool should have the required structure
expect(tool).toHaveProperty("type", "function");
Expand Down Expand Up @@ -52,6 +55,6 @@ describe("Tool Definitions", () => {
expect(typeof property.type).toBe("string");
});
}
});
}
});
});
43 changes: 43 additions & 0 deletions core/tools/implementations/readSkill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { ToolImpl } from ".";
import { loadMarkdownSkills } from "../../config/markdown/loadMarkdownSkills";
import { ContinueError, ContinueErrorReason } from "../../util/errors";
import { getStringArg } from "../parseArgs";

export const readSkillImpl: ToolImpl = async (args, extras) => {
const skillName = getStringArg(args, "skillName");

const { skills } = await loadMarkdownSkills(extras.ide);

const skill = skills.find((s) => s.name === skillName);

if (!skill) {
const availableSkills = skills.map((s) => s.name).join(", ");
throw new ContinueError(
ContinueErrorReason.SkillNotFound,
`Skill "${skillName}" not found. Available skills: ${availableSkills || "none"}`,
);
}

let content = skill.content;

if (skill.files.length > 0) {
content += `\n
## Supporting files
Skill directory:
${skill.files.join("\n")}
Use the read file tool to access these files as needed.`;
}

return [
{
name: `Skill: ${skill.name}`,
description: skill.description,
content,
uri: {
type: "file",
value: skill.path,
},
},
];
};
7 changes: 4 additions & 3 deletions core/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,14 @@ export const getBaseToolDefinitions = () => [
toolDefinitions.fetchUrlContentTool,
];

export const getConfigDependentToolDefinitions = (
export const getConfigDependentToolDefinitions = async (
params: ConfigDependentToolParams,
): Tool[] => {
): Promise<Tool[]> => {
const { modelName, isSignedIn, enableExperimentalTools, isRemote } = params;
const tools: Tool[] = [];

tools.push(toolDefinitions.requestRuleTool(params));
tools.push(await toolDefinitions.requestRuleTool(params));
tools.push(await toolDefinitions.readSkillTool(params));

if (isSignedIn) {
// Web search is only available for signed-in users
Expand Down
8 changes: 5 additions & 3 deletions core/tools/searchWebGating.vitest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import { expect, test } from "vitest";
import { BuiltInToolNames } from "./builtIn";
import { getConfigDependentToolDefinitions } from "./index";

test("searchWeb tool is only available when user is signed in", () => {
test("searchWeb tool is only available when user is signed in", async () => {
// Test with signed-in user
const signedInTools = getConfigDependentToolDefinitions({
const signedInTools = await getConfigDependentToolDefinitions({
rules: [],
enableExperimentalTools: false,
isSignedIn: true,
isRemote: false,
modelName: "",
ide: {} as any,
});

const searchWebToolSignedIn = signedInTools.find(
Expand All @@ -19,12 +20,13 @@ test("searchWeb tool is only available when user is signed in", () => {
expect(searchWebToolSignedIn?.displayTitle).toBe("Search Web");

// Test with non-signed-in user
const notSignedInTools = getConfigDependentToolDefinitions({
const notSignedInTools = await getConfigDependentToolDefinitions({
rules: [],
enableExperimentalTools: false,
isSignedIn: false,
isRemote: false,
modelName: "",
ide: {} as any,
});

const searchWebToolNotSignedIn = notSignedInTools.find(
Expand Down
3 changes: 3 additions & 0 deletions core/util/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ export enum ContinueErrorReason {
// Rules
RuleNotFound = "rule_not_found",

// Skills
SkillNotFound = "skill_not_found",

// Other
Unspecified = "unspecified", // I.e. a known error but no specific code for it
Unknown = "unknown", // I.e. an unexpected error
Expand Down
Loading