mirror of
https://github.com/cline/cline.git
synced 2026-09-01 23:19:18 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f1770375e | |||
| 60bb1a439d | |||
| 3cb8c82b75 |
@@ -38,13 +38,27 @@ export const SlashCommandMenu: React.FC<SlashCommandMenuProps> = ({ commands, se
|
||||
const { items: visibleCommands, startIndex } = getVisibleWindow(commands, selectedIndex)
|
||||
const hasMoreBelow = startIndex + visibleCommands.length < commands.length
|
||||
|
||||
const getTypeLabel = (section?: string) => {
|
||||
switch (section) {
|
||||
case "custom":
|
||||
return "[Workflow]"
|
||||
case "skill":
|
||||
return "[Skill]"
|
||||
case "mcp":
|
||||
return "[MCP]"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} paddingLeft={1} paddingRight={1}>
|
||||
{visibleCommands.map((cmd, idx) => {
|
||||
const isSelected = startIndex + idx === selectedIndex
|
||||
// Only show description for default commands (not workflows)
|
||||
const showDescription = cmd.section === "default" || !cmd.section
|
||||
const commandPrefix = `${isSelected ? "❯" : " "} /${cmd.name}`
|
||||
const showDescription =
|
||||
cmd.section === "default" || cmd.section === "mcp" || cmd.section === "skill" || !cmd.section
|
||||
const typeLabel = getTypeLabel(cmd.section)
|
||||
const commandPrefix = `${isSelected ? "❯" : " "} /${cmd.name}${typeLabel ? ` ${typeLabel}` : ""}`
|
||||
const truncatedCommand = truncateText(commandPrefix, contentWidth)
|
||||
const descriptionText = showDescription && cmd.description ? ` - ${cmd.description}` : ""
|
||||
const fullLine = truncateText(truncatedCommand + descriptionText, contentWidth)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { SlashCommandInfo } from "@shared/proto/cline/slash"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { extractSlashQuery, sortCommandsWorkflowsFirst } from "./slash-commands"
|
||||
|
||||
describe("slash-commands utils", () => {
|
||||
describe("sortCommandsWorkflowsFirst", () => {
|
||||
it("sorts sections by priority: skill, custom, default, mcp", () => {
|
||||
const commands: SlashCommandInfo[] = [
|
||||
{ name: "zz-default", section: "default", description: "", cliCompatible: true },
|
||||
{ name: "aa-mcp", section: "mcp", description: "", cliCompatible: true },
|
||||
{ name: "bb-workflow", section: "custom", description: "", cliCompatible: true },
|
||||
{ name: "cc-skill", section: "skill", description: "", cliCompatible: true },
|
||||
]
|
||||
|
||||
const sorted = sortCommandsWorkflowsFirst(commands)
|
||||
expect(sorted.map((c) => c.name)).toEqual(["cc-skill", "bb-workflow", "zz-default", "aa-mcp"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("extractSlashQuery", () => {
|
||||
it("supports colon-delimited commands while typing", () => {
|
||||
const result = extractSlashQuery("please run /mcp:github:issue_to_fix_workflow")
|
||||
expect(result.inSlashMode).toBe(true)
|
||||
expect(result.query).toBe("mcp:github:issue_to_fix_workflow")
|
||||
})
|
||||
|
||||
it("does not enter slash mode for a second command after a completed colon command", () => {
|
||||
const text = "/mcp:github:prompt /skill-candidate"
|
||||
const result = extractSlashQuery(text, text.length)
|
||||
expect(result.inSlashMode).toBe(false)
|
||||
expect(result.query).toBe("")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -22,7 +22,7 @@ export interface VisibleWindow<T> {
|
||||
* Centers the selected item in the visible window when possible.
|
||||
* Returns the visible items and the start index for selection tracking.
|
||||
*/
|
||||
export function getVisibleWindow<T>(items: T[], selectedIndex: number, maxVisible: number = 5): VisibleWindow<T> {
|
||||
export function getVisibleWindow<T>(items: T[], selectedIndex: number, maxVisible = 5): VisibleWindow<T> {
|
||||
if (items.length <= maxVisible) {
|
||||
return { items, startIndex: 0 }
|
||||
}
|
||||
@@ -40,10 +40,24 @@ export function getVisibleWindow<T>(items: T[], selectedIndex: number, maxVisibl
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort commands with workflows (custom section) first, then default commands.
|
||||
* Sort commands by section priority for menu display.
|
||||
*/
|
||||
export function sortCommandsWorkflowsFirst(commands: SlashCommandInfo[]): SlashCommandInfo[] {
|
||||
return [...commands.filter((cmd) => cmd.section === "custom"), ...commands.filter((cmd) => cmd.section !== "custom")]
|
||||
const sectionPriority: Record<string, number> = {
|
||||
skill: 0,
|
||||
custom: 1,
|
||||
default: 2,
|
||||
mcp: 3,
|
||||
}
|
||||
|
||||
return [...commands].sort((a, b) => {
|
||||
const aPriority = sectionPriority[a.section || "default"] ?? 99
|
||||
const bPriority = sectionPriority[b.section || "default"] ?? 99
|
||||
if (aPriority !== bPriority) {
|
||||
return aPriority - bPriority
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,7 +92,7 @@ export function extractSlashQuery(text: string, cursorPosition?: number): SlashQ
|
||||
|
||||
// Check if there's already a completed slash command earlier in the text
|
||||
// (only first slash command per message is processed)
|
||||
const firstSlashCommandRegex = /(^|\s)\/[a-zA-Z0-9_.-]+\s/
|
||||
const firstSlashCommandRegex = /(^|\s)\/[a-zA-Z0-9_.:@-]+\s/
|
||||
const textBeforeCurrentSlash = text.slice(0, slashIndex)
|
||||
if (firstSlashCommandRegex.test(textBeforeCurrentSlash)) {
|
||||
return { inSlashMode: false, query: "", slashIndex: -1 }
|
||||
|
||||
@@ -85,7 +85,7 @@ Compare @src/old-api.ts with @src/new-api.ts and list the breaking changes
|
||||
|
||||
## Slash Commands
|
||||
|
||||
Type `/` to see available commands. Slash commands provide quick access to settings, history, and workflows.
|
||||
Type `/` to see available commands. Slash commands provide quick access to settings, history, skills, and workflows.
|
||||
|
||||
### Built-in Commands
|
||||
|
||||
@@ -106,6 +106,18 @@ If you have [workflows](/features/slash-commands/workflows/index) configured, th
|
||||
/code-review
|
||||
```
|
||||
|
||||
Legacy filename-style workflow commands like `/code-review.md` are also supported for compatibility.
|
||||
|
||||
### Skill Commands
|
||||
|
||||
If you have [skills](/features/skills) enabled and discovered, they also appear as slash commands.
|
||||
|
||||
```
|
||||
/my-skill
|
||||
```
|
||||
|
||||
If a skill and workflow share the same command name, the skill command wins for `/name`.
|
||||
|
||||
## Settings Panel
|
||||
|
||||
Access the settings panel with `/settings`. Navigate between tabs using arrow keys.
|
||||
|
||||
@@ -32,7 +32,7 @@ Key features:
|
||||
- **Real-time conversation** - Type messages, see Cline's responses, and iterate on tasks
|
||||
- **Visual feedback** - Animated welcome screen, syntax-highlighted code, and progress indicators
|
||||
- **File mentions** with `@` - Reference workspace files with fuzzy search autocomplete
|
||||
- **Slash commands** with `/` - Quick access to `/settings`, `/history`, `/models`, and workflows
|
||||
- **Slash commands** with `/` - Quick access to `/settings`, `/history`, `/models`, skills, and workflows
|
||||
- **Keyboard shortcuts** - `Tab` to toggle Plan/Act, `Shift+Tab` for auto-approve all
|
||||
- **Session summaries** - See tasks completed, files modified, and token usage on exit
|
||||
- **Settings panel** - Configure providers, models, and features without leaving the CLI
|
||||
|
||||
@@ -98,6 +98,20 @@ description: Deploy applications to AWS using CDK. Use when deploying, updating
|
||||
|
||||
Asking "deploy this to AWS" would trigger Cline to activate the skill, load its detailed instructions, and follow them to complete your request.
|
||||
|
||||
## Calling Skills with Slash Commands
|
||||
|
||||
Enabled skills are also available as slash commands.
|
||||
|
||||
- Type `/` in chat and pick a skill from the **Skill Commands** section
|
||||
- Use `/skill-name` directly (for example, `/aws-deploy`)
|
||||
- Skill commands use the skill's `name` from `SKILL.md`
|
||||
|
||||
When a skill name collides with a workflow name, the skill command wins for `/name`.
|
||||
|
||||
<Note>
|
||||
Workflows are still supported, but we recommend creating new automation as skills. Workflow support is planned for future deprecation.
|
||||
</Note>
|
||||
|
||||
## Example: Data Analysis Skill
|
||||
|
||||
Here's a practical skill for data analysis tasks. Create a directory called `data-analysis/` with this `SKILL.md`:
|
||||
@@ -208,12 +222,12 @@ The best skills encode institutional knowledge that would otherwise live only in
|
||||
| Feature | Purpose | When Active |
|
||||
|---------|---------|-------------|
|
||||
| **Rules** | Define how Cline should behave | Always (or contextually) |
|
||||
| **Workflows** | Step-by-step task automation | Invoked with `/workflow.md` |
|
||||
| **Workflows** | Step-by-step task automation | Invoked with `/workflow` (legacy `/workflow.md` also works) |
|
||||
| **Skills** | Domain expertise loaded on-demand | Triggered by matching requests |
|
||||
|
||||
**Rules** set constraints and preferences (like "always use TypeScript" or "follow this style guide").
|
||||
|
||||
**Workflows** are explicit sequences you invoke for specific tasks (like `/release.md` for a release process).
|
||||
**Workflows** are explicit sequences you invoke for specific tasks (like `/release` for a release process).
|
||||
|
||||
**Skills** are expertise that Cline activates automatically when relevant (like data analysis knowledge when you're working with CSV files).
|
||||
|
||||
@@ -224,4 +238,3 @@ Use rules for ongoing constraints, workflows for explicit automation, and skills
|
||||
- [Cline Rules](/features/cline-rules) for always-active project guidance
|
||||
- [Workflows](/features/slash-commands/workflows/index) for explicit task automation
|
||||
- [Hooks](/features/hooks/index) for injecting custom logic at key moments
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Creating a workflow is simpler than you might think. There's actually a workflow
|
||||
|
||||
First, **save the [create-new-workflow.md](https://github.com/cline/prompts/blob/main/workflows/create-new-workflow.md) file to your workspace** (e.g., in `.clinerules/workflows/`).
|
||||
|
||||
Then, type `/create-new-workflow.md` and Cline guides you through it:
|
||||
Then, type `/create-new-workflow` and Cline guides you through it:
|
||||
|
||||
1. It asks for the purpose and a concise name.
|
||||
2. You describe the objective and expected outputs.
|
||||
|
||||
@@ -6,7 +6,15 @@ description: "Learn what Cline workflows are, why they are useful, and how to st
|
||||
|
||||
Workflows in Cline are Markdown files that define a series of steps to guide Cline through repetitive or complex tasks. They are a powerful way to automate your development processes directly within your editor.
|
||||
|
||||
To invoke a workflow, you simply type `/` followed by the workflow's filename in the chat (e.g., `/deploy.md`).
|
||||
To invoke a workflow, type `/` followed by the workflow name in chat (for example, `/deploy`).
|
||||
|
||||
<Note>
|
||||
Legacy filename-style commands like `/deploy.md` are still supported for compatibility.
|
||||
</Note>
|
||||
|
||||
<Warning>
|
||||
Workflows remain supported today, but new automation should prefer [skills](/features/skills). Workflow support is planned for future deprecation.
|
||||
</Warning>
|
||||
|
||||
## Why Use Cline Workflows?
|
||||
|
||||
@@ -51,11 +59,19 @@ This is tedious and easy to mess up. You might forget to run the tests or format
|
||||
**With a Cline workflow**, you define these steps once in a `release.md` file. Then, you just type:
|
||||
|
||||
```bash
|
||||
/release.md
|
||||
/release
|
||||
```
|
||||
|
||||
Cline will then meticulously follow your instructions: updating files, running tests, and executing git commands—pausing only if it encounters an error or needs your input.
|
||||
|
||||
## Skills and Name Collisions
|
||||
|
||||
Skills and workflows both appear as slash commands.
|
||||
|
||||
- If a skill and workflow share the same command name, the skill wins for `/name`.
|
||||
- For file-backed workflows, `/name.md` remains a compatibility alias you can invoke explicitly.
|
||||
- To avoid ambiguity, prefer unique names between skills and workflows.
|
||||
|
||||
## Where are Workflows Stored?
|
||||
|
||||
You can store workflows in two locations, depending on whether they are specific to a project or meant to be global.
|
||||
|
||||
@@ -83,7 +83,8 @@ This workflow will automate the process of fetching PR details, analyzing the co
|
||||
````
|
||||
|
||||
<Note>
|
||||
When you run this workflow, you will replace `PR_NUMBER` with the actual number of the pull request you want to review (e.g., `/pr-review.md 123`).
|
||||
When you run this workflow, you will replace `PR_NUMBER` with the actual number of the pull request you want to review (e.g., `/pr-review 123`).
|
||||
Legacy `/pr-review.md` still works.
|
||||
</Note>
|
||||
</Step>
|
||||
|
||||
@@ -91,7 +92,7 @@ This workflow will automate the process of fetching PR details, analyzing the co
|
||||
Now you're ready to run your new workflow.
|
||||
|
||||
1. Open the Cline chat panel.
|
||||
2. Type `/pr-review.md` followed by the PR number (e.g., `/pr-review.md 42`) and press Enter.
|
||||
2. Type `/pr-review` followed by the PR number (e.g., `/pr-review 42`) and press Enter.
|
||||
3. Cline will fetch the PR details, analyze the code, and present you with its findings before submitting the review.
|
||||
|
||||
<Tip>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { discoverSkills, getAvailableSkills } from "@core/context/instructions/user-instructions/skills"
|
||||
import { EmptyRequest } from "@shared/proto/cline/common"
|
||||
import { SlashCommandInfo, SlashCommandsResponse } from "@shared/proto/cline/slash"
|
||||
import { toWorkflowCommandName } from "@shared/slash-command-names"
|
||||
import { BASE_SLASH_COMMANDS } from "@/shared/slashCommands"
|
||||
import { Controller } from ".."
|
||||
|
||||
@@ -27,19 +29,52 @@ export async function getAvailableSlashCommands(controller: Controller, _request
|
||||
const remoteWorkflowToggles = controller.stateManager.getGlobalStateKey("remoteWorkflowToggles") ?? {}
|
||||
const remoteConfigSettings = controller.stateManager.getRemoteConfigSettings()
|
||||
const remoteWorkflows = remoteConfigSettings?.remoteGlobalWorkflows ?? []
|
||||
const globalSkillsToggles = controller.stateManager.getGlobalSettingsKey("globalSkillsToggles") ?? {}
|
||||
const localSkillsToggles = controller.stateManager.getWorkspaceStateKey("localSkillsToggles") ?? {}
|
||||
|
||||
// Track local workflow names to avoid duplicates from global
|
||||
const localNames = new Set<string>()
|
||||
// Add enabled skills first so skills can win workflow name collisions.
|
||||
const skillNames = new Set<string>()
|
||||
try {
|
||||
const cwd = controller.getWorkspaceManager?.()?.getPrimaryRoot?.()?.path ?? process.cwd()
|
||||
const resolvedSkills = getAvailableSkills(await discoverSkills(cwd))
|
||||
for (const skill of resolvedSkills) {
|
||||
const toggles = skill.source === "global" ? globalSkillsToggles : localSkillsToggles
|
||||
if (toggles[skill.path] === false) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!skillNames.has(skill.name)) {
|
||||
skillNames.add(skill.name)
|
||||
|
||||
commands.push(
|
||||
SlashCommandInfo.create({
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
section: "skill",
|
||||
cliCompatible: true,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skills are additive for slash autocomplete. If discovery fails, continue.
|
||||
}
|
||||
|
||||
// Track workflow names to avoid duplicates from global/remote
|
||||
const workflowNames = new Set<string>()
|
||||
|
||||
// Add local workflows (enabled only)
|
||||
for (const [path, enabled] of Object.entries(localWorkflowToggles)) {
|
||||
if (enabled) {
|
||||
const fileName = fullPathToFileName(path)
|
||||
localNames.add(fileName)
|
||||
const fileName = toWorkflowCommandName(path)
|
||||
if (skillNames.has(fileName) || workflowNames.has(fileName)) {
|
||||
continue
|
||||
}
|
||||
workflowNames.add(fileName)
|
||||
commands.push(
|
||||
SlashCommandInfo.create({
|
||||
name: fileName,
|
||||
description: `Custom workflow: ${fileName}`,
|
||||
description: "Workflow command",
|
||||
section: "custom",
|
||||
cliCompatible: true,
|
||||
}),
|
||||
@@ -50,17 +85,19 @@ export async function getAvailableSlashCommands(controller: Controller, _request
|
||||
// Add global workflows (enabled only, skip if local exists with same name)
|
||||
for (const [path, enabled] of Object.entries(globalWorkflowToggles)) {
|
||||
if (enabled) {
|
||||
const fileName = fullPathToFileName(path)
|
||||
if (!localNames.has(fileName)) {
|
||||
commands.push(
|
||||
SlashCommandInfo.create({
|
||||
name: fileName,
|
||||
description: `Custom workflow: ${fileName}`,
|
||||
section: "custom",
|
||||
cliCompatible: true,
|
||||
}),
|
||||
)
|
||||
const fileName = toWorkflowCommandName(path)
|
||||
if (skillNames.has(fileName) || workflowNames.has(fileName)) {
|
||||
continue
|
||||
}
|
||||
workflowNames.add(fileName)
|
||||
commands.push(
|
||||
SlashCommandInfo.create({
|
||||
name: fileName,
|
||||
description: "Workflow command",
|
||||
section: "custom",
|
||||
cliCompatible: true,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,10 +105,15 @@ export async function getAvailableSlashCommands(controller: Controller, _request
|
||||
for (const workflow of remoteWorkflows) {
|
||||
const enabled = workflow.alwaysEnabled || remoteWorkflowToggles[workflow.name] !== false
|
||||
if (enabled) {
|
||||
const workflowName = toWorkflowCommandName(workflow.name)
|
||||
if (skillNames.has(workflowName) || workflowNames.has(workflowName)) {
|
||||
continue
|
||||
}
|
||||
workflowNames.add(workflowName)
|
||||
commands.push(
|
||||
SlashCommandInfo.create({
|
||||
name: workflow.name,
|
||||
description: `Remote workflow: ${workflow.name}`,
|
||||
name: workflowName,
|
||||
description: "Workflow command",
|
||||
section: "custom",
|
||||
cliCompatible: true,
|
||||
}),
|
||||
@@ -81,8 +123,3 @@ export async function getAvailableSlashCommands(controller: Controller, _request
|
||||
|
||||
return SlashCommandsResponse.create({ commands })
|
||||
}
|
||||
|
||||
function fullPathToFileName(path: string): string {
|
||||
// e.g. replace /path/to/workflow.md with workflow.md
|
||||
return path.replace(/^.*[/\\]/, "")
|
||||
}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import type { McpPromptResponse } from "@shared/mcp"
|
||||
import { expect } from "chai"
|
||||
import fs from "fs/promises"
|
||||
import * as sinon from "sinon"
|
||||
import * as skillsUtils from "../../context/instructions/user-instructions/skills"
|
||||
import { formatMcpPromptResponse, McpPromptFetcher, parseSlashCommands } from "../index"
|
||||
|
||||
describe("slash-commands", () => {
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
describe("formatMcpPromptResponse", () => {
|
||||
it("should format text message", () => {
|
||||
const response: McpPromptResponse = {
|
||||
@@ -141,4 +148,85 @@ describe("slash-commands", () => {
|
||||
// are skipped because they require StateManager initialization when falling
|
||||
// through to workflow checking. The core MCP functionality is covered above.
|
||||
})
|
||||
|
||||
describe("parseSlashCommands skill handling", () => {
|
||||
it("should process skill command in task tag", async () => {
|
||||
sinon.stub(skillsUtils, "getSkillContent").resolves({
|
||||
name: "debug-build",
|
||||
description: "Debug build issues",
|
||||
path: "/tmp/skills/debug-build/SKILL.md",
|
||||
source: "project",
|
||||
instructions: "Step 1: inspect logs.\nStep 2: propose fix.",
|
||||
})
|
||||
|
||||
const text = "<task>/debug-build investigate this failure</task>"
|
||||
const result = await parseSlashCommands(text, {}, {}, "test-ulid", undefined, false, undefined, undefined, [
|
||||
{
|
||||
name: "debug-build",
|
||||
description: "Debug build issues",
|
||||
path: "/tmp/skills/debug-build/SKILL.md",
|
||||
source: "project",
|
||||
},
|
||||
])
|
||||
|
||||
expect(result.processedText).to.include('<explicit_instructions type="skill:debug-build">')
|
||||
expect(result.processedText).to.include("Step 1: inspect logs.")
|
||||
expect(result.processedText).to.include("investigate this failure")
|
||||
})
|
||||
|
||||
it("should prefer skill over workflow on name collision", async () => {
|
||||
const readFileStub = sinon.stub(fs, "readFile").resolves("workflow instructions")
|
||||
sinon.stub(skillsUtils, "getSkillContent").resolves({
|
||||
name: "release-checklist",
|
||||
description: "Release checklist",
|
||||
path: "/tmp/skills/release-checklist/SKILL.md",
|
||||
source: "global",
|
||||
instructions: "skill instructions",
|
||||
})
|
||||
|
||||
const text = "<task>/release-checklist run this now</task>"
|
||||
const result = await parseSlashCommands(
|
||||
text,
|
||||
{
|
||||
"/tmp/.clinerules/workflows/release-checklist.md": true,
|
||||
},
|
||||
{},
|
||||
"test-ulid",
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
[
|
||||
{
|
||||
name: "release-checklist",
|
||||
description: "Release checklist",
|
||||
path: "/tmp/skills/release-checklist/SKILL.md",
|
||||
source: "global",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
expect(result.processedText).to.include('<explicit_instructions type="skill:release-checklist">')
|
||||
expect(result.processedText).to.include("skill instructions")
|
||||
expect(result.processedText).to.not.include("workflow instructions")
|
||||
expect(readFileStub.called).to.equal(false)
|
||||
})
|
||||
|
||||
it("should support workflow command without file extension", async () => {
|
||||
sinon.stub(fs, "readFile").resolves("workflow instructions")
|
||||
|
||||
const text = "<task>/git-branch-analysis please run</task>"
|
||||
const result = await parseSlashCommands(
|
||||
text,
|
||||
{
|
||||
"/tmp/.clinerules/workflows/git-branch-analysis.md": true,
|
||||
},
|
||||
{},
|
||||
"test-ulid",
|
||||
)
|
||||
|
||||
expect(result.processedText).to.include('<explicit_instructions type="git-branch-analysis.md">')
|
||||
expect(result.processedText).to.include("workflow instructions")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { ApiProviderInfo } from "@core/api"
|
||||
import { getSkillContent } from "@core/context/instructions/user-instructions/skills"
|
||||
import { ClineRulesToggles } from "@shared/cline-rules"
|
||||
import { McpPromptResponse } from "@shared/mcp"
|
||||
import type { SkillMetadata } from "@shared/skills"
|
||||
import { getWorkflowCommandAliases } from "@shared/slash-command-names"
|
||||
import fs from "fs/promises"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
@@ -48,6 +51,7 @@ export async function parseSlashCommands(
|
||||
enableNativeToolCalls?: boolean,
|
||||
providerInfo?: ApiProviderInfo,
|
||||
mcpPromptFetcher?: McpPromptFetcher,
|
||||
availableSkills: SkillMetadata[] = [],
|
||||
): Promise<{ processedText: string; needsClinerulesFileCheck: boolean }> {
|
||||
const SUPPORTED_DEFAULT_COMMANDS = ["newtask", "smol", "compact", "newrule", "reportbug", "deep-planning", "explain-changes"]
|
||||
|
||||
@@ -173,6 +177,26 @@ export async function parseSlashCommands(
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the command matches an enabled skill (skills win collisions with workflows)
|
||||
const matchingSkill = availableSkills.find((skill) => skill.name === commandName)
|
||||
if (matchingSkill) {
|
||||
try {
|
||||
const skillContent = await getSkillContent(matchingSkill.name, availableSkills)
|
||||
if (skillContent) {
|
||||
const textWithoutSlashCommand = removeSlashCommand(text, tagContent, contentStartIndex, slashMatch)
|
||||
const processedText =
|
||||
`<explicit_instructions type="skill:${matchingSkill.name}">\n${skillContent.instructions}\n</explicit_instructions>\n` +
|
||||
textWithoutSlashCommand
|
||||
|
||||
telemetryService.captureSlashCommandUsed(ulid, commandName, "skill")
|
||||
|
||||
return { processedText, needsClinerulesFileCheck: false }
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`Error loading skill ${matchingSkill.name}: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
const globalWorkflows: Workflow[] = Object.entries(globalWorkflowToggles)
|
||||
.filter(([_, enabled]) => enabled)
|
||||
.map(([filePath, _]) => ({
|
||||
@@ -189,11 +213,17 @@ export async function parseSlashCommands(
|
||||
isRemote: false,
|
||||
}))
|
||||
|
||||
// Get remote workflows from remote config
|
||||
const stateManager = StateManager.get()
|
||||
const remoteConfigSettings = stateManager.getRemoteConfigSettings()
|
||||
const remoteWorkflows = remoteConfigSettings.remoteGlobalWorkflows || []
|
||||
const remoteWorkflowToggles = stateManager.getGlobalStateKey("remoteWorkflowToggles") || {}
|
||||
// Get remote workflows from remote config (if state manager is initialized)
|
||||
let remoteWorkflows: Array<{ name: string; contents: string; alwaysEnabled?: boolean }> = []
|
||||
let remoteWorkflowToggles: Record<string, boolean> = {}
|
||||
try {
|
||||
const stateManager = StateManager.get()
|
||||
const remoteConfigSettings = stateManager.getRemoteConfigSettings()
|
||||
remoteWorkflows = remoteConfigSettings?.remoteGlobalWorkflows || []
|
||||
remoteWorkflowToggles = stateManager.getGlobalStateKey("remoteWorkflowToggles") || {}
|
||||
} catch {
|
||||
// StateManager may be uninitialized in isolated tests.
|
||||
}
|
||||
|
||||
const enabledRemoteWorkflows: Workflow[] = remoteWorkflows
|
||||
.filter((workflow) => {
|
||||
@@ -210,8 +240,10 @@ export async function parseSlashCommands(
|
||||
// local workflows have precedence over global workflows, which have precedence over remote workflows
|
||||
const enabledWorkflows: Workflow[] = [...localWorkflows, ...globalWorkflows, ...enabledRemoteWorkflows]
|
||||
|
||||
// Then check if the command matches any enabled workflow filename
|
||||
const matchingWorkflow = enabledWorkflows.find((workflow) => workflow.fileName === commandName)
|
||||
// Then check if the command matches any enabled workflow alias
|
||||
const matchingWorkflow = enabledWorkflows.find((workflow) =>
|
||||
getWorkflowCommandAliases(workflow.fileName).includes(commandName),
|
||||
)
|
||||
|
||||
if (matchingWorkflow) {
|
||||
try {
|
||||
|
||||
@@ -3086,6 +3086,25 @@ export class Task {
|
||||
const providerInfo = this.getCurrentProviderInfo()
|
||||
const cwd = this.cwd
|
||||
const { localWorkflowToggles, globalWorkflowToggles } = await refreshWorkflowToggles(this.controller, cwd)
|
||||
let cachedAvailableSkills: Awaited<ReturnType<typeof discoverSkills>> | undefined
|
||||
|
||||
const getEnabledSkills = async () => {
|
||||
if (cachedAvailableSkills) {
|
||||
return cachedAvailableSkills
|
||||
}
|
||||
|
||||
const discoveredSkills = await discoverSkills(cwd)
|
||||
const resolvedSkills = getAvailableSkills(discoveredSkills)
|
||||
const globalSkillsToggles = this.stateManager.getGlobalSettingsKey("globalSkillsToggles") ?? {}
|
||||
const localSkillsToggles = this.stateManager.getWorkspaceStateKey("localSkillsToggles") ?? {}
|
||||
|
||||
cachedAvailableSkills = resolvedSkills.filter((skill) => {
|
||||
const toggles = skill.source === "global" ? globalSkillsToggles : localSkillsToggles
|
||||
return toggles[skill.path] !== false
|
||||
})
|
||||
|
||||
return cachedAvailableSkills
|
||||
}
|
||||
|
||||
const hasUserContentTag = (text: string): boolean => {
|
||||
return USER_CONTENT_TAGS.some((tag) => text.includes(tag))
|
||||
@@ -3118,6 +3137,7 @@ export class Task {
|
||||
useNativeToolCalls,
|
||||
providerInfo,
|
||||
mcpPromptFetcher,
|
||||
await getEnabledSkills(),
|
||||
)
|
||||
|
||||
if (needsCheck) {
|
||||
|
||||
@@ -1565,9 +1565,13 @@ export class TelemetryService {
|
||||
* Records when slash commands or workflows are activated
|
||||
* @param ulid Unique identifier for the task
|
||||
* @param commandName The name of the command (e.g., "newtask", "reportbug", or custom workflow name)
|
||||
* @param commandType Whether it's a built-in command, custom workflow, or MCP prompt
|
||||
* @param commandType Whether it's a built-in command, custom workflow, MCP prompt, or skill
|
||||
*/
|
||||
public captureSlashCommandUsed(ulid: string, commandName: string, commandType: "builtin" | "workflow" | "mcp_prompt") {
|
||||
public captureSlashCommandUsed(
|
||||
ulid: string,
|
||||
commandName: string,
|
||||
commandType: "builtin" | "workflow" | "mcp_prompt" | "skill",
|
||||
) {
|
||||
this.capture({
|
||||
event: TelemetryService.EVENTS.TASK.SLASH_COMMAND_USED,
|
||||
properties: {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
const WORKFLOW_EXTENSION_REGEX = /\.(md|txt)$/i
|
||||
|
||||
/**
|
||||
* Converts a workflow filename/path into a clean slash command name.
|
||||
* Example: "pr-review.md" -> "pr-review"
|
||||
*/
|
||||
export function toWorkflowCommandName(input: string): string {
|
||||
const fileName = input.replace(/^.*[/\\]/, "")
|
||||
return fileName.replace(WORKFLOW_EXTENSION_REGEX, "")
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all accepted command aliases for a workflow.
|
||||
* First alias is the normalized display command name.
|
||||
*/
|
||||
export function getWorkflowCommandAliases(fileName: string): string[] {
|
||||
const normalized = toWorkflowCommandName(fileName)
|
||||
return normalized === fileName ? [fileName] : [normalized, fileName]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
export interface SlashCommand {
|
||||
name: string
|
||||
description?: string
|
||||
section?: "default" | "custom" | "mcp"
|
||||
section?: "default" | "custom" | "mcp" | "skill"
|
||||
cliCompatible?: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it } from "mocha"
|
||||
import "should"
|
||||
import { getWorkflowCommandAliases, toWorkflowCommandName } from "../shared/slash-command-names"
|
||||
|
||||
describe("slash-command-names", () => {
|
||||
describe("toWorkflowCommandName", () => {
|
||||
it("removes .md extension", () => {
|
||||
toWorkflowCommandName("my-workflow.md").should.equal("my-workflow")
|
||||
})
|
||||
|
||||
it("removes .txt extension", () => {
|
||||
toWorkflowCommandName("my-workflow.txt").should.equal("my-workflow")
|
||||
})
|
||||
|
||||
it("keeps names without extension", () => {
|
||||
toWorkflowCommandName("my-workflow").should.equal("my-workflow")
|
||||
})
|
||||
|
||||
it("extracts file name from full path", () => {
|
||||
toWorkflowCommandName("/tmp/.clinerules/workflows/my-workflow.md").should.equal("my-workflow")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getWorkflowCommandAliases", () => {
|
||||
it("returns normalized name and original file name when extension exists", () => {
|
||||
getWorkflowCommandAliases("my-workflow.md").should.deepEqual(["my-workflow", "my-workflow.md"])
|
||||
})
|
||||
|
||||
it("returns single alias when already normalized", () => {
|
||||
getWorkflowCommandAliases("my-workflow").should.deepEqual(["my-workflow"])
|
||||
})
|
||||
})
|
||||
})
|
||||
+110
-10
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, it } from "mocha"
|
||||
import "should"
|
||||
import * as skillsUtils from "@core/context/instructions/user-instructions/skills"
|
||||
import * as sinon from "sinon"
|
||||
import { Controller } from "../core/controller"
|
||||
import { getAvailableSlashCommands } from "../core/controller/slash/getAvailableSlashCommands"
|
||||
@@ -36,6 +37,9 @@ describe("getAvailableSlashCommands", () => {
|
||||
mockController = {
|
||||
stateManager: mockStateManager as any,
|
||||
}
|
||||
|
||||
sinon.stub(skillsUtils, "discoverSkills").resolves([])
|
||||
sinon.stub(skillsUtils, "getAvailableSkills").returns([])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -86,12 +90,12 @@ describe("getAvailableSlashCommands", () => {
|
||||
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
const myWorkflow = response.commands.find((cmd) => cmd.name === "my-workflow.md")
|
||||
const myWorkflow = response.commands.find((cmd) => cmd.name === "my-workflow")
|
||||
myWorkflow!.should.not.be.undefined()
|
||||
myWorkflow!.section.should.equal("custom")
|
||||
myWorkflow!.cliCompatible.should.equal(true)
|
||||
|
||||
const anotherWorkflow = response.commands.find((cmd) => cmd.name === "another-workflow.md")
|
||||
const anotherWorkflow = response.commands.find((cmd) => cmd.name === "another-workflow")
|
||||
anotherWorkflow!.should.not.be.undefined()
|
||||
})
|
||||
|
||||
@@ -103,10 +107,10 @@ describe("getAvailableSlashCommands", () => {
|
||||
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
const enabled = response.commands.find((cmd) => cmd.name === "enabled-workflow.md")
|
||||
const enabled = response.commands.find((cmd) => cmd.name === "enabled-workflow")
|
||||
enabled!.should.not.be.undefined()
|
||||
|
||||
const disabled = response.commands.find((cmd) => cmd.name === "disabled-workflow.md")
|
||||
const disabled = response.commands.find((cmd) => cmd.name === "disabled-workflow")
|
||||
;(disabled === undefined).should.be.true()
|
||||
})
|
||||
|
||||
@@ -117,7 +121,7 @@ describe("getAvailableSlashCommands", () => {
|
||||
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
const workflow = response.commands.find((cmd) => cmd.name === "deep-analysis.md")
|
||||
const workflow = response.commands.find((cmd) => cmd.name === "deep-analysis")
|
||||
workflow!.should.not.be.undefined()
|
||||
})
|
||||
|
||||
@@ -128,7 +132,7 @@ describe("getAvailableSlashCommands", () => {
|
||||
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
const workflow = response.commands.find((cmd) => cmd.name === "windows-workflow.md")
|
||||
const workflow = response.commands.find((cmd) => cmd.name === "windows-workflow")
|
||||
workflow!.should.not.be.undefined()
|
||||
})
|
||||
})
|
||||
@@ -141,7 +145,7 @@ describe("getAvailableSlashCommands", () => {
|
||||
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
const workflow = response.commands.find((cmd) => cmd.name === "global-workflow.md")
|
||||
const workflow = response.commands.find((cmd) => cmd.name === "global-workflow")
|
||||
workflow!.should.not.be.undefined()
|
||||
workflow!.section.should.equal("custom")
|
||||
})
|
||||
@@ -153,7 +157,7 @@ describe("getAvailableSlashCommands", () => {
|
||||
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
const workflow = response.commands.find((cmd) => cmd.name === "disabled-global.md")
|
||||
const workflow = response.commands.find((cmd) => cmd.name === "disabled-global")
|
||||
;(workflow === undefined).should.be.true()
|
||||
})
|
||||
})
|
||||
@@ -171,7 +175,7 @@ describe("getAvailableSlashCommands", () => {
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
// Should only appear once
|
||||
const matches = response.commands.filter((cmd) => cmd.name === "shared-workflow.md")
|
||||
const matches = response.commands.filter((cmd) => cmd.name === "shared-workflow")
|
||||
matches.length.should.equal(1)
|
||||
})
|
||||
|
||||
@@ -186,7 +190,7 @@ describe("getAvailableSlashCommands", () => {
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
// Global should appear since local is disabled
|
||||
const workflow = response.commands.find((cmd) => cmd.name === "shared-workflow.md")
|
||||
const workflow = response.commands.find((cmd) => cmd.name === "shared-workflow")
|
||||
workflow!.should.not.be.undefined()
|
||||
})
|
||||
})
|
||||
@@ -282,4 +286,100 @@ describe("getAvailableSlashCommands", () => {
|
||||
response.commands.length.should.be.greaterThanOrEqual(BASE_SLASH_COMMANDS.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Skills", () => {
|
||||
beforeEach(() => {
|
||||
;(skillsUtils.discoverSkills as sinon.SinonStub).resolves([
|
||||
{
|
||||
name: "summarize-pr",
|
||||
description: "Summarize a pull request",
|
||||
path: "/Users/test/.cline/skills/summarize-pr/SKILL.md",
|
||||
source: "global",
|
||||
},
|
||||
{
|
||||
name: "project-planner",
|
||||
description: "Plan a project",
|
||||
path: "/workspace/.clinerules/skills/project-planner/SKILL.md",
|
||||
source: "project",
|
||||
},
|
||||
])
|
||||
;(skillsUtils.getAvailableSkills as sinon.SinonStub).callsFake((skills) => skills)
|
||||
})
|
||||
|
||||
it("should include enabled skills as slash commands", async () => {
|
||||
mockStateManager.getGlobalSettingsKey.withArgs("globalSkillsToggles").returns({
|
||||
"/Users/test/.cline/skills/summarize-pr/SKILL.md": true,
|
||||
})
|
||||
mockStateManager.getWorkspaceStateKey.withArgs("localSkillsToggles").returns({
|
||||
"/workspace/.clinerules/skills/project-planner/SKILL.md": true,
|
||||
})
|
||||
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
const globalSkill = response.commands.find((cmd) => cmd.name === "summarize-pr")
|
||||
globalSkill!.should.not.be.undefined()
|
||||
globalSkill!.section.should.equal("skill")
|
||||
|
||||
const localSkill = response.commands.find((cmd) => cmd.name === "project-planner")
|
||||
localSkill!.should.not.be.undefined()
|
||||
localSkill!.section.should.equal("skill")
|
||||
})
|
||||
|
||||
it("should prefer skill when skill and workflow names collide", async () => {
|
||||
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({
|
||||
"/path/to/summarize-pr.md": true,
|
||||
})
|
||||
mockStateManager.getGlobalSettingsKey.withArgs("globalSkillsToggles").returns({
|
||||
"/Users/test/.cline/skills/summarize-pr/SKILL.md": true,
|
||||
})
|
||||
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
const skillMatches = response.commands.filter((cmd) => cmd.name === "summarize-pr" && cmd.section === "skill")
|
||||
const workflowMatches = response.commands.filter((cmd) => cmd.name === "summarize-pr" && cmd.section === "custom")
|
||||
|
||||
skillMatches.length.should.equal(1)
|
||||
workflowMatches.length.should.equal(0)
|
||||
})
|
||||
|
||||
it("should exclude disabled skills", async () => {
|
||||
mockStateManager.getGlobalSettingsKey.withArgs("globalSkillsToggles").returns({
|
||||
"/Users/test/.cline/skills/summarize-pr/SKILL.md": false,
|
||||
})
|
||||
mockStateManager.getWorkspaceStateKey.withArgs("localSkillsToggles").returns({
|
||||
"/workspace/.clinerules/skills/project-planner/SKILL.md": false,
|
||||
})
|
||||
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
const skillCommands = response.commands.filter((cmd) => cmd.section === "skill")
|
||||
skillCommands.length.should.equal(0)
|
||||
})
|
||||
|
||||
it("should keep skill description unchanged when it overrides a workflow", async () => {
|
||||
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({
|
||||
"/path/to/summarize-pr.md": true,
|
||||
})
|
||||
mockStateManager.getGlobalSettingsKey.withArgs("globalSkillsToggles").returns({
|
||||
"/Users/test/.cline/skills/summarize-pr/SKILL.md": true,
|
||||
})
|
||||
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
const skill = response.commands.find((cmd) => cmd.name === "summarize-pr" && cmd.section === "skill")
|
||||
|
||||
skill?.description.should.equal("Summarize a pull request")
|
||||
})
|
||||
|
||||
it("should keep skill description unchanged when it overrides a remote workflow", async () => {
|
||||
mockStateManager.getRemoteConfigSettings.returns({
|
||||
remoteGlobalWorkflows: [{ name: "summarize-pr", alwaysEnabled: true }],
|
||||
})
|
||||
mockStateManager.getGlobalSettingsKey.withArgs("globalSkillsToggles").returns({
|
||||
"/Users/test/.cline/skills/summarize-pr/SKILL.md": true,
|
||||
})
|
||||
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
const skill = response.commands.find((cmd) => cmd.name === "summarize-pr" && cmd.section === "skill")
|
||||
|
||||
skill?.description.should.equal("Summarize a pull request")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PulsingBorder } from "@paper-design/shaders-react"
|
||||
import { mentionRegex, mentionRegexGlobal } from "@shared/context-mentions"
|
||||
import { StringRequest } from "@shared/proto/cline/common"
|
||||
import { FileSearchRequest, FileSearchType, RelativePathsRequest } from "@shared/proto/cline/file"
|
||||
import { EmptyRequest, StringRequest } from "@shared/proto/cline/common"
|
||||
import { FileSearchRequest, FileSearchType, RefreshedSkills, RelativePathsRequest, SkillInfo } from "@shared/proto/cline/file"
|
||||
import { PlanActMode, TogglePlanActModeRequest } from "@shared/proto/cline/state"
|
||||
import { type SlashCommand } from "@shared/slashCommands"
|
||||
import { Mode } from "@shared/storage/types"
|
||||
@@ -220,6 +220,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
apiConfiguration,
|
||||
openRouterModels,
|
||||
platform,
|
||||
globalSkillsToggles,
|
||||
localSkillsToggles,
|
||||
localWorkflowToggles,
|
||||
globalWorkflowToggles,
|
||||
remoteWorkflowToggles,
|
||||
@@ -237,6 +239,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
const [selectedSlashCommandsIndex, setSelectedSlashCommandsIndex] = useState(0)
|
||||
const [slashCommandsQuery, setSlashCommandsQuery] = useState("")
|
||||
const slashCommandsMenuContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [globalSkills, setGlobalSkills] = useState<SkillInfo[]>([])
|
||||
const [localSkills, setLocalSkills] = useState<SkillInfo[]>([])
|
||||
|
||||
const [thumbnailsHeight, setThumbnailsHeight] = useState(0)
|
||||
const [textAreaBaseHeight, setTextAreaBaseHeight] = useState<number | undefined>(undefined)
|
||||
@@ -331,6 +335,28 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
}
|
||||
}, [showSlashCommandsMenu])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
FileServiceClient.refreshSkills(EmptyRequest.create())
|
||||
.then((response: RefreshedSkills) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
setGlobalSkills(response.globalSkills || [])
|
||||
setLocalSkills(response.localSkills || [])
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setGlobalSkills([])
|
||||
setLocalSkills([])
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [globalSkillsToggles, localSkillsToggles])
|
||||
|
||||
const handleMentionSelect = useCallback(
|
||||
(type: ContextMenuOptionType, value?: string) => {
|
||||
if (type === ContextMenuOptionType.NoResults) {
|
||||
@@ -474,6 +500,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
remoteWorkflowToggles,
|
||||
remoteConfigSettings?.remoteGlobalWorkflows,
|
||||
mcpServers,
|
||||
globalSkills,
|
||||
localSkills,
|
||||
)
|
||||
|
||||
if (allCommands.length === 0) {
|
||||
@@ -499,6 +527,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
remoteWorkflowToggles,
|
||||
remoteConfigSettings?.remoteGlobalWorkflows,
|
||||
mcpServers,
|
||||
globalSkills,
|
||||
localSkills,
|
||||
)
|
||||
if (commands.length > 0) {
|
||||
handleSlashCommandsSelect(commands[selectedSlashCommandsIndex])
|
||||
@@ -658,6 +688,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
slashCommandsQuery,
|
||||
handleSlashCommandsSelect,
|
||||
sendingDisabled,
|
||||
globalSkills,
|
||||
localSkills,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -957,6 +989,9 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
globalWorkflowToggles,
|
||||
remoteWorkflowToggles,
|
||||
remoteConfigSettings?.remoteGlobalWorkflows,
|
||||
mcpServers,
|
||||
globalSkills,
|
||||
localSkills,
|
||||
)
|
||||
|
||||
if (isValidCommand) {
|
||||
@@ -970,7 +1005,15 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
highlightLayerRef.current.innerHTML = processedText
|
||||
highlightLayerRef.current.scrollTop = textAreaRef.current.scrollTop
|
||||
highlightLayerRef.current.scrollLeft = textAreaRef.current.scrollLeft
|
||||
}, [localWorkflowToggles, globalWorkflowToggles, remoteWorkflowToggles, remoteConfigSettings])
|
||||
}, [
|
||||
localWorkflowToggles,
|
||||
globalWorkflowToggles,
|
||||
remoteWorkflowToggles,
|
||||
remoteConfigSettings,
|
||||
mcpServers,
|
||||
globalSkills,
|
||||
localSkills,
|
||||
])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
updateHighlights()
|
||||
@@ -1388,7 +1431,9 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
{showSlashCommandsMenu && (
|
||||
<div ref={slashCommandsMenuContainerRef}>
|
||||
<SlashCommandMenu
|
||||
globalSkills={globalSkills}
|
||||
globalWorkflowToggles={globalWorkflowToggles}
|
||||
localSkills={localSkills}
|
||||
localWorkflowToggles={localWorkflowToggles}
|
||||
mcpServers={mcpServers}
|
||||
onMouseDown={handleMenuMouseDown}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { McpServer } from "@shared/mcp"
|
||||
import type { SlashCommand } from "@/utils/slash-commands"
|
||||
import { SkillInfo } from "@shared/proto/cline/file"
|
||||
import React, { useCallback, useEffect, useRef } from "react"
|
||||
import ScreenReaderAnnounce from "@/components/common/ScreenReaderAnnounce"
|
||||
import { useMenuAnnouncement } from "@/hooks/useMenuAnnouncement"
|
||||
import type { SlashCommand } from "@/utils/slash-commands"
|
||||
import { getMatchingSlashCommands } from "@/utils/slash-commands"
|
||||
|
||||
interface SlashCommandMenuProps {
|
||||
@@ -16,6 +17,8 @@ interface SlashCommandMenuProps {
|
||||
remoteWorkflowToggles?: Record<string, boolean>
|
||||
remoteWorkflows?: any[]
|
||||
mcpServers?: McpServer[]
|
||||
globalSkills?: SkillInfo[]
|
||||
localSkills?: SkillInfo[]
|
||||
}
|
||||
|
||||
const SlashCommandMenu: React.FC<SlashCommandMenuProps> = ({
|
||||
@@ -29,6 +32,8 @@ const SlashCommandMenu: React.FC<SlashCommandMenuProps> = ({
|
||||
remoteWorkflowToggles,
|
||||
remoteWorkflows,
|
||||
mcpServers = [],
|
||||
globalSkills = [],
|
||||
localSkills = [],
|
||||
}) => {
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -40,9 +45,12 @@ const SlashCommandMenu: React.FC<SlashCommandMenuProps> = ({
|
||||
remoteWorkflowToggles,
|
||||
remoteWorkflows,
|
||||
mcpServers,
|
||||
globalSkills,
|
||||
localSkills,
|
||||
)
|
||||
const defaultCommands = filteredCommands.filter((cmd) => cmd.section === "default" || !cmd.section)
|
||||
const workflowCommands = filteredCommands.filter((cmd) => cmd.section === "custom")
|
||||
const skillCommands = filteredCommands.filter((cmd) => cmd.section === "skill")
|
||||
const mcpCommands = filteredCommands.filter((cmd) => cmd.section === "mcp")
|
||||
|
||||
// Screen reader announcements
|
||||
@@ -95,6 +103,14 @@ const SlashCommandMenu: React.FC<SlashCommandMenuProps> = ({
|
||||
</div>
|
||||
{commands.map((command, index) => {
|
||||
const itemIndex = index + indexOffset
|
||||
const typeLabel =
|
||||
command.section === "skill"
|
||||
? "Skill"
|
||||
: command.section === "custom"
|
||||
? "Workflow"
|
||||
: command.section === "mcp"
|
||||
? "MCP"
|
||||
: ""
|
||||
return (
|
||||
<div
|
||||
aria-selected={itemIndex === selectedIndex}
|
||||
@@ -108,8 +124,13 @@ const SlashCommandMenu: React.FC<SlashCommandMenuProps> = ({
|
||||
onClick={() => handleClick(command)}
|
||||
onMouseEnter={() => setSelectedIndex(itemIndex)}
|
||||
role="option">
|
||||
<div className="font-bold whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
<div className="font-bold whitespace-nowrap overflow-hidden text-ellipsis flex items-center gap-2">
|
||||
<span className="ph-no-capture">/{command.name}</span>
|
||||
{typeLabel && (
|
||||
<span className="text-[0.75em] font-medium text-(--vscode-descriptionForeground)">
|
||||
[{typeLabel}]
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{showDescriptions && command.description && (
|
||||
<div className="text-[0.85em] text-(--vscode-descriptionForeground) whitespace-normal overflow-hidden text-ellipsis">
|
||||
@@ -139,11 +160,17 @@ const SlashCommandMenu: React.FC<SlashCommandMenuProps> = ({
|
||||
{filteredCommands.length > 0 ? (
|
||||
<>
|
||||
{renderCommandSection(defaultCommands, "Default Commands", 0, true)}
|
||||
{renderCommandSection(workflowCommands, "Workflow Commands", defaultCommands.length, false)}
|
||||
{renderCommandSection(skillCommands, "Skill Commands", defaultCommands.length, true)}
|
||||
{renderCommandSection(
|
||||
workflowCommands,
|
||||
"Workflow Commands",
|
||||
defaultCommands.length + skillCommands.length,
|
||||
true,
|
||||
)}
|
||||
{renderCommandSection(
|
||||
mcpCommands,
|
||||
"MCP Prompts",
|
||||
defaultCommands.length + workflowCommands.length,
|
||||
defaultCommands.length + skillCommands.length + workflowCommands.length,
|
||||
true,
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { McpServer } from "@shared/mcp"
|
||||
import { SkillInfo } from "@shared/proto/cline/file"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { getMatchingSlashCommands, getMcpPromptCommands, slashCommandRegex, validateSlashCommand } from "../slash-commands"
|
||||
import {
|
||||
getMatchingSlashCommands,
|
||||
getMcpPromptCommands,
|
||||
getSkillCommands,
|
||||
getWorkflowCommands,
|
||||
slashCommandRegex,
|
||||
validateSlashCommand,
|
||||
} from "../slash-commands"
|
||||
|
||||
// Helper to create a mock MCP server
|
||||
function createMockMcpServer(overrides: Partial<McpServer> = {}): McpServer {
|
||||
@@ -17,6 +25,53 @@ function createMockMcpServer(overrides: Partial<McpServer> = {}): McpServer {
|
||||
}
|
||||
|
||||
describe("slash-commands", () => {
|
||||
describe("workflow and skill command helpers", () => {
|
||||
it("should strip markdown extension from workflow command names", () => {
|
||||
const commands = getWorkflowCommands(
|
||||
{
|
||||
"/tmp/.clinerules/workflows/pr-review.md": true,
|
||||
},
|
||||
{},
|
||||
)
|
||||
|
||||
expect(commands).toHaveLength(1)
|
||||
expect(commands[0].name).toBe("pr-review")
|
||||
})
|
||||
|
||||
it("should strip txt extension from workflow command names", () => {
|
||||
const commands = getWorkflowCommands(
|
||||
{
|
||||
"/tmp/.clinerules/workflows/release-checklist.txt": true,
|
||||
},
|
||||
{},
|
||||
)
|
||||
|
||||
expect(commands).toHaveLength(1)
|
||||
expect(commands[0].name).toBe("release-checklist")
|
||||
})
|
||||
|
||||
it("should build skill commands and keep global override by name", () => {
|
||||
const localSkill = SkillInfo.create({
|
||||
name: "deploy",
|
||||
description: "Local deploy skill",
|
||||
path: "/workspace/.clinerules/skills/deploy/SKILL.md",
|
||||
enabled: true,
|
||||
})
|
||||
const globalSkill = SkillInfo.create({
|
||||
name: "deploy",
|
||||
description: "Global deploy skill",
|
||||
path: "/home/user/.cline/skills/deploy/SKILL.md",
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
const commands = getSkillCommands([globalSkill], [localSkill])
|
||||
expect(commands).toHaveLength(1)
|
||||
expect(commands[0].name).toBe("deploy")
|
||||
expect(commands[0].description).toBe("Global deploy skill")
|
||||
expect(commands[0].section).toBe("skill")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getMcpPromptCommands", () => {
|
||||
it("should return empty array when no servers provided", () => {
|
||||
const result = getMcpPromptCommands([])
|
||||
@@ -174,6 +229,90 @@ describe("slash-commands", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("skill precedence in command matching", () => {
|
||||
it("should prefer skill command over workflow when names collide", () => {
|
||||
const globalSkills = [
|
||||
SkillInfo.create({
|
||||
name: "pr-review",
|
||||
description: "Skill PR review",
|
||||
path: "/home/user/.cline/skills/pr-review/SKILL.md",
|
||||
enabled: true,
|
||||
}),
|
||||
]
|
||||
|
||||
const commands = getMatchingSlashCommands(
|
||||
"",
|
||||
{
|
||||
"/workspace/.clinerules/workflows/pr-review.md": true,
|
||||
},
|
||||
{},
|
||||
undefined,
|
||||
undefined,
|
||||
[],
|
||||
globalSkills,
|
||||
[],
|
||||
)
|
||||
|
||||
const skillMatches = commands.filter((c) => c.name === "pr-review" && c.section === "skill")
|
||||
const workflowMatches = commands.filter((c) => c.name === "pr-review" && c.section === "custom")
|
||||
|
||||
expect(skillMatches).toHaveLength(1)
|
||||
expect(workflowMatches).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should keep skill description unchanged when it overrides a workflow", () => {
|
||||
const globalSkills = [
|
||||
SkillInfo.create({
|
||||
name: "pr-review",
|
||||
description: "Skill PR review",
|
||||
path: "/home/user/.cline/skills/pr-review/SKILL.md",
|
||||
enabled: true,
|
||||
}),
|
||||
]
|
||||
|
||||
const commands = getMatchingSlashCommands(
|
||||
"",
|
||||
{
|
||||
"/workspace/.clinerules/workflows/pr-review.md": true,
|
||||
},
|
||||
{},
|
||||
undefined,
|
||||
undefined,
|
||||
[],
|
||||
globalSkills,
|
||||
[],
|
||||
)
|
||||
const skill = commands.find((c) => c.name === "pr-review" && c.section === "skill")
|
||||
|
||||
expect(skill?.description).toBe("Skill PR review")
|
||||
})
|
||||
|
||||
it("should keep skill description unchanged when it overrides a remote workflow", () => {
|
||||
const globalSkills = [
|
||||
SkillInfo.create({
|
||||
name: "pr-review",
|
||||
description: "Skill PR review",
|
||||
path: "/home/user/.cline/skills/pr-review/SKILL.md",
|
||||
enabled: true,
|
||||
}),
|
||||
]
|
||||
|
||||
const commands = getMatchingSlashCommands(
|
||||
"",
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
[{ name: "pr-review", alwaysEnabled: true }],
|
||||
[],
|
||||
globalSkills,
|
||||
[],
|
||||
)
|
||||
const skill = commands.find((c) => c.name === "pr-review" && c.section === "skill")
|
||||
|
||||
expect(skill?.description).toBe("Skill PR review")
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateSlashCommand with MCP servers", () => {
|
||||
const mcpServers = [
|
||||
createMockMcpServer({
|
||||
@@ -203,6 +342,22 @@ describe("slash-commands", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateSlashCommand with skills", () => {
|
||||
it("should validate skill command as full match", () => {
|
||||
const globalSkills = [
|
||||
SkillInfo.create({
|
||||
name: "summarize-pr",
|
||||
description: "Summarize PR",
|
||||
path: "/home/user/.cline/skills/summarize-pr/SKILL.md",
|
||||
enabled: true,
|
||||
}),
|
||||
]
|
||||
|
||||
const result = validateSlashCommand("summarize-pr", {}, {}, undefined, undefined, [], globalSkills, [])
|
||||
expect(result).toBe("full")
|
||||
})
|
||||
})
|
||||
|
||||
describe("slashCommandRegex with MCP format", () => {
|
||||
it("should match MCP command format with colons", () => {
|
||||
const text = "/mcp:server:prompt"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { McpServer } from "@shared/mcp"
|
||||
import { SkillInfo } from "@shared/proto/cline/file"
|
||||
import { toWorkflowCommandName } from "@shared/slash-command-names"
|
||||
import { PLATFORM_CONFIG, PlatformType } from "@/config/platform.config"
|
||||
import { BASE_SLASH_COMMANDS, type SlashCommand, VSCODE_ONLY_COMMANDS } from "../../../src/shared/slashCommands.ts"
|
||||
|
||||
@@ -17,11 +19,12 @@ export function getWorkflowCommands(
|
||||
.filter(([_, enabled]) => enabled)
|
||||
.reduce(
|
||||
(acc, [filePath, _]) => {
|
||||
const fileName = filePath.replace(/^.*[/\\]/, "")
|
||||
const fileName = toWorkflowCommandName(filePath)
|
||||
|
||||
// Add to array of workflows
|
||||
acc.workflows.push({
|
||||
name: fileName,
|
||||
description: "Workflow command",
|
||||
section: "custom",
|
||||
} as SlashCommand)
|
||||
|
||||
@@ -36,7 +39,7 @@ export function getWorkflowCommands(
|
||||
const globalWorkflows = Object.entries(globalWorkflowToggles)
|
||||
.filter(([_, enabled]) => enabled)
|
||||
.flatMap(([filePath, _]) => {
|
||||
const fileName = filePath.replace(/^.*[/\\]/, "")
|
||||
const fileName = toWorkflowCommandName(filePath)
|
||||
|
||||
// skip if a local workflow with the same name exists
|
||||
if (localWorkflowNames.has(fileName)) {
|
||||
@@ -46,6 +49,7 @@ export function getWorkflowCommands(
|
||||
return [
|
||||
{
|
||||
name: fileName,
|
||||
description: "Workflow command",
|
||||
section: "custom",
|
||||
},
|
||||
] as SlashCommand[]
|
||||
@@ -59,7 +63,8 @@ export function getWorkflowCommands(
|
||||
const enabled = workflow.alwaysEnabled || remoteWorkflowToggles[workflow.name] !== false
|
||||
if (enabled) {
|
||||
remoteWorkflowCommands.push({
|
||||
name: workflow.name,
|
||||
name: toWorkflowCommandName(workflow.name),
|
||||
description: "Workflow command",
|
||||
section: "custom",
|
||||
})
|
||||
}
|
||||
@@ -70,6 +75,40 @@ export function getWorkflowCommands(
|
||||
return workflows
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets enabled skill commands from discovered skills.
|
||||
* Global skills override local skills with the same name.
|
||||
*/
|
||||
export function getSkillCommands(globalSkills: SkillInfo[] = [], localSkills: SkillInfo[] = []): SlashCommand[] {
|
||||
const commandsByName = new Map<string, SlashCommand>()
|
||||
|
||||
for (const skill of localSkills) {
|
||||
if (skill.enabled === false) {
|
||||
continue
|
||||
}
|
||||
|
||||
commandsByName.set(skill.name, {
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
section: "skill",
|
||||
})
|
||||
}
|
||||
|
||||
for (const skill of globalSkills) {
|
||||
if (skill.enabled === false) {
|
||||
continue
|
||||
}
|
||||
|
||||
commandsByName.set(skill.name, {
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
section: "skill",
|
||||
})
|
||||
}
|
||||
|
||||
return Array.from(commandsByName.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets MCP prompt commands from connected MCP servers
|
||||
* Format: mcp:<server-name>:<prompt-name>
|
||||
@@ -181,6 +220,8 @@ export function getMatchingSlashCommands(
|
||||
remoteWorkflowToggles?: Record<string, boolean>,
|
||||
remoteWorkflows?: any[],
|
||||
mcpServers: McpServer[] = [],
|
||||
globalSkills: SkillInfo[] = [],
|
||||
localSkills: SkillInfo[] = [],
|
||||
): SlashCommand[] {
|
||||
const workflowCommands = getWorkflowCommands(
|
||||
localWorkflowToggles,
|
||||
@@ -188,8 +229,11 @@ export function getMatchingSlashCommands(
|
||||
remoteWorkflowToggles,
|
||||
remoteWorkflows,
|
||||
)
|
||||
const skillCommands = getSkillCommands(globalSkills, localSkills)
|
||||
const skillNames = new Set(skillCommands.map((command) => command.name))
|
||||
const filteredWorkflowCommands = workflowCommands.filter((workflow) => !skillNames.has(workflow.name))
|
||||
const mcpPromptCommands = getMcpPromptCommands(mcpServers)
|
||||
const allCommands = [...DEFAULT_SLASH_COMMANDS, ...workflowCommands, ...mcpPromptCommands]
|
||||
const allCommands = [...DEFAULT_SLASH_COMMANDS, ...skillCommands, ...filteredWorkflowCommands, ...mcpPromptCommands]
|
||||
|
||||
if (!query) {
|
||||
return allCommands
|
||||
@@ -233,6 +277,8 @@ export function validateSlashCommand(
|
||||
remoteWorkflowToggles?: Record<string, boolean>,
|
||||
remoteWorkflows?: any[],
|
||||
mcpServers: McpServer[] = [],
|
||||
globalSkills: SkillInfo[] = [],
|
||||
localSkills: SkillInfo[] = [],
|
||||
): "full" | "partial" | null {
|
||||
if (!command) {
|
||||
return null
|
||||
@@ -244,8 +290,11 @@ export function validateSlashCommand(
|
||||
remoteWorkflowToggles,
|
||||
remoteWorkflows,
|
||||
)
|
||||
const skillCommands = getSkillCommands(globalSkills, localSkills)
|
||||
const skillNames = new Set(skillCommands.map((skill) => skill.name))
|
||||
const filteredWorkflowCommands = workflowCommands.filter((workflow) => !skillNames.has(workflow.name))
|
||||
const mcpPromptCommands = getMcpPromptCommands(mcpServers)
|
||||
const allCommands = [...DEFAULT_SLASH_COMMANDS, ...workflowCommands, ...mcpPromptCommands]
|
||||
const allCommands = [...DEFAULT_SLASH_COMMANDS, ...skillCommands, ...filteredWorkflowCommands, ...mcpPromptCommands]
|
||||
|
||||
// case insensitive matching
|
||||
const exactMatch = allCommands.some((cmd) => cmd.name.toLowerCase() === command.toLowerCase())
|
||||
|
||||
Reference in New Issue
Block a user