Compare commits

...

3 Commits

Author SHA1 Message Date
Robin Newhouse 2f1770375e test: fix stale skill description expectations 2026-02-12 17:31:42 -06:00
Robin Newhouse 60bb1a439d feat(slash): add direct skill commands with skill-first precedence 2026-02-12 16:43:45 -06:00
Robin Newhouse 3cb8c82b75 docs: clarify skill slash commands and workflow precedence 2026-02-12 16:43:44 -06:00
22 changed files with 785 additions and 72 deletions
+17 -3
View File
@@ -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)
+34
View File
@@ -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("")
})
})
})
+18 -4
View File
@@ -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 }
+13 -1
View File
@@ -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.
+1 -1
View File
@@ -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
+16 -3
View File
@@ -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")
})
})
})
+39 -7
View File
@@ -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 {
+20
View File
@@ -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) {
+6 -2
View File
@@ -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: {
+19
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
export interface SlashCommand {
name: string
description?: string
section?: "default" | "custom" | "mcp"
section?: "default" | "custom" | "mcp" | "skill"
cliCompatible?: boolean
}
+33
View File
@@ -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
View File
@@ -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"
+54 -5
View File
@@ -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())