Skip to content
Closed
Show file tree
Hide file tree
Changes from 12 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
5 changes: 4 additions & 1 deletion core/config/markdown/loadMarkdownSkills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { getAllDotContinueDefinitionFiles } from "../loadLocalAssistants";
const skillFrontmatterSchema = z.object({
name: z.string().min(1),
description: z.string().min(1),
when_to_use: z.string().min(1).optional(),
});

const SKILLS_DIR = "skills";
Expand Down Expand Up @@ -87,7 +88,9 @@ export async function loadMarkdownSkills(ide: IDE) {
const foundRelativeUri = findUriInDirs(fileUri, workspaceDirs);

skills.push({
...validatedFrontmatter,
name: validatedFrontmatter.name,
description: validatedFrontmatter.description,
whenToUse: validatedFrontmatter.when_to_use,
content: markdown,
path: foundRelativeUri.foundInDir
? foundRelativeUri.relativePathOrBasename
Expand Down
1 change: 1 addition & 0 deletions core/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1938,6 +1938,7 @@ export interface RuleWithSource extends RuleMetadata {
export interface Skill {
name: string;
description: string;
whenToUse?: string;
path: string;
content: string;
files: string[];
Expand Down
56 changes: 47 additions & 9 deletions core/tools/definitions/readSkill.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,69 @@
import { GetTool } from "../..";
import { Skill, GetTool } from "../..";
import { loadMarkdownSkills } from "../../config/markdown/loadMarkdownSkills";
import { BUILT_IN_GROUP_NAME, BuiltInToolNames } from "../builtIn";

function formatSkillEntry(skill: Skill): string {
const lines = [
` <skill>`,
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
];
if (skill.whenToUse) {
lines.push(` <when_to_use>${skill.whenToUse}</when_to_use>`);
}
lines.push(` </skill>`);
return lines.join("\n");
}

function buildDescription(skills: Skill[]): string {
if (skills.length === 0) {
return "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available.";
}

const skillExamples = skills
.slice(0, 3)
.map((s) => `"${s.name}"`)
.join(", ");

return [
"Load a specialized skill that provides domain-specific instructions, workflows, and access to bundled resources into the conversation context.",
"",
"When you recognize that a task matches one of the available skills below, invoke this tool to load the full skill instructions BEFORE generating any other response about the task.",
"",
"Important:",
"- When a skill matches the user's request, this is a BLOCKING REQUIREMENT: invoke this tool BEFORE generating any other response about the task.",
"- NEVER describe or summarize what a skill does without actually loading it first.",
"- Do not invoke a skill that has already been loaded in the current conversation.",
"",
"<available_skills>",
...skills.map(formatSkillEntry),
"</available_skills>",
"",
`Invoke with the skill name (e.g. ${skillExamples}).`,
].join("\n");
}

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 }}}",
wouldLikeTo: "load skill {{{ skillName }}}",
isCurrently: "loading skill {{{ skillName }}}",
hasAlready: "loaded 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`)}`,
description: buildDescription(skills),
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.",
description: `The name of the skill from <available_skills> (e.g. ${skills.length > 0 ? skills.map((s) => `"${s.name}"`).join(", ") : "..."}).`,
},
},
},
Expand Down
35 changes: 27 additions & 8 deletions core/tools/implementations/readSkill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,41 @@ export const readSkillImpl: ToolImpl = async (args, extras) => {
);
}

let content = skill.content;
const contentParts = [
`<skill name="${skill.name}">`,
`# Skill: ${skill.name}`,
"",
skill.content.trim(),
];

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.`;
const skillDir = skill.path.substring(0, skill.path.lastIndexOf("/"));
contentParts.push("");
contentParts.push(`Skill directory: ${skillDir}`);
contentParts.push(
"Relative paths in this skill are relative to the skill directory above.",
);
contentParts.push("");
contentParts.push("<skill_files>");
contentParts.push(...skill.files);
contentParts.push("</skill_files>");
contentParts.push("");
contentParts.push(
"Use the read file tool to access these supporting files as needed.",
);
}

contentParts.push("</skill>");
contentParts.push("");
contentParts.push(
"Follow the instructions in the loaded skill above. The skill is now active for this conversation.",
);

return [
{
name: `Skill: ${skill.name}`,
description: skill.description,
content,
content: contentParts.join("\n"),
uri: {
type: "file",
value: skill.path,
Expand Down
12 changes: 12 additions & 0 deletions extensions/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,18 @@ This is a CLI tool for Continue Dev that provides an interactive AI-assisted dev
- JSX support for React components
- Relative import paths require explicit file extensions, e.g. 'from "./test.js"' instead of 'from "./test"'

### Local Testing

To test the CLI locally after making changes:

1. **Build**: `npm run build` from `extensions/cli/`
2. **Link** (one-time): `npm link` from `extensions/cli/` — this symlinks the global `cn` command to `dist/cn.js`
3. **Use**: Run `cn` anywhere to test your local build

After the initial link, you only need to re-run `npm run build` — the symlink stays active and always points to the latest build output.

When you're done working on a feature, always build and link so the user can immediately test the changes by running `cn`.

### Important rules

- Whenever you create / update a test, you should run the test to be certain that it passes
Expand Down
6 changes: 5 additions & 1 deletion extensions/cli/src/__mocks__/commands/commands.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { vi } from "vitest";

export const getAllSlashCommands = vi.fn(() => [
export const getAllSlashCommands = vi.fn(async () => [
{ name: "help", description: "Show help", category: "system" },
{ name: "login", description: "Login to Continue", category: "system" },
]);

export const getAvailableSkills = vi.fn(async () => []);

export const getSkillSlashCommands = vi.fn(async () => []);
27 changes: 16 additions & 11 deletions extensions/cli/src/commands/commands.integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import type { AssistantConfig } from "@continuedev/sdk";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";

import { getAllSlashCommands } from "./commands.js";

// Mock loadMarkdownSkills so tests don't depend on filesystem
vi.mock("../util/loadMarkdownSkills.js", () => ({
loadMarkdownSkills: vi.fn().mockResolvedValue({ skills: [], errors: [] }),
}));

describe("Slash Commands Integration", () => {
const mockAssistant: AssistantConfig = {
name: "test-assistant",
Expand All @@ -17,8 +22,8 @@ describe("Slash Commands Integration", () => {
};

describe("System Commands Registration", () => {
it("should include all system commands in the commands list", () => {
const commands = getAllSlashCommands(mockAssistant);
it("should include all system commands in the commands list", async () => {
const commands = await getAllSlashCommands(mockAssistant);
const commandNames = commands.map((cmd) => cmd.name);

// Check that system commands are present (mode commands have been removed)
Expand All @@ -33,15 +38,15 @@ describe("Slash Commands Integration", () => {
expect(commandNames).toContain("config");
});

it("should include assistant prompt commands", () => {
const commands = getAllSlashCommands(mockAssistant);
it("should include assistant prompt commands", async () => {
const commands = await getAllSlashCommands(mockAssistant);
const commandNames = commands.map((cmd) => cmd.name);

expect(commandNames).toContain("test-prompt");
});

it("should categorize system commands correctly", () => {
const commands = getAllSlashCommands(mockAssistant);
it("should categorize system commands correctly", async () => {
const commands = await getAllSlashCommands(mockAssistant);
const systemCommands = commands.filter((cmd) =>
[
"help",
Expand All @@ -60,8 +65,8 @@ describe("Slash Commands Integration", () => {
});
});

it("should categorize assistant commands correctly", () => {
const commands = getAllSlashCommands(mockAssistant);
it("should categorize assistant commands correctly", async () => {
const commands = await getAllSlashCommands(mockAssistant);
const assistantCommands = commands.filter(
(cmd) => cmd.name === "test-prompt",
);
Expand All @@ -71,8 +76,8 @@ describe("Slash Commands Integration", () => {
});
});

it("should only show remote mode commands in remote mode", () => {
const commands = getAllSlashCommands(mockAssistant, {
it("should only show remote mode commands in remote mode", async () => {
const commands = await getAllSlashCommands(mockAssistant, {
isRemoteMode: true,
});
const commandNames = commands.map((cmd) => cmd.name);
Expand Down
45 changes: 42 additions & 3 deletions extensions/cli/src/commands/commands.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { type AssistantConfig } from "@continuedev/sdk";

import { loadMarkdownSkills, type Skill } from "../util/loadMarkdownSkills.js";

// Export command functions
export { chat } from "./chat.js";
export { login } from "./login.js";
Expand Down Expand Up @@ -106,6 +108,11 @@
description: "List background jobs",
category: "system",
},
{
name: "skills",
description: "Browse and select available skills",
category: "system",
},
];

// Remote mode specific commands
Expand All @@ -130,10 +137,10 @@
/**
* Get all available slash commands including system commands and assistant prompts
*/
export function getAllSlashCommands(
export async function getAllSlashCommands(
assistant: AssistantConfig,
options: { isRemoteMode?: boolean } = {},
): SlashCommand[] {
): Promise<SlashCommand[]> {
const { isRemoteMode = false } = options;

// In remote mode, only show the exit command
Expand All @@ -155,7 +162,10 @@
// Get invokable rule commands
const invokableRuleCommands = getInvokableRuleSlashCommands(assistant);

return [...systemCommands, ...assistantCommands, ...invokableRuleCommands];
// Get skill commands
const skillCommands = await getSkillSlashCommands();

return [...systemCommands, ...assistantCommands, ...invokableRuleCommands, ...skillCommands];
}

/**
Expand Down Expand Up @@ -202,3 +212,32 @@
};
});
}

/**
* Get skill commands that can be invoked as slash commands
*/
export async function getSkillSlashCommands(): Promise<SlashCommand[]> {
try {
const { skills } = await loadMarkdownSkills();
return skills.map((skill) => ({
name: skill.name,
description: skill.description,
category: "assistant" as const,
}));
} catch (error) {

Check failure on line 227 in extensions/cli/src/commands/commands.ts

View workflow job for this annotation

GitHub Actions / lint

'error' is defined but never used
// If skills can't be loaded, return empty array
return [];
}
}

/**
* Get available skills
*/
export async function getAvailableSkills(): Promise<Skill[]> {
try {
const { skills } = await loadMarkdownSkills();
return skills;
} catch (error) {

Check failure on line 240 in extensions/cli/src/commands/commands.ts

View workflow job for this annotation

GitHub Actions / lint

'error' is defined but never used
return [];
}
}
1 change: 1 addition & 0 deletions extensions/cli/src/permissions/defaultPolicies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export const PLAN_MODE_POLICIES: ToolPermissionPolicy[] = [
{ tool: "Checklist", permission: "allow" },
{ tool: "Diff", permission: "allow" },
{ tool: "Exit", permission: "allow" },
{ tool: "ExitPlanMode", permission: "allow" },
{ tool: "Fetch", permission: "allow" },
{ tool: "List", permission: "allow" },
{ tool: "Read", permission: "allow" },
Expand Down
3 changes: 3 additions & 0 deletions extensions/cli/src/services/QuizService.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { EventEmitter } from "events";

/** Sentinel value returned when the user presses Escape to decline answering */
export const QUIZ_DECLINED = "__QUIZ_DECLINED__";

/**Types */
export interface QuizQuestion {
question: string;
Expand Down
8 changes: 7 additions & 1 deletion extensions/cli/src/slashCommands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,13 @@ vi.mock("./telemetry/posthogService.js", () => ({

// Mock commands
vi.mock("./commands/commands.js", () => ({
getAllSlashCommands: vi.fn(() => []),
getAllSlashCommands: vi.fn(async () => []),
getAvailableSkills: vi.fn(async () => []),
}));

// Mock loadMarkdownSkills
vi.mock("./util/loadMarkdownSkills.js", () => ({
loadMarkdownSkills: vi.fn().mockResolvedValue({ skills: [], errors: [] }),
}));

// Mock logger to avoid file system operations
Expand Down
Loading
Loading