Compare commits

...
51 changed files with 145 additions and 1357 deletions
+1
View File
@@ -15,6 +15,7 @@
### Changed
- Polish `Notification` hook functionality
- Remove the workflows feature; migrate recurring guidance to rules, skills, hooks, or MCP prompts
## [3.76.0]
-11
View File
@@ -68,10 +68,6 @@ interface AppProps {
localWindsurfRulesToggles?: Record<string, boolean>
localAgentsRulesToggles?: Record<string, boolean>
onToggleRule?: (isGlobal: boolean, rulePath: string, enabled: boolean, ruleType: string) => void
// Workflow toggles
globalWorkflowToggles?: Record<string, boolean>
localWorkflowToggles?: Record<string, boolean>
onToggleWorkflow?: (isGlobal: boolean, workflowPath: string, enabled: boolean) => void
// Hooks
hooksEnabled?: boolean
globalHooks?: HookInfo[]
@@ -123,10 +119,6 @@ const InternalApp: React.FC<AppProps> = ({
localWindsurfRulesToggles,
localAgentsRulesToggles,
onToggleRule,
// Workflows
globalWorkflowToggles,
localWorkflowToggles,
onToggleWorkflow,
// Hooks
hooksEnabled,
globalHooks,
@@ -225,18 +217,15 @@ const InternalApp: React.FC<AppProps> = ({
globalHooks={globalHooks}
globalSkills={globalSkills}
globalState={globalState}
globalWorkflowToggles={globalWorkflowToggles}
hooksEnabled={hooksEnabled}
localAgentsRulesToggles={localAgentsRulesToggles}
localClineRulesToggles={localClineRulesToggles}
localCursorRulesToggles={localCursorRulesToggles}
localSkills={localSkills}
localWindsurfRulesToggles={localWindsurfRulesToggles}
localWorkflowToggles={localWorkflowToggles}
onToggleHook={onToggleHook}
onToggleRule={onToggleRule}
onToggleSkill={onToggleSkill}
onToggleWorkflow={onToggleWorkflow}
skillsEnabled={skillsEnabled}
workspaceHooks={workspaceHooks}
workspaceState={workspaceState}
-1
View File
@@ -133,7 +133,6 @@ vi.mock("../utils/slash-commands", async (importOriginal) => {
extractSlashQuery: vi.fn(() => ({ inSlashMode: false, query: "", slashIndex: -1 })),
filterCommands: vi.fn(() => []),
insertSlashCommand: vi.fn((text: string) => text),
sortCommandsWorkflowsFirst: vi.fn((cmds: unknown[]) => cmds),
}
})
+1 -2
View File
@@ -142,7 +142,6 @@ import {
filterCommands,
getStandaloneSlashCommandToExecute,
insertSlashCommand,
sortCommandsWorkflowsFirst,
} from "../utils/slash-commands"
import { waitFor } from "../utils/timeout"
import { isFileEditTool, parseToolFromMessage } from "../utils/tools"
@@ -626,7 +625,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
// fetch completes. This avoids a race that can make the quit command tests
// flaky on slower Windows CI runners.
const cliOnlyCommands = createCliOnlySlashCommands()
setAvailableCommands([...cliOnlyCommands, ...sortCommandsWorkflowsFirst(cliCommands)])
setAvailableCommands([...cliOnlyCommands, ...cliCommands])
} catch {
// Keep CLI-only commands available even if backend command loading fails.
}
+7 -67
View File
@@ -1,6 +1,6 @@
/**
* Interactive config view component for displaying and editing configuration values
* Supports tabs for Settings, Rules, Workflows, Hooks, and Skills
* Supports tabs for Settings, Rules, Hooks, and Skills
*/
import {
@@ -55,10 +55,6 @@ interface ConfigViewProps {
localWindsurfRulesToggles?: Record<string, boolean>
localAgentsRulesToggles?: Record<string, boolean>
onToggleRule?: (isGlobal: boolean, rulePath: string, enabled: boolean, ruleType: string) => void
// Workflow toggles
globalWorkflowToggles?: Record<string, boolean>
localWorkflowToggles?: Record<string, boolean>
onToggleWorkflow?: (isGlobal: boolean, workflowPath: string, enabled: boolean) => void
// Hooks
hooksEnabled?: boolean
globalHooks?: HookInfo[]
@@ -70,7 +66,7 @@ interface ConfigViewProps {
localSkills?: SkillInfo[]
onToggleSkill?: (isGlobal: boolean, skillPath: string, enabled: boolean) => void
// Open folder callback
onOpenFolder?: (folderType: "rules" | "workflows" | "hooks" | "skills", isGlobal: boolean) => void
onOpenFolder?: (folderType: "rules" | "hooks" | "skills", isGlobal: boolean) => void
}
// ============================================================================
@@ -89,9 +85,6 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
localWindsurfRulesToggles,
localAgentsRulesToggles,
onToggleRule,
globalWorkflowToggles,
localWorkflowToggles,
onToggleWorkflow,
hooksEnabled,
globalHooks = [],
workspaceHooks = [],
@@ -141,14 +134,6 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
localAgentsRulesToggles,
])
// Build entries for workflows tab
const workflowEntries = useMemo(() => {
const entries: ToggleEntry[] = []
entries.push(...buildToggleEntries(globalWorkflowToggles, "global"))
entries.push(...buildToggleEntries(localWorkflowToggles, "workspace"))
return entries
}, [globalWorkflowToggles, localWorkflowToggles])
// Build flat list of hooks
const hookEntries = useMemo(() => {
const entries: { hook: HookInfo; isGlobal: boolean; workspaceName?: string }[] = []
@@ -174,8 +159,6 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
return filteredConfigEntries.length
case "rules":
return ruleEntries.length
case "workflows":
return workflowEntries.length
case "hooks":
return hookEntries.length
case "skills":
@@ -183,14 +166,7 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
default:
return 0
}
}, [
currentTab,
filteredConfigEntries.length,
ruleEntries.length,
workflowEntries.length,
hookEntries.length,
skillEntries.length,
])
}, [currentTab, filteredConfigEntries.length, ruleEntries.length, hookEntries.length, skillEntries.length])
// Get available tabs
const availableTabs = useMemo(() => {
@@ -277,14 +253,11 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
}
}
// Toggle handlers for rules/workflows/hooks/skills
// Toggle handlers for rules/hooks/skills
const handleToggle = () => {
if (currentTab === "rules" && ruleEntries[selectedIndex] && onToggleRule) {
const entry = ruleEntries[selectedIndex]
onToggleRule(entry.source === "global", entry.path, !entry.enabled, entry.ruleType || "cline")
} else if (currentTab === "workflows" && workflowEntries[selectedIndex] && onToggleWorkflow) {
const entry = workflowEntries[selectedIndex]
onToggleWorkflow(entry.source === "global", entry.path, !entry.enabled)
} else if (currentTab === "hooks" && hookEntries[selectedIndex] && onToggleHook) {
const entry = hookEntries[selectedIndex]
onToggleHook(entry.isGlobal, entry.hook.name, !entry.hook.enabled, entry.workspaceName)
@@ -359,24 +332,22 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
setSearchQuery((prev) => prev + input)
}
} else if (key.return || key.tab || input === " ") {
// Toggle for rules/workflows/hooks/skills
// Toggle for rules/hooks/skills
handleToggle()
}
// Open folder (for rules/workflows/hooks/skills tabs)
// Open folder (for rules/hooks/skills tabs)
if (input === "o" && onOpenFolder && currentTab !== "settings") {
// Determine if current selection is global or workspace based on the selected entry
let isGlobal = true
if (currentTab === "rules" && ruleEntries[selectedIndex]) {
isGlobal = ruleEntries[selectedIndex].source === "global"
} else if (currentTab === "workflows" && workflowEntries[selectedIndex]) {
isGlobal = workflowEntries[selectedIndex].source === "global"
} else if (currentTab === "hooks" && hookEntries[selectedIndex]) {
isGlobal = hookEntries[selectedIndex].isGlobal
} else if (currentTab === "skills" && skillEntries[selectedIndex]) {
isGlobal = skillEntries[selectedIndex].isGlobal
}
onOpenFolder(currentTab as "rules" | "workflows" | "hooks" | "skills", isGlobal)
onOpenFolder(currentTab as "rules" | "hooks" | "skills", isGlobal)
}
},
{ isActive: isRawModeSupported && !isEditing },
@@ -511,37 +482,6 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
)
}
case "workflows": {
if (workflowEntries.length === 0) {
return (
<Box>
<Text color="gray">No workflows configured. Add workflow files to enable this feature.</Text>
</Box>
)
}
const visibleEntries = workflowEntries.slice(startIndex, startIndex + MAX_VISIBLE)
return (
<Box flexDirection="column">
{visibleEntries.map((entry, idx) => {
const actualIndex = startIndex + idx
const prevEntry = visibleEntries[idx - 1]
const showHeader = !prevEntry || prevEntry.source !== entry.source
return (
<React.Fragment key={`${entry.source}-${entry.path}`}>
{showHeader && (
<SectionHeader
title={entry.source === "global" ? "Global Workflows:" : "Workspace Workflows:"}
/>
)}
<ToggleRow entry={entry} isSelected={actualIndex === selectedIndex} />
</React.Fragment>
)
})}
</Box>
)
}
case "hooks": {
if (hookEntries.length === 0) {
return (
+1 -2
View File
@@ -11,7 +11,7 @@ import { useStdinContext } from "../context/StdinContext"
// ============================================================================
export type ValueType = "string" | "number" | "boolean" | "object" | "undefined"
export type TabView = "settings" | "rules" | "workflows" | "hooks" | "skills"
export type TabView = "settings" | "rules" | "hooks" | "skills"
export interface ConfigEntry {
key: string
@@ -71,7 +71,6 @@ export const SEPARATOR = "─".repeat(80)
export const TABS: { key: TabView; label: string; requiresFlag?: "hooks" | "skills" }[] = [
{ key: "settings", label: "Settings" },
{ key: "rules", label: "Rules" },
{ key: "workflows", label: "Workflows" },
{ key: "hooks", label: "Hooks", requiresFlag: "hooks" },
{ key: "skills", label: "Skills", requiresFlag: "skills" },
]
+2 -28
View File
@@ -63,10 +63,6 @@ export const ConfigViewWrapper: React.FC<ConfigViewWrapperProps> = ({
const [localWindsurfRulesToggles, setLocalWindsurfRulesToggles] = useState<Record<string, boolean>>({})
const [localAgentsRulesToggles, setLocalAgentsRulesToggles] = useState<Record<string, boolean>>({})
// Workflow state
const [globalWorkflowToggles, setGlobalWorkflowToggles] = useState<Record<string, boolean>>({})
const [localWorkflowToggles, setLocalWorkflowToggles] = useState<Record<string, boolean>>({})
// Hooks state
const [globalHooks, setGlobalHooks] = useState<HookInfo[]>([])
const [workspaceHooksState, setWorkspaceHooksState] = useState<WorkspaceHooks[]>([])
@@ -88,8 +84,6 @@ export const ConfigViewWrapper: React.FC<ConfigViewWrapperProps> = ({
setLocalCursorRulesToggles(rulesData.localCursorRulesToggles?.toggles || {})
setLocalWindsurfRulesToggles(rulesData.localWindsurfRulesToggles?.toggles || {})
setLocalAgentsRulesToggles(rulesData.localAgentsRulesToggles?.toggles || {})
setGlobalWorkflowToggles(rulesData.globalWorkflowToggles?.toggles || {})
setLocalWorkflowToggles(rulesData.localWorkflowToggles?.toggles || {})
if (hooksEnabled) {
const hooksData = await refreshHooks(controller, {})
@@ -146,23 +140,6 @@ export const ConfigViewWrapper: React.FC<ConfigViewWrapperProps> = ({
[controller],
)
const handleToggleWorkflow = useCallback(
async (isGlobal: boolean, workflowPath: string, enabled: boolean) => {
const { toggleWorkflow } = await import("@/core/controller/file/toggleWorkflow")
const scope = isGlobal ? RuleScope.GLOBAL : RuleScope.LOCAL
// Optimistic update
if (isGlobal) {
setGlobalWorkflowToggles((prev) => ({ ...prev, [workflowPath]: enabled }))
} else {
setLocalWorkflowToggles((prev) => ({ ...prev, [workflowPath]: enabled }))
}
await toggleWorkflow(controller, { metadata: undefined, workflowPath, enabled, scope })
},
[controller],
)
const handleToggleHook = useCallback(
async (isGlobal: boolean, hookName: string, enabled: boolean, workspaceName?: string) => {
const { toggleHook } = await import("@/core/controller/file/toggleHook")
@@ -206,7 +183,7 @@ export const ConfigViewWrapper: React.FC<ConfigViewWrapperProps> = ({
)
const handleOpenFolder = useCallback(
async (folderType: "rules" | "workflows" | "hooks" | "skills", isGlobal: boolean) => {
async (folderType: "rules" | "hooks" | "skills", isGlobal: boolean) => {
let folderPath: string
if (isGlobal) {
@@ -220,7 +197,7 @@ export const ConfigViewWrapper: React.FC<ConfigViewWrapperProps> = ({
if (!primaryWorkspace) {
return
}
// Local rules/workflows/hooks/skills are in .clinerules or .cline
// Local rules/hooks/skills are in .clinerules or .cline
const subFolder = folderType === "rules" ? "rules" : folderType
folderPath = path.join(primaryWorkspace, ".clinerules", subFolder)
}
@@ -277,19 +254,16 @@ export const ConfigViewWrapper: React.FC<ConfigViewWrapperProps> = ({
globalHooks={globalHooks}
globalSkills={globalSkills}
globalState={globalStateLocal}
globalWorkflowToggles={globalWorkflowToggles}
hooksEnabled={hooksEnabled}
localAgentsRulesToggles={localAgentsRulesToggles}
localClineRulesToggles={localClineRulesToggles}
localCursorRulesToggles={localCursorRulesToggles}
localSkills={localSkills}
localWindsurfRulesToggles={localWindsurfRulesToggles}
localWorkflowToggles={localWorkflowToggles}
onOpenFolder={handleOpenFolder}
onToggleHook={handleToggleHook}
onToggleRule={handleToggleRule}
onToggleSkill={handleToggleSkill}
onToggleWorkflow={handleToggleWorkflow}
onUpdateGlobal={handleUpdateGlobal}
onUpdateWorkspace={handleUpdateWorkspace}
skillsEnabled={skillsEnabled}
-7
View File
@@ -57,13 +57,6 @@ export function getVisibleWindow<T>(items: T[], selectedIndex: number, maxVisibl
return { items: items.slice(startIndex, endIndex), startIndex }
}
/**
* Sort commands with workflows (custom section) first, then default commands.
*/
export function sortCommandsWorkflowsFirst(commands: SlashCommandInfo[]): SlashCommandInfo[] {
return [...commands.filter((cmd) => cmd.section === "custom"), ...commands.filter((cmd) => cmd.section !== "custom")]
}
/**
* Extract slash command query from input text.
* Returns info about whether we're in slash mode and what the query is.
-1
View File
@@ -1,5 +1,4 @@
{
"workflowToggles": {},
"localClineRulesToggles": {},
"localWindsurfRulesToggles": {},
"localCursorRulesToggles": {},
-8
View File
@@ -36,14 +36,6 @@ Manage Cline rules that guide AI behavior:
Rules help Cline understand your project's conventions, coding standards, and preferences.
### Workflows Tab
View and manage [workflows](/customization/workflows):
- List available workflows
- View workflow definitions
- Workflows appear as slash commands in interactive mode
### Hooks Tab
Configure [hooks](/customization/hooks) for custom logic integration:
+1 -7
View File
@@ -98,13 +98,7 @@ Type `/` to see available commands. Slash commands provide quick access to setti
| `/help` | Show help and available commands |
| `/exit` | Exit the CLI |
### Workflow Commands
If you have [workflows](/customization/workflows) configured, they appear as additional slash commands. For example, if you have a workflow named `code-review`, you can invoke it with:
```text
/code-review
```
The slash-command menu also includes compatible built-in commands and any available MCP prompt commands returned by your configured servers.
## Settings Panel
+2 -2
View File
@@ -311,6 +311,6 @@ When using these templates:
4. **Test your own instructions.** Follow your guide from scratch to catch missing steps.
<Tip>
Use the `/write-docs` workflow to generate documentation from these templates automatically.
Cline helps you fill in each section based on your project.
Use these templates with Cline to generate documentation drafts.
Cline can help you fill in each section based on your project.
</Tip>
+5 -5
View File
@@ -6,16 +6,16 @@ description: "How to write and contribute to Cline documentation"
Cline's documentation lives in the `docs/` directory and uses [Mintlify](https://mintlify.com) for rendering. This guide covers how to write docs that match Cline's established style.
## Using the Documentation Workflow
## Using Cline to Draft Documentation
The fastest way to create documentation is using the `/write-docs` workflow. Type `/write-docs` in Cline and describe what you want to document. Cline guides you through a 4-step process:
The fastest way to create documentation is to ask Cline directly and use the templates in this guide. A practical 4-step process is:
1. **Research**: Examine existing docs structure and patterns
2. **Scope**: Clarify audience, doc type, and key use cases
3. **Outline**: Select a template and create structure
4. **Write**: Generate documentation following style guidelines
The workflow file lives at `.clinerules/workflows/write-docs.md` and contains templates, style rules, and examples.
The templates and examples in this guide support each of those steps.
## Documentation Principles
@@ -194,7 +194,7 @@ Open `http://localhost:3000` to see your changes in real time.
<Card title="Documentation Templates" icon="file-lines" href="/contributing/doc-templates">
Templates for different documentation types.
</Card>
<Card title="Workflows" icon="diagram-project" href="/customization/workflows">
Learn about Cline's workflow system.
<Card title="Customization Overview" icon="sliders" href="/customization/overview">
Understand how rules, skills, hooks, and .clineignore fit together.
</Card>
</CardGroup>
+1 -7
View File
@@ -1,7 +1,7 @@
---
title: "Using Commands"
sidebarTitle: "Using Commands"
description: "Built-in slash commands to manage context, plan implementations, and create reusable workflows."
description: "Built-in slash commands to manage context, plan implementations, and navigate key Cline actions."
---
Cline provides slash commands in chat that help you manage your conversation and plan complex implementations.
@@ -67,9 +67,3 @@ Use `/explain-changes` when reviewing code, onboarding to a new codebase, or und
`/reportbug` collects diagnostic information and helps you report issues with Cline. It gathers relevant context like your configuration, recent errors, and system details to make bug reports more useful for the development team.
Use `/reportbug` when you encounter unexpected behavior, crashes, or bugs you want to report.
## Custom Workflows
Beyond the built-in slash commands, you can create your own workflow files that work the same way. Store Markdown files in `.clinerules/workflows/` and invoke them with `/your-workflow.md`.
For a complete guide on creating and managing custom workflows, see [Workflows](/customization/workflows).
+6 -11
View File
@@ -1,12 +1,12 @@
---
title: "Overview"
sidebarTitle: "Overview"
description: "Understand how Rules, Skills, Workflows, Hooks, and .clineignore work together to customize Cline."
description: "Understand how Rules, Skills, Hooks, and .clineignore work together to customize Cline."
---
Out of the box, Cline is a general-purpose AI assistant. Customizations transform it into an expert on your codebase, your team's conventions, and your workflows. Instead of repeating the same instructions every task, you define them once and Cline follows them automatically.
Cline offers five systems for this: Rules, Skills, Workflows, Hooks, and .clineignore. Each serves a different purpose and activates at different times.
Cline offers four systems for this: Rules, Skills, Hooks, and .clineignore. Each serves a different purpose and activates at different times.
## Quick Comparison
@@ -14,7 +14,6 @@ Cline offers five systems for this: Rules, Skills, Workflows, Hooks, and .clinei
|---------|---------|-------------|----------|
| **[Rules](/customization/cline-rules)** | Define how Cline behaves | Always (or contextually) | Coding standards, project constraints, team conventions |
| **[Skills](/customization/skills)** | Domain expertise loaded on-demand | Triggered by matching requests | Specialized knowledge, complex procedures, institutional expertise |
| **[Workflows](/customization/workflows)** | Step-by-step task automation | Invoked with `/workflow.md` | Repetitive processes, release procedures, setup scripts |
| **[Hooks](/customization/hooks)** | Inject custom logic at key moments | Automatically on specific events | Validation, enforcement, monitoring, automation triggers |
| **[.clineignore](/customization/clineignore)** | Control file access | Always | Excluding dependencies, build artifacts, large data files |
@@ -24,31 +23,27 @@ Cline offers five systems for this: Rules, Skills, Workflows, Hooks, and .clinei
**[Skills](/customization/skills)** are domain expertise that loads only when relevant. Use them when you have extensive knowledge that would waste context if always active. Cline sees skill descriptions at startup and activates the full instructions only when your request matches. A data analysis skill might include pandas patterns, visualization preferences, and output formats that Cline only loads when you're working with data files.
**[Workflows](/customization/workflows)** are explicit task scripts you invoke on demand. Use them when you have a repeatable multi-step process that should run the same way every time. Type `/release.md` and Cline executes your release sequence: bump version, run tests, update changelog, commit, tag, push. Workflows define *what* to do, step by step.
**[Hooks](/customization/hooks)** are programmatic guardrails that run automatically at key moments. Use them when you need to validate, enforce, or extend Cline's behavior with custom code. A hook might block `.js` file creation in a TypeScript project, run linters before saves, or notify external services after deployments.
**[.clineignore](/customization/clineignore)** controls which files and directories Cline can access. Use it to exclude dependencies, build artifacts, generated files, and large data files from Cline's context. This reduces token usage, lowers costs, and keeps Cline focused on the code that matters. It works like `.gitignore`: add patterns to a `.clineignore` file in your project root and matching files are automatically excluded.
### Example: A Release Process
Consider how all five work together for releasing a new version:
Consider how these systems work together for releasing a new version:
1. **Rules** ensure Cline follows your team's commit message format and versioning policy
2. **Skills** offer deep knowledge about your CI/CD system that Cline loads when deployment questions arise
3. **Workflows** provide the explicit `/release.md` sequence: bump version, update changelog, tag, push
4. **Hooks** validate that tests pass before allowing any commit or that the changelog was actually updated
5. **.clineignore** keeps build artifacts, `node_modules/`, and generated files out of Cline's context so it stays focused
3. **Hooks** validate that tests pass before allowing any commit or that the changelog was actually updated
4. **.clineignore** keeps build artifacts, `node_modules/`, and generated files out of Cline's context so it stays focused
## Storage Locations
All five systems support both global and project-specific configurations:
These systems support both global and project-specific configurations where applicable:
| System | Global Location | Project Location |
|--------|-----------------|------------------|
| Rules | `~/Documents/Cline/Rules/` | `.clinerules/` |
| Skills | `~/.cline/skills/` | `.cline/skills/` |
| Workflows | `~/Documents/Cline/Workflows/` | `.clinerules/workflows/` |
| Hooks | `~/Documents/Cline/Hooks/` | `.clinerules/hooks/` |
| .clineignore | N/A | `.clineignore` |
-221
View File
@@ -1,221 +0,0 @@
---
title: "Workflows"
sidebarTitle: "Workflows"
description: "Automate repetitive tasks with Markdown-based workflow files."
---
Workflows are Markdown files that define a series of steps to guide Cline through repetitive or complex tasks. Type `/` followed by the workflow's filename to invoke it (e.g., `/deploy.md`).
Deploying, setting up a new project, running through a release checklist: these tasks often require remembering a dozen steps, running commands in the right order, and updating files manually. Mess up one step and you're debugging for an hour. Workflows turn those multi-step processes into one command. Type `/release.md` and Cline handles the version bump, runs tests, updates the changelog, commits, tags, and pushes. You just review and approve.
## Workflow Structure
A workflow is a markdown file with a title and steps. The filename becomes the command: `demo-workflow.md` is invoked with `/demo-workflow.md`.
````markdown title="demo-workflow.md"
# Demo Workflow
Brief description of what this workflow accomplishes.
## Step 1: Check prerequisites
Verify the environment is ready. Look for required tools and dependencies.
## Step 2: Run the build
Execute the build command:
```bash
npm run build
```
## Step 3: Verify results
Check that the build completed successfully and report any issues.
````
Steps can be written at different levels of detail:
- **High-level**: "Run the test suite and fix any failures" lets Cline decide how to accomplish the goal
- **Specific**: Use XML tool syntax or exact commands when you need precise control
## Creating Workflows
<Steps>
<Step title="Open the Workflows menu">
Click the scale icon at the bottom of the Cline panel, to the left of the model selector. Switch to the Workflows tab.
</Step>
<Step title="Create a new workflow file">
Click "New workflow file..." and enter a filename (e.g., `deploy`). The file will be created with a `.md` extension.
</Step>
<Step title="Write your workflow">
Add a title and numbered steps in markdown format. Describe what each step should accomplish.
</Step>
</Steps>
<Tip>
**Create workflows from completed tasks.** After finishing something you'll need to repeat, tell Cline: "Create a workflow for the process I just completed." Cline analyzes the conversation, identifies the steps, and generates the workflow file. Your accumulated context becomes reusable automation.
</Tip>
### Invoking Workflows
Type `/` in the chat input to see available workflows. Cline shows autocomplete suggestions as you type, so `/rel` would match `release-prep.md`. Select a workflow and press Enter to start it.
Cline executes each step in sequence, pausing for your approval when needed. You can stop a workflow at any point by rejecting a step.
### Toggling Workflows
Every workflow has a toggle to enable or disable it. This lets you control which workflows appear in the `/` menu without deleting the file.
## Where Workflows Live
Workflows can be stored in two locations: your project workspace or globally on your system.
**Workspace workflows** go in `.clinerules/workflows/` at your project root. Use these for project-specific automation like deployment scripts, release processes, or setup procedures that your team shares.
**Global workflows** go in your system's Cline Workflows directory. Use these for personal productivity workflows you use across all projects.
### Global Workflows Directory
| Operating System | Default Location |
|------------------|------------------|
| Windows | `Documents\Cline\Workflows` |
| macOS | `~/Documents/Cline/Workflows` |
| Linux/WSL | `~/Documents/Cline/Workflows` |
Workspace workflows take precedence when names match global workflows. See [Storage Locations](/customization/overview#storage-locations) for more guidance.
## What Workflows Can Use
Workflows can combine natural language instructions with specific tool calls. This flexibility lets you write workflows that are as simple or as precise as your task requires.
### Natural Language
Write steps as plain instructions. Cline interprets them and figures out which tools to use:
```markdown
## Step 1: Check for uncommitted changes
Look at the git status. If there are uncommitted changes, ask whether to continue or abort.
## Step 2: Run the test suite
Execute all tests. If any fail, show the failures and stop.
```
This approach works well when you want Cline to adapt to the situation rather than follow rigid steps.
### Cline Tools
For precise control, use Cline's built-in tools with XML syntax. This guarantees specific actions:
```xml
<execute_command>
<command>npm run test</command>
<requires_approval>false</requires_approval>
</execute_command>
```
```xml
<read_file>
<path>src/config.json</path>
</read_file>
```
```xml
<ask_followup_question>
<question>Deploy to production or staging?</question>
<options>["Production", "Staging", "Cancel"]</options>
</ask_followup_question>
```
See the full list in the [Cline Tools Reference](/tools-reference/all-cline-tools).
### CLI Tools
Reference any command-line tool installed on your machine. Git, npm, docker, gh, make, curl: whatever you have available.
```bash
git log --author="$(git config user.name)" --since="yesterday" --oneline
```
### MCP Tools
If you have [MCP servers](/mcp/mcp-overview) connected, use them in your workflows with the `use_mcp_tool` syntax. This lets you integrate with external services like GitHub, Slack, databases, or custom internal tools.
```xml
<use_mcp_tool>
<server_name>github-server</server_name>
<tool_name>create_release</tool_name>
<arguments>{"tag": "v1.2.0", "name": "Release v1.2.0", "body": "Changelog content here"}</arguments>
</use_mcp_tool>
```
Or describe the intent in natural language and let Cline figure out the tool call:
```markdown
## Step 3: Create GitHub release
Use the GitHub MCP server to create a release tagged with the version from package.json.
Include the changelog as the release body.
```
## Writing Effective Workflows
**Start simple.** Write natural language steps first. Only add XML tool calls when you need guaranteed behavior.
**Be specific about decisions.** If a step requires user input, make that explicit: "Ask whether to deploy to production or staging."
**Include failure handling.** Tell Cline what to do when something goes wrong: "If tests fail, show the failures and stop the workflow."
**Keep workflows focused.** A `deploy.md` should deploy. A `setup-db.md` should set up the database. Split complex processes into multiple workflows that can be run independently.
**Version control your workflows.** Store workflows in `.clinerules/workflows/` and commit them. Your team can share, review, and improve them together.
<Warning>
Workflows execute with your permissions. Review workflows before running them, especially those from external sources.
</Warning>
## Example: Release Preparation
This workflow automates the tedious pre-release checklist. It verifies your working directory is clean, runs tests and builds, prompts you for the version bump, and generates a changelog from recent commits.
The workflow demonstrates both approaches: XML tool syntax (`<execute_command>`, `<ask_followup_question>`) for steps that need precise control, and natural language for steps where Cline should adapt to the situation.
````markdown title="release-prep.md"
# Release Preparation
Prepare a new release by running tests, building, and updating version info.
## Step 1: Check for clean working directory
<execute_command>
<command>git status --porcelain</command>
</execute_command>
If there are uncommitted changes, ask whether to continue or stash them first.
## Step 2: Run the test suite
<execute_command>
<command>npm run test</command>
</execute_command>
If any tests fail, stop the workflow and report the failures.
## Step 3: Build the project
<execute_command>
<command>npm run build</command>
</execute_command>
Verify the build completes without errors.
## Step 4: Ask for new version
<ask_followup_question>
<question>What should the new version be?</question>
<options>["Patch (x.x.X)", "Minor (x.X.0)", "Major (X.0.0)", "Custom"]</options>
</ask_followup_question>
## Step 5: Update version
Update the version in `package.json` to the new version specified by the user.
## Step 6: Generate changelog entry
<execute_command>
<command>git log --oneline $(git describe --tags --abbrev=0)..HEAD</command>
</execute_command>
Use these commits to write a changelog entry for the new version.
````
Invoke it with `/release-prep.md` and Cline walks through each step.
-13
View File
@@ -91,7 +91,6 @@
"customization/overview",
"customization/cline-rules",
"customization/skills",
"customization/workflows",
"customization/hooks",
"customization/clineignore"
]
@@ -567,18 +566,6 @@
"source": "/features/slash-commands/new-task",
"destination": "/core-workflows/using-commands"
},
{
"source": "/features/slash-commands/workflows/index",
"destination": "/customization/workflows"
},
{
"source": "/features/slash-commands/workflows/quickstart",
"destination": "/customization/workflows"
},
{
"source": "/features/slash-commands/workflows/best-practices",
"destination": "/customization/workflows"
},
{
"source": "/exploring-clines-tools/cline-tools-guide",
"destination": "/tools-reference/all-cline-tools"
+1 -1
View File
@@ -111,7 +111,7 @@ For each workspace folder, Cline detects:
This means Cline understands that your frontend and backend might be at different commits, on different branches, or even use different version control systems.
<Note>
While Cline detects VCS information for all workspace folders, certain features only use the **primary workspace** (the first folder): [Cline rules](/customization/cline-rules), [workflows](/customization/workflows), and [Git-related features](/core-workflows/working-with-files) like `@git` mentions.
While Cline detects VCS information for all workspace folders, certain features only use the **primary workspace** (the first folder): [Cline rules](/customization/cline-rules) and [Git-related features](/core-workflows/working-with-files) like `@git` mentions.
</Note>
## Referencing Files Across Workspaces
+1 -1
View File
@@ -32,7 +32,7 @@ Cline is an AI coding agent that lives in your editor and your terminal. It can
Learn the daily patterns: task management, plan & act, working with files, commands, and checkpoints.
</Card>
<Card title="Customization" icon="sliders" href="/customization/overview">
Tailor Cline to your workflow with rules, skills, workflows, hooks, and .clineignore.
Tailor Cline to your workflow with rules, skills, hooks, and .clineignore.
</Card>
<Card title="Features" icon="sparkles" href="/features/memory-bank">
Discover Memory Bank, Focus Chain, auto-approve, subagents, Jupyter support, and more.
+3 -14
View File
@@ -52,15 +52,12 @@ service FileService {
// Toggle an Agents rule (enable or disable)
rpc toggleAgentsRule(ToggleAgentsRuleRequest) returns (ClineRulesToggles);
// Refreshes all rule toggles (Cline, External, and Workflows)
// Refreshes all rule toggles (Cline and external rule families)
rpc refreshRules(EmptyRequest) returns (RefreshedRules);
// Opens a task's conversation history file on disk
rpc openDiskConversationHistory(StringRequest) returns (Empty);
// Toggles a workflow on or off
rpc toggleWorkflow(ToggleWorkflowRequest) returns (ClineRulesToggles);
// Check if file exists in the project
rpc ifFileExistsRelativePath(StringRequest) returns (BooleanResponse);
@@ -102,8 +99,8 @@ message RefreshedRules {
ClineRulesToggles local_cursor_rules_toggles = 3;
ClineRulesToggles local_windsurf_rules_toggles = 4;
ClineRulesToggles local_agents_rules_toggles = 5;
ClineRulesToggles local_workflow_toggles = 6;
ClineRulesToggles global_workflow_toggles = 7;
reserved 6; // was local_workflow_toggles (workflows removed)
reserved 7; // was global_workflow_toggles (workflows removed)
}
// Request to toggle a Windsurf rule
@@ -225,14 +222,6 @@ message ToggleCursorRuleRequest {
bool enabled = 3; // Whether to enable or disable the rule
}
// Request to toggle a workflow on or off
message ToggleWorkflowRequest {
Metadata metadata = 1;
string workflow_path = 2;
bool enabled = 3;
RuleScope scope = 4; // Scope of the workflow (local, global, or remote)
}
// Maps from hook name to enabled/disabled status
message HookInfo {
string name = 1;
+1 -1
View File
@@ -240,7 +240,7 @@ message Settings {
optional string lm_studio_model_id = 127;
optional AutoApprovalSettings auto_approval_settings = 128;
optional string global_cline_rules_toggles = 129;
optional string global_workflow_toggles = 130;
reserved 130; // was global_workflow_toggles (workflows removed)
optional string global_skills_toggles = 131;
optional BrowserSettings browser_settings = 132;
optional string telemetry_setting = 133;
@@ -92,7 +92,6 @@ export const getLocalClineRules = async (
if (await isDirectory(clineRulesFilePath)) {
try {
const rulesFilePaths = await readDirectory(clineRulesFilePath, [
[".clinerules", "workflows"],
[".clinerules", "hooks"],
[".clinerules", "skills"],
])
@@ -163,7 +162,6 @@ export async function refreshClineRulesToggles(
const localClineRulesToggles = controller.stateManager.getWorkspaceStateKey("localClineRulesToggles")
const localClineRulesFilePath = path.resolve(workingDirectory, GlobalFileNames.clineRules)
const updatedLocalToggles = await synchronizeRuleToggles(localClineRulesFilePath, localClineRulesToggles, "", [
[".clinerules", "workflows"],
[".clinerules", "hooks"],
[".clinerules", "skills"],
])
@@ -1,4 +1,4 @@
import { ensureRulesDirectoryExists, ensureWorkflowsDirectoryExists, GlobalFileNames } from "@core/storage/disk"
import { ensureRulesDirectoryExists, GlobalFileNames } from "@core/storage/disk"
import { ClineRulesToggles } from "@shared/cline-rules"
import { GlobalInstructionsFile } from "@shared/remote-config/schema"
import { fileExistsAtPath, isDirectory, readDirectory } from "@utils/fs"
@@ -42,7 +42,7 @@ export async function readDirectoryRecursive(
export async function synchronizeRuleToggles(
rulesDirectoryPath: string,
currentToggles: ClineRulesToggles,
allowedFileExtension: string = "",
allowedFileExtension = "",
excludedPaths: string[][] = [],
): Promise<ClineRulesToggles> {
// Create a copy of toggles to modify
@@ -275,8 +275,8 @@ export function getRemoteRulesTotalContentWithMetadata(
}
/**
* Handles converting any directory into a file (specifically used for .clinerules and .clinerules/workflows)
* The old .clinerules file or .clinerules/workflows file will be renamed to a default filename
* Handles converting a legacy .clinerules file path into a directory-backed rules layout.
* The old .clinerules file will be renamed to a default filename inside the new directory.
* Doesn't do anything if the dir already exists or doesn't exist
* Returns whether there are any uncaught errors
*/
@@ -318,13 +318,8 @@ export const createRuleFile = async (isGlobal: boolean, filename: string, cwd: s
try {
let filePath: string
if (isGlobal) {
if (type === "workflow") {
const globalClineWorkflowFilePath = await ensureWorkflowsDirectoryExists()
filePath = path.join(globalClineWorkflowFilePath, filename)
} else {
const globalClineRulesFilePath = await ensureRulesDirectoryExists()
filePath = path.join(globalClineRulesFilePath, filename)
}
const globalClineRulesFilePath = await ensureRulesDirectoryExists()
filePath = path.join(globalClineRulesFilePath, filename)
} else {
const localClineRulesFilePath = path.resolve(cwd, GlobalFileNames.clineRules)
@@ -335,21 +330,8 @@ export const createRuleFile = async (isGlobal: boolean, filename: string, cwd: s
await fs.mkdir(localClineRulesFilePath, { recursive: true })
if (type === "workflow") {
const localWorkflowsFilePath = path.resolve(cwd, GlobalFileNames.workflows)
const hasError = await ensureLocalClineDirExists(localWorkflowsFilePath, "default-workflows.md")
if (hasError === true) {
return { filePath: null, fileExists: false }
}
await fs.mkdir(localWorkflowsFilePath, { recursive: true })
filePath = path.join(localWorkflowsFilePath, filename)
} else {
// clinerules file creation
filePath = path.join(localClineRulesFilePath, filename)
}
// clinerules file creation
filePath = path.join(localClineRulesFilePath, filename)
}
const fileExists = await fileExistsAtPath(filePath)
@@ -393,21 +375,11 @@ export async function deleteRuleFile(
// Update the appropriate toggles
if (isGlobal) {
if (type === "workflow") {
const toggles = controller.stateManager.getGlobalSettingsKey("globalWorkflowToggles")
delete toggles[rulePath]
controller.stateManager.setGlobalState("globalWorkflowToggles", toggles)
} else {
const toggles = controller.stateManager.getGlobalSettingsKey("globalClineRulesToggles")
delete toggles[rulePath]
controller.stateManager.setGlobalState("globalClineRulesToggles", toggles)
}
const toggles = controller.stateManager.getGlobalSettingsKey("globalClineRulesToggles")
delete toggles[rulePath]
controller.stateManager.setGlobalState("globalClineRulesToggles", toggles)
} else {
if (type === "workflow") {
const toggles = controller.stateManager.getWorkspaceStateKey("workflowToggles")
delete toggles[rulePath]
controller.stateManager.setWorkspaceState("workflowToggles", toggles)
} else if (type === "cursor") {
if (type === "cursor") {
const toggles = controller.stateManager.getWorkspaceStateKey("localCursorRulesToggles")
delete toggles[rulePath]
controller.stateManager.setWorkspaceState("localCursorRulesToggles", toggles)
@@ -1,32 +0,0 @@
import { synchronizeRuleToggles } from "@core/context/instructions/user-instructions/rule-helpers"
import { ensureWorkflowsDirectoryExists, GlobalFileNames } from "@core/storage/disk"
import { ClineRulesToggles } from "@shared/cline-rules"
import path from "path"
import { Controller } from "@/core/controller"
/**
* Refresh the workflow toggles
*/
export async function refreshWorkflowToggles(
controller: Controller,
workingDirectory: string,
): Promise<{
globalWorkflowToggles: ClineRulesToggles
localWorkflowToggles: ClineRulesToggles
}> {
// Global workflows
const globalWorkflowToggles = controller.stateManager.getGlobalSettingsKey("globalWorkflowToggles")
const globalClineWorkflowsFilePath = await ensureWorkflowsDirectoryExists()
const updatedGlobalWorkflowToggles = await synchronizeRuleToggles(globalClineWorkflowsFilePath, globalWorkflowToggles)
controller.stateManager.setGlobalState("globalWorkflowToggles", updatedGlobalWorkflowToggles)
const workflowRulesToggles = controller.stateManager.getWorkspaceStateKey("workflowToggles")
const workflowsDirPath = path.resolve(workingDirectory, GlobalFileNames.workflows)
const updatedWorkflowToggles = await synchronizeRuleToggles(workflowsDirPath, workflowRulesToggles)
controller.stateManager.setWorkspaceState("workflowToggles", updatedWorkflowToggles)
return {
globalWorkflowToggles: updatedGlobalWorkflowToggles,
localWorkflowToggles: updatedWorkflowToggles,
}
}
+2 -7
View File
@@ -2,7 +2,6 @@ import { refreshClineRulesToggles } from "@core/context/instructions/user-instru
import { createRuleFile as createRuleFileImpl } from "@core/context/instructions/user-instructions/rule-helpers"
import { getWorkspaceBasename } from "@core/workspace"
import { RuleFile, RuleFileRequest } from "@shared/proto/cline/file"
import { refreshWorkflowToggles } from "@/core/context/instructions/user-instructions/workflows"
import { HostProvider } from "@/hosts/host-provider"
import { ShowMessageType } from "@/shared/proto/host/window"
import { Logger } from "@/shared/services/Logger"
@@ -40,7 +39,7 @@ export async function createRuleFile(controller: Controller, request: RuleFileRe
throw new Error("Failed to create file.")
}
const fileTypeName = request.type === "workflow" ? "workflow" : "rule"
const fileTypeName = "rule"
if (fileExists) {
const message = `${fileTypeName} file "${request.filename}" already exists.`
@@ -51,11 +50,7 @@ export async function createRuleFile(controller: Controller, request: RuleFileRe
// Still open it for editing
await openFile(controller, { value: filePath })
} else {
if (request.type === "workflow") {
await refreshWorkflowToggles(controller, cwd)
} else {
await refreshClineRulesToggles(controller, cwd)
}
await refreshClineRulesToggles(controller, cwd)
await controller.postStateToWebview()
await openFile(controller, { value: filePath })
+1 -2
View File
@@ -38,12 +38,11 @@ export async function deleteRuleFile(controller: Controller, request: RuleFileRe
// we refresh inside of the deleteRuleFileImpl(..) call
//await refreshClineRulesToggles(controller.context, cwd)
//await refreshExternalRulesToggles(controller.context, cwd)
//await refreshWorkflowToggles(controller.context, cwd)
await controller.postStateToWebview()
const fileName = getWorkspaceBasename(request.rulePath, "Controller.deleteRuleFile")
const fileTypeName = request.type === "workflow" ? "workflow" : "rule"
const fileTypeName = "rule"
const message = `${fileTypeName} file "${fileName}" deleted successfully`
HostProvider.window.showMessage({
+9 -15
View File
@@ -11,9 +11,8 @@ import { Controller } from ".."
* Opens a file in the editor
* @param controller The controller instance
* @param request The request message containing the file path in the 'value' field.
* Supports special URI format for remote rules/workflows:
* Supports special URI format for remote rules:
* - remote://rule/{ruleName}
* - remote://workflow/{workflowName}
* @returns Empty response
*/
export async function openFile(_controller: Controller, request: StringRequest): Promise<Empty> {
@@ -29,35 +28,30 @@ export async function openFile(_controller: Controller, request: StringRequest):
}
/**
* Opens a remote rule or workflow file by creating a temp file with its contents
* @param uri The remote URI in format: remote://rule/{name} or remote://workflow/{name}
* Opens a remote rule file by creating a temp file with its contents
* @param uri The remote URI in format: remote://rule/{name}
*/
async function openRemoteFile(uri: string): Promise<void> {
// Parse: remote://rule/{name} or remote://workflow/{name}
const match = uri.match(/^remote:\/\/(rule|workflow)\/(.+)$/)
const match = uri.match(/^remote:\/\/rule\/(.+)$/)
if (!match) {
throw new Error(`Invalid remote file URI: ${uri}`)
}
const [, type, name] = match
const [, name] = match
const remoteConfig = StateManager.get().getRemoteConfigSettings()
// Look up content based on type
const items = type === "rule" ? remoteConfig.remoteGlobalRules : remoteConfig.remoteGlobalWorkflows
const item = items?.find((r) => r.name === name)
const item = remoteConfig.remoteGlobalRules?.find((r) => r.name === name)
if (!item?.contents) {
throw new Error(`Remote ${type} not found: ${name}`)
throw new Error(`Remote rule not found: ${name}`)
}
// Create temp file with read-only header comment
const typeLabel = type === "rule" ? "rule" : "workflow"
const header = `# ⚠️ READ-ONLY: This ${typeLabel} is managed by your organization.\n# Changes made here will not be saved.\n\n`
const header = "# ⚠️ READ-ONLY: This rule is managed by your organization.\n# Changes made here will not be saved.\n\n"
const content = header + item.contents
// Sanitize the name for use in filename (replace invalid characters)
const sanitizedName = name.replace(/[<>:"/\\|?*]/g, "_")
const tempPath = path.join(os.tmpdir(), `cline-remote-${type}-${sanitizedName}.md`)
const tempPath = path.join(os.tmpdir(), `cline-remote-rule-${sanitizedName}.md`)
await writeFile(tempPath, content)
await openFileIntegration(tempPath)
+2 -6
View File
@@ -1,6 +1,5 @@
import { refreshClineRulesToggles } from "@core/context/instructions/user-instructions/cline-rules"
import { refreshExternalRulesToggles } from "@core/context/instructions/user-instructions/external-rules"
import { refreshWorkflowToggles } from "@core/context/instructions/user-instructions/workflows"
import { EmptyRequest } from "@shared/proto/cline/common"
import { RefreshedRules } from "@shared/proto/cline/file"
import { Logger } from "@/shared/services/Logger"
@@ -8,10 +7,10 @@ import { getCwd, getDesktopDir } from "@/utils/path"
import type { Controller } from "../index"
/**
* Refreshes all rule toggles (Cline, External, and Workflows)
* Refreshes all rule toggles (Cline and external rule families)
* @param controller The controller instance
* @param _request The empty request
* @returns RefreshedRules containing updated toggles for all rule types
* @returns RefreshedRules containing updated toggles for supported rule types
*/
export async function refreshRules(controller: Controller, _request: EmptyRequest): Promise<RefreshedRules> {
try {
@@ -21,7 +20,6 @@ export async function refreshRules(controller: Controller, _request: EmptyReques
controller,
cwd,
)
const { localWorkflowToggles, globalWorkflowToggles } = await refreshWorkflowToggles(controller, cwd)
return RefreshedRules.create({
globalClineRulesToggles: { toggles: globalToggles },
@@ -29,8 +27,6 @@ export async function refreshRules(controller: Controller, _request: EmptyReques
localCursorRulesToggles: { toggles: cursorLocalToggles },
localWindsurfRulesToggles: { toggles: windsurfLocalToggles },
localAgentsRulesToggles: { toggles: agentsLocalToggles },
localWorkflowToggles: { toggles: localWorkflowToggles },
globalWorkflowToggles: { toggles: globalWorkflowToggles },
})
} catch (error) {
Logger.error("Failed to refresh rules:", error)
@@ -1,53 +0,0 @@
import { ClineRulesToggles, RuleScope, ToggleWorkflowRequest } from "@shared/proto/cline/file"
import { Logger } from "@/shared/services/Logger"
import { Controller } from ".."
/**
* Toggles a workflow on or off
* @param controller The controller instance
* @param request The request containing the workflow path and enabled state
* @returns The updated workflow toggles
*/
export async function toggleWorkflow(controller: Controller, request: ToggleWorkflowRequest): Promise<ClineRulesToggles> {
const { workflowPath, enabled, scope } = request
if (!workflowPath || typeof enabled !== "boolean" || scope === undefined) {
Logger.error("toggleWorkflow: Missing or invalid parameters", {
workflowPath,
scope,
enabled: typeof enabled === "boolean" ? enabled : `Invalid: ${typeof enabled}`,
})
throw new Error("Missing or invalid parameters for toggleWorkflow")
}
// Handle the three different scopes
let toggles: Record<string, boolean>
switch (scope) {
case RuleScope.GLOBAL: {
toggles = controller.stateManager.getGlobalSettingsKey("globalWorkflowToggles")
toggles[workflowPath] = enabled
controller.stateManager.setGlobalState("globalWorkflowToggles", toggles)
break
}
case RuleScope.LOCAL: {
toggles = controller.stateManager.getWorkspaceStateKey("workflowToggles")
toggles[workflowPath] = enabled
controller.stateManager.setWorkspaceState("workflowToggles", toggles)
break
}
case RuleScope.REMOTE: {
toggles = controller.stateManager.getGlobalStateKey("remoteWorkflowToggles")
toggles[workflowPath] = enabled
controller.stateManager.setGlobalState("remoteWorkflowToggles", toggles)
break
}
default:
throw new Error(`Invalid scope: ${scope}`)
}
await controller.postStateToWebview()
// Return the updated toggles
return ClineRulesToggles.create({ toggles: toggles })
}
-6
View File
@@ -864,11 +864,9 @@ export class Controller {
const planActSeparateModelsSetting = this.stateManager.getGlobalSettingsKey("planActSeparateModelsSetting")
const enableCheckpointsSetting = this.stateManager.getGlobalSettingsKey("enableCheckpointsSetting")
const globalClineRulesToggles = this.stateManager.getGlobalSettingsKey("globalClineRulesToggles")
const globalWorkflowToggles = this.stateManager.getGlobalSettingsKey("globalWorkflowToggles")
const globalSkillsToggles = this.stateManager.getGlobalSettingsKey("globalSkillsToggles")
const localSkillsToggles = this.stateManager.getWorkspaceStateKey("localSkillsToggles")
const remoteRulesToggles = this.stateManager.getGlobalStateKey("remoteRulesToggles")
const remoteWorkflowToggles = this.stateManager.getGlobalStateKey("remoteWorkflowToggles")
const shellIntegrationTimeout = this.stateManager.getGlobalSettingsKey("shellIntegrationTimeout")
const terminalReuseEnabled = this.stateManager.getGlobalStateKey("terminalReuseEnabled")
const vscodeTerminalExecutionMode = this.stateManager.getGlobalStateKey("vscodeTerminalExecutionMode")
@@ -894,7 +892,6 @@ export class Controller {
const localWindsurfRulesToggles = this.stateManager.getWorkspaceStateKey("localWindsurfRulesToggles")
const localCursorRulesToggles = this.stateManager.getWorkspaceStateKey("localCursorRulesToggles")
const localAgentsRulesToggles = this.stateManager.getWorkspaceStateKey("localAgentsRulesToggles")
const workflowToggles = this.stateManager.getWorkspaceStateKey("workflowToggles")
const currentTaskItem = this.task?.taskId ? (taskHistory || []).find((item) => item.id === this.task?.taskId) : undefined
// Spread to create new array reference - React needs this to detect changes in useEffect dependencies
@@ -950,12 +947,9 @@ export class Controller {
localWindsurfRulesToggles: localWindsurfRulesToggles || {},
localCursorRulesToggles: localCursorRulesToggles || {},
localAgentsRulesToggles: localAgentsRulesToggles || {},
localWorkflowToggles: workflowToggles || {},
globalWorkflowToggles: globalWorkflowToggles || {},
globalSkillsToggles: globalSkillsToggles || {},
localSkillsToggles: localSkillsToggles || {},
remoteRulesToggles: remoteRulesToggles,
remoteWorkflowToggles: remoteWorkflowToggles,
shellIntegrationTimeout,
terminalReuseEnabled,
vscodeTerminalExecutionMode: vscodeTerminalExecutionMode,
@@ -21,68 +21,5 @@ export async function getAvailableSlashCommands(controller: Controller, _request
)
}
// Get workflow toggles from state
const localWorkflowToggles = controller.stateManager.getWorkspaceStateKey("workflowToggles") ?? {}
const globalWorkflowToggles = controller.stateManager.getGlobalSettingsKey("globalWorkflowToggles") ?? {}
const remoteWorkflowToggles = controller.stateManager.getGlobalStateKey("remoteWorkflowToggles") ?? {}
const remoteConfigSettings = controller.stateManager.getRemoteConfigSettings()
const remoteWorkflows = remoteConfigSettings?.remoteGlobalWorkflows ?? []
// Track local workflow names to avoid duplicates from global
const localNames = 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)
commands.push(
SlashCommandInfo.create({
name: fileName,
description: `Custom workflow: ${fileName}`,
section: "custom",
cliCompatible: true,
}),
)
}
}
// 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,
}),
)
}
}
}
// Add remote workflows that are enabled
for (const workflow of remoteWorkflows) {
const enabled = workflow.alwaysEnabled || remoteWorkflowToggles[workflow.name] !== false
if (enabled) {
commands.push(
SlashCommandInfo.create({
name: workflow.name,
description: `Remote workflow: ${workflow.name}`,
section: "custom",
cliCompatible: true,
}),
)
}
}
return SlashCommandsResponse.create({ commands })
}
function fullPathToFileName(path: string): string {
// e.g. replace /path/to/workflow.md with workflow.md
return path.replace(/^.*[/\\]/, "")
}
@@ -105,7 +105,7 @@ describe("slash-commands", () => {
it("should process MCP prompt command in task tag", async () => {
const text = "<task>/mcp:test-server:greet</task>"
const result = await parseSlashCommands(text, {}, {}, "test-ulid", undefined, false, undefined, mockMcpPromptFetcher)
const result = await parseSlashCommands(text, "test-ulid", undefined, false, undefined, mockMcpPromptFetcher)
expect(result.processedText).to.include('<mcp_prompt server="test-server" prompt="greet">')
expect(result.processedText).to.include("Hello from MCP!")
@@ -114,7 +114,7 @@ describe("slash-commands", () => {
it("should process MCP prompt with additional text", async () => {
const text = "<task>/mcp:test-server:greet Please expand on this</task>"
const result = await parseSlashCommands(text, {}, {}, "test-ulid", undefined, false, undefined, mockMcpPromptFetcher)
const result = await parseSlashCommands(text, "test-ulid", undefined, false, undefined, mockMcpPromptFetcher)
expect(result.processedText).to.include('<mcp_prompt server="test-server" prompt="greet">')
expect(result.processedText).to.include("Please expand on this")
@@ -131,14 +131,19 @@ describe("slash-commands", () => {
}
const text = "<task>/mcp:server:prompt:with:colons</task>"
const result = await parseSlashCommands(text, {}, {}, "test-ulid", undefined, false, undefined, fetcherWithColons)
const result = await parseSlashCommands(text, "test-ulid", undefined, false, undefined, fetcherWithColons)
expect(result.processedText).to.include('prompt="prompt:with:colons"')
expect(result.processedText).to.include("Colon prompt")
})
// Note: Tests for "unknown MCP server", "no fetcher", and "fetcher errors"
// are skipped because they require StateManager initialization when falling
// through to workflow checking. The core MCP functionality is covered above.
it("should treat arbitrary workflow-like filenames as unknown slash commands", async () => {
const text = "<task>/release.md Ship the release</task>"
const result = await parseSlashCommands(text, "test-ulid")
expect(result.processedText).to.equal(text)
expect(result.processedText).to.not.include("<explicit_instructions")
expect(result.needsClinerulesFileCheck).to.equal(false)
})
})
})
+1 -86
View File
@@ -1,7 +1,5 @@
import type { ApiProviderInfo } from "@core/api"
import { ClineRulesToggles } from "@shared/cline-rules"
import { McpPromptResponse } from "@shared/mcp"
import fs from "fs/promises"
import { telemetryService } from "@/services/telemetry"
import { Logger } from "@/shared/services/Logger"
import { isNativeToolCallingConfig } from "@/utils/model-utils"
@@ -13,36 +11,18 @@ import {
newTaskToolResponse,
reportBugToolResponse,
} from "../prompts/commands"
import { StateManager } from "../storage/StateManager"
/**
* Callback type for fetching MCP prompts
*/
export type McpPromptFetcher = (serverName: string, promptName: string) => Promise<McpPromptResponse | null>
type FileBasedWorkflow = {
fullPath: string
fileName: string
isRemote: false
}
type RemoteWorkflow = {
fullPath: string
fileName: string
isRemote: true
contents: string
}
type Workflow = FileBasedWorkflow | RemoteWorkflow
/**
* Processes text for slash commands and transforms them with appropriate instructions
* This is called after parseMentions() to process any slash commands in the user's message
*/
export async function parseSlashCommands(
text: string,
localWorkflowToggles: ClineRulesToggles,
globalWorkflowToggles: ClineRulesToggles,
ulid: string,
focusChainSettings?: { enabled: boolean },
enableNativeToolCalls?: boolean,
@@ -165,78 +145,13 @@ export async function parseSlashCommands(
return { processedText, needsClinerulesFileCheck: false }
}
// Prompt not found - log for debugging and fall through to workflow checking
// Prompt not found - log for debugging and fall through to default handling
Logger.debug(`MCP prompt not found: ${commandName} (server: ${serverName}, prompt: ${promptName})`)
} catch (error) {
Logger.error(`Error fetching MCP prompt ${commandName}: ${error}`)
}
}
}
const globalWorkflows: Workflow[] = Object.entries(globalWorkflowToggles)
.filter(([_, enabled]) => enabled)
.map(([filePath, _]) => ({
fullPath: filePath,
fileName: filePath.replace(/^.*[/\\]/, ""),
isRemote: false,
}))
const localWorkflows: Workflow[] = Object.entries(localWorkflowToggles)
.filter(([_, enabled]) => enabled)
.map(([filePath, _]) => ({
fullPath: filePath,
fileName: filePath.replace(/^.*[/\\]/, ""),
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") || {}
const enabledRemoteWorkflows: Workflow[] = remoteWorkflows
.filter((workflow) => {
// If alwaysEnabled, always include; otherwise check toggle
return workflow.alwaysEnabled || remoteWorkflowToggles[workflow.name] !== false
})
.map((workflow) => ({
fullPath: "",
fileName: workflow.name,
isRemote: true,
contents: workflow.contents,
}))
// 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)
if (matchingWorkflow) {
try {
// Get workflow content - either from file or from remote config
let workflowContent: string
if (matchingWorkflow.isRemote) {
workflowContent = matchingWorkflow.contents.trim()
} else {
workflowContent = (await fs.readFile(matchingWorkflow.fullPath, "utf8")).trim()
}
// remove the slash command and add custom instructions at the top of this message
const textWithoutSlashCommand = removeSlashCommand(text, tagContent, contentStartIndex, slashMatch)
const processedText =
`<explicit_instructions type="${matchingWorkflow.fileName}">\n${workflowContent}\n</explicit_instructions>\n` +
textWithoutSlashCommand
// Track telemetry for workflow command usage
telemetryService.captureSlashCommandUsed(ulid, commandName, "workflow")
return { processedText, needsClinerulesFileCheck: false }
} catch (error) {
Logger.error(`Error reading workflow file ${matchingWorkflow.fullPath}: ${error}`)
}
}
}
}
-12
View File
@@ -54,7 +54,6 @@ export const GlobalFileNames = {
hicapModels: "hicap_models.json",
mcpSettings: "cline_mcp_settings.json",
clineRules: ".clinerules",
workflows: ".clinerules/workflows",
hooksDir: ".clinerules/hooks",
clineruleSkillsDir: ".clinerules/skills",
clineSkillsDir: ".cline/skills",
@@ -133,17 +132,6 @@ export async function ensureRulesDirectoryExists(): Promise<string> {
return clineRulesDir
}
export async function ensureWorkflowsDirectoryExists(): Promise<string> {
const userDocumentsPath = await getDocumentsPath()
const clineWorkflowsDir = path.join(userDocumentsPath, "Cline", "Workflows")
try {
await fs.mkdir(clineWorkflowsDir, { recursive: true })
} catch (_error) {
return path.join(os.homedir(), "Documents", "Cline", "Workflows") // in case creating a directory in documents fails for whatever reason (e.g. permissions) - this is fine because we will fail gracefully with a path that does not exist
}
return clineWorkflowsDir
}
export async function ensureMcpServersDirectoryExists(): Promise<string> {
const userDocumentsPath = await getDocumentsPath()
const mcpServersDir = path.join(userDocumentsPath, "Cline", "MCP")
+1 -8
View File
@@ -217,13 +217,10 @@ export function transformRemoteConfigToStateShape(remoteConfig: RemoteConfig): P
transformed.remoteConfiguredProviders = providers
}
// Map global rules and workflows
// Map global rules
if (remoteConfig.globalRules !== undefined) {
transformed.remoteGlobalRules = remoteConfig.globalRules
}
if (remoteConfig.globalWorkflows !== undefined) {
transformed.remoteGlobalWorkflows = remoteConfig.globalWorkflows
}
if (remoteConfig.enterpriseTelemetry?.promptUploading) {
const promptUplaoding = remoteConfig.enterpriseTelemetry.promptUploading
@@ -279,7 +276,6 @@ export function clearRemoteConfig() {
telemetryService.removeProvider(REMOTE_CONFIG_OTEL_PROVIDER_ID)
// the remote config cline rules toggle state is stored in global state
stateManager.setGlobalState("remoteRulesToggles", {})
stateManager.setGlobalState("remoteWorkflowToggles", {})
// clear secrets
stateManager.setSecret("remoteLiteLlmApiKey", undefined)
@@ -315,13 +311,10 @@ export async function applyRemoteConfig(
// Synchronize toggle state
const currentRuleToggles = stateManager.getGlobalStateKey("remoteRulesToggles") || {}
const currentWorkflowToggles = stateManager.getGlobalStateKey("remoteWorkflowToggles") || {}
const syncedRuleToggles = synchronizeRemoteRuleToggles(remoteConfig.globalRules || [], currentRuleToggles)
const syncedWorkflowToggles = synchronizeRemoteRuleToggles(remoteConfig.globalWorkflows || [], currentWorkflowToggles)
stateManager.setGlobalState("remoteRulesToggles", syncedRuleToggles)
stateManager.setGlobalState("remoteWorkflowToggles", syncedWorkflowToggles)
// Clear existing remote config cache
stateManager.clearRemoteConfig()
-4
View File
@@ -110,7 +110,6 @@ import { Session } from "@/shared/services/Session"
import { RuleContextBuilder } from "../context/instructions/user-instructions/RuleContextBuilder"
import { ensureLocalClineDirExists } from "../context/instructions/user-instructions/rule-helpers"
import { discoverSkills, getAvailableSkills } from "../context/instructions/user-instructions/skills"
import { refreshWorkflowToggles } from "../context/instructions/user-instructions/workflows"
import { Controller } from "../controller"
import { executeHook } from "../hooks/hook-executor"
import { StateManager } from "../storage/StateManager"
@@ -3323,7 +3322,6 @@ export class Task {
const useNativeToolCalls = this.stateManager.getGlobalStateKey("nativeToolCallEnabled")
const providerInfo = this.getCurrentProviderInfo()
const cwd = this.cwd
const { localWorkflowToggles, globalWorkflowToggles } = await refreshWorkflowToggles(this.controller, cwd)
const hasUserContentTag = (text: string): boolean => {
return USER_CONTENT_TAGS.some((tag) => text.includes(tag))
@@ -3349,8 +3347,6 @@ export class Task {
const { processedText, needsClinerulesFileCheck: needsCheck } = await parseSlashCommands(
parsedText,
localWorkflowToggles,
globalWorkflowToggles,
ulid,
focusChainSettings,
useNativeToolCalls,
-3
View File
@@ -73,12 +73,9 @@ export interface ExtensionState {
distinctId: string
globalClineRulesToggles: ClineRulesToggles
localClineRulesToggles: ClineRulesToggles
localWorkflowToggles: ClineRulesToggles
globalWorkflowToggles: ClineRulesToggles
localCursorRulesToggles: ClineRulesToggles
localWindsurfRulesToggles: ClineRulesToggles
remoteRulesToggles?: ClineRulesToggles
remoteWorkflowToggles?: ClineRulesToggles
localAgentsRulesToggles: ClineRulesToggles
mcpResponsesCollapsed?: boolean
strictPlanModeEnabled?: boolean
@@ -672,13 +672,6 @@ describe("Remote Config Schema", () => {
contents: "# Optional Guidelines\n\nConsider these best practices...",
},
],
globalWorkflows: [
{
alwaysEnabled: true,
name: "deployment-workflow.md",
contents: "# Deployment Workflow\n\n1. Run tests\n2. Build\n3. Deploy",
},
],
providerSettings: {
OpenAiCompatible: {
models: [
@@ -836,11 +829,6 @@ describe("Remote Config Schema", () => {
expect(result.globalRules?.[1].alwaysEnabled).to.equal(false)
expect(result.globalRules?.[1].name).to.equal("optional-guidelines.md")
expect(result.globalWorkflows).to.have.lengthOf(1)
expect(result.globalWorkflows?.[0].alwaysEnabled).to.equal(true)
expect(result.globalWorkflows?.[0].name).to.equal("deployment-workflow.md")
expect(result.globalWorkflows?.[0].contents).to.include("Deployment Workflow")
expect(result.enterpriseTelemetry?.promptUploading?.enabled).to.equal(true)
expect(result.enterpriseTelemetry?.promptUploading?.type).to.equal("s3_access_keys")
expect(result.enterpriseTelemetry?.promptUploading?.s3AccessSettings?.bucket).to.equal("enterprise-prompts")
+5 -6
View File
@@ -136,13 +136,13 @@ export const RemoteMCPServerSchema = z.object({
headers: z.record(z.string(), z.string()).optional(),
})
// Settings for a global cline rules or workflow file.
// Settings for a global cline rules file.
export const GlobalInstructionsFileSchema = z.object({
// When this is enabled, the user cannot turn off this rule or workflow.
// When this is enabled, the user cannot turn off this rule.
alwaysEnabled: z.boolean(),
// The name of the rules or workflow file.
// The name of the rule file.
name: z.string(),
// The contents of the rules or workflow file
// The contents of the rule file
contents: z.string(),
})
@@ -221,9 +221,8 @@ export const RemoteConfigSchema = z.object({
enterpriseTelemetry: EnterpriseTelemetrySchema.optional(),
// Rules & Workflows
// Rules
globalRules: z.array(GlobalInstructionsFileSchema).optional(),
globalWorkflows: z.array(GlobalInstructionsFileSchema).optional(),
})
export const APIKeySchema = z.record(z.string(), z.string())
-4
View File
@@ -54,7 +54,6 @@ const REMOTE_CONFIG_EXTRA_FIELDS = {
remoteMCPServers: { default: undefined as Array<{ name: string; url: string; alwaysEnabled?: boolean }> | undefined },
previousRemoteMCPServers: { default: undefined as Array<{ name: string; url: string }> | undefined },
remoteGlobalRules: { default: undefined as GlobalInstructionsFile[] | undefined },
remoteGlobalWorkflows: { default: undefined as GlobalInstructionsFile[] | undefined },
blockPersonalRemoteMCPServers: { default: false as boolean },
openTelemetryOtlpHeaders: { default: undefined as Record<string, string> | undefined },
otlpMetricsHeaders: { default: undefined as Record<string, string> | undefined },
@@ -88,7 +87,6 @@ const GLOBAL_STATE_FIELDS = {
lastDismissedCliBannerVersion: { default: 0 as number },
nativeToolCallEnabled: { default: true as boolean },
remoteRulesToggles: { default: {} as ClineRulesToggles },
remoteWorkflowToggles: { default: {} as ClineRulesToggles },
dismissedBanners: { default: [] as Array<{ bannerId: string; dismissedAt: number }> },
// Path to worktree that should auto-open Cline sidebar when launched
worktreeAutoOpenPath: { default: undefined as string | undefined },
@@ -245,7 +243,6 @@ const USER_SETTINGS_FIELDS = {
default: DEFAULT_AUTO_APPROVAL_SETTINGS as AutoApprovalSettings,
},
globalClineRulesToggles: { default: {} as ClineRulesToggles },
globalWorkflowToggles: { default: {} as ClineRulesToggles },
globalSkillsToggles: { default: {} as Record<string, boolean> },
browserSettings: {
default: DEFAULT_BROWSER_SETTINGS as BrowserSettings,
@@ -359,7 +356,6 @@ export const LocalStateKeys = [
"localWindsurfRulesToggles",
"localAgentsRulesToggles",
"localSkillsToggles",
"workflowToggles",
] as const
// ============================================================================
+5 -213
View File
@@ -12,29 +12,10 @@ import { BASE_SLASH_COMMANDS } from "../shared/slashCommands"
*/
describe("getAvailableSlashCommands", () => {
let mockController: Partial<Controller>
let mockStateManager: {
getWorkspaceStateKey: sinon.SinonStub
getGlobalSettingsKey: sinon.SinonStub
getGlobalStateKey: sinon.SinonStub
getRemoteConfigSettings: sinon.SinonStub
}
beforeEach(() => {
mockStateManager = {
getWorkspaceStateKey: sinon.stub(),
getGlobalSettingsKey: sinon.stub(),
getGlobalStateKey: sinon.stub(),
getRemoteConfigSettings: sinon.stub(),
}
// Default stubs return empty/null values
mockStateManager.getWorkspaceStateKey.returns(null)
mockStateManager.getGlobalSettingsKey.returns(null)
mockStateManager.getGlobalStateKey.returns(null)
mockStateManager.getRemoteConfigSettings.returns(null)
mockController = {
stateManager: mockStateManager as any,
stateManager: {} as any,
}
})
@@ -77,209 +58,20 @@ describe("getAvailableSlashCommands", () => {
})
})
describe("Local Workflow Toggles", () => {
it("should include enabled local workflows", async () => {
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({
"/path/to/my-workflow.md": true,
"/path/to/another-workflow.md": true,
})
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
const myWorkflow = response.commands.find((cmd) => cmd.name === "my-workflow.md")
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")
anotherWorkflow!.should.not.be.undefined()
})
it("should exclude disabled local workflows", async () => {
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({
"/path/to/enabled-workflow.md": true,
"/path/to/disabled-workflow.md": false,
})
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
const enabled = response.commands.find((cmd) => cmd.name === "enabled-workflow.md")
enabled!.should.not.be.undefined()
const disabled = response.commands.find((cmd) => cmd.name === "disabled-workflow.md")
;(disabled === undefined).should.be.true()
})
it("should extract filename from full path", async () => {
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({
"/Users/test/project/.clinerules/workflows/deep-analysis.md": true,
})
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
const workflow = response.commands.find((cmd) => cmd.name === "deep-analysis.md")
workflow!.should.not.be.undefined()
})
it("should handle Windows-style paths", async () => {
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({
"C:\\Users\\test\\project\\.clinerules\\workflows\\windows-workflow.md": true,
})
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
const workflow = response.commands.find((cmd) => cmd.name === "windows-workflow.md")
workflow!.should.not.be.undefined()
})
})
describe("Global Workflow Toggles", () => {
it("should include enabled global workflows", async () => {
mockStateManager.getGlobalSettingsKey.withArgs("globalWorkflowToggles").returns({
"/global/path/global-workflow.md": true,
})
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
const workflow = response.commands.find((cmd) => cmd.name === "global-workflow.md")
workflow!.should.not.be.undefined()
workflow!.section.should.equal("custom")
})
it("should exclude disabled global workflows", async () => {
mockStateManager.getGlobalSettingsKey.withArgs("globalWorkflowToggles").returns({
"/global/path/disabled-global.md": false,
})
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
const workflow = response.commands.find((cmd) => cmd.name === "disabled-global.md")
;(workflow === undefined).should.be.true()
})
})
describe("Workflow Deduplication", () => {
it("should prefer local workflows over global workflows with same name", async () => {
// Same filename in both local and global
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({
"/local/path/shared-workflow.md": true,
})
mockStateManager.getGlobalSettingsKey.withArgs("globalWorkflowToggles").returns({
"/global/path/shared-workflow.md": true,
})
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
// Should only appear once
const matches = response.commands.filter((cmd) => cmd.name === "shared-workflow.md")
matches.length.should.equal(1)
})
it("should include global workflow if local with same name is disabled", async () => {
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({
"/local/path/shared-workflow.md": false, // disabled locally
})
mockStateManager.getGlobalSettingsKey.withArgs("globalWorkflowToggles").returns({
"/global/path/shared-workflow.md": true, // enabled globally
})
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")
workflow!.should.not.be.undefined()
})
})
describe("Remote Workflows", () => {
it("should include alwaysEnabled remote workflows", async () => {
mockStateManager.getRemoteConfigSettings.returns({
remoteGlobalWorkflows: [{ name: "always-on-workflow", alwaysEnabled: true }],
})
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
const workflow = response.commands.find((cmd) => cmd.name === "always-on-workflow")
workflow!.should.not.be.undefined()
workflow!.section.should.equal("custom")
})
it("should include remote workflows enabled by toggle", async () => {
mockStateManager.getRemoteConfigSettings.returns({
remoteGlobalWorkflows: [{ name: "toggle-workflow", alwaysEnabled: false }],
})
mockStateManager.getGlobalStateKey.withArgs("remoteWorkflowToggles").returns({
"toggle-workflow": true, // not explicitly disabled
})
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
const workflow = response.commands.find((cmd) => cmd.name === "toggle-workflow")
workflow!.should.not.be.undefined()
})
it("should exclude remote workflows explicitly disabled by toggle", async () => {
mockStateManager.getRemoteConfigSettings.returns({
remoteGlobalWorkflows: [{ name: "disabled-remote", alwaysEnabled: false }],
})
mockStateManager.getGlobalStateKey.withArgs("remoteWorkflowToggles").returns({
"disabled-remote": false,
})
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
const workflow = response.commands.find((cmd) => cmd.name === "disabled-remote")
;(workflow === undefined).should.be.true()
})
it("should include remote workflows by default if not explicitly disabled", async () => {
mockStateManager.getRemoteConfigSettings.returns({
remoteGlobalWorkflows: [{ name: "default-enabled", alwaysEnabled: false }],
})
// No toggle entry for this workflow
mockStateManager.getGlobalStateKey.withArgs("remoteWorkflowToggles").returns({})
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
const workflow = response.commands.find((cmd) => cmd.name === "default-enabled")
workflow!.should.not.be.undefined()
})
})
describe("Edge Cases", () => {
it("should handle null/undefined state values gracefully", async () => {
mockStateManager.getWorkspaceStateKey.returns(null)
mockStateManager.getGlobalSettingsKey.returns(undefined)
mockStateManager.getGlobalStateKey.returns(null)
mockStateManager.getRemoteConfigSettings.returns(null)
it("should only return base commands when feature-specific workflow state exists elsewhere", async () => {
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
// Should still return base commands
response.commands.length.should.be.greaterThanOrEqual(BASE_SLASH_COMMANDS.length)
})
it("should handle empty workflow toggle objects", async () => {
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({})
mockStateManager.getGlobalSettingsKey.withArgs("globalWorkflowToggles").returns({})
mockStateManager.getGlobalStateKey.withArgs("remoteWorkflowToggles").returns({})
mockStateManager.getRemoteConfigSettings.returns({
remoteGlobalWorkflows: [],
})
it("should not return any custom workflow commands", async () => {
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
// Should only have base commands
const customCommands = response.commands.filter((cmd) => cmd.section === "custom")
customCommands.should.have.length(0)
response.commands.length.should.equal(BASE_SLASH_COMMANDS.length)
})
it("should handle remote config with no remoteGlobalWorkflows property", async () => {
mockStateManager.getRemoteConfigSettings.returns({})
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
// Should not throw, just return base commands
response.commands.length.should.be.greaterThanOrEqual(BASE_SLASH_COMMANDS.length)
})
})
})
+24 -24
View File
@@ -198,7 +198,7 @@ describe("Filesystem Utilities", () => {
files.sort().should.deepEqual(expectedFiles.sort())
})
it("should exclude .clinerules/workflows directory specifically", async () => {
it("should exclude .clinerules/skills directory specifically", async () => {
// Create a test directory structure
const clinerulesDirTest = path.join(tmpDir, "clinerules-test")
const clinerulesDirPath = path.join(clinerulesDirTest, ".clinerules")
@@ -214,24 +214,24 @@ describe("Filesystem Utilities", () => {
await fs.writeFile(path.join(otherDirPath, "helper.js"), "// helper code")
await fs.writeFile(path.join(otherDirPath, "util.js"), "// util functions")
// Create .clinerules/workflows directory and files
const workflowsDirPath = path.join(clinerulesDirPath, "workflows")
await fs.mkdir(workflowsDirPath, { recursive: true })
await fs.writeFile(path.join(workflowsDirPath, "workflow1.js"), "// workflow1")
await fs.writeFile(path.join(workflowsDirPath, "workflow2.js"), "// workflow2")
// Create .clinerules/skills directory and files
const skillsDirPath = path.join(clinerulesDirPath, "skills")
await fs.mkdir(skillsDirPath, { recursive: true })
await fs.writeFile(path.join(skillsDirPath, "skill1.md"), "# skill1")
await fs.writeFile(path.join(skillsDirPath, "skill2.md"), "# skill2")
// Get all files WITHOUT exclusion
const allFiles = await readDirectory(clinerulesDirPath)
// Verify all files are included
allFiles.length.should.equal(6) // 2 in root + 2 in other + 2 in workflows
allFiles.some((file) => file.includes("workflow1.js")).should.be.true()
allFiles.some((file) => file.includes("workflow2.js")).should.be.true()
allFiles.length.should.equal(6) // 2 in root + 2 in other + 2 in skills
allFiles.some((file) => file.includes("skill1.md")).should.be.true()
allFiles.some((file) => file.includes("skill2.md")).should.be.true()
// Get files WITH workflows directory excluded
const filteredFiles = await readDirectory(clinerulesDirPath, [[".clinerules", "workflows"]])
// Get files WITH skills directory excluded
const filteredFiles = await readDirectory(clinerulesDirPath, [[".clinerules", "skills"]])
// Verify workflows files are excluded but others remain
// Verify skills files are excluded but others remain
filteredFiles.length.should.equal(4) // 2 in root + 2 in other
const expectedFiles = [
@@ -245,11 +245,11 @@ describe("Filesystem Utilities", () => {
// Test with multiple exclusions
const multiExcludeFiles = await readDirectory(clinerulesDirPath, [
[".clinerules", "workflows"],
[".clinerules", "skills"],
[".clinerules", "other"],
])
// Verify both workflows and other directories are excluded
// Verify both skills and other directories are excluded
multiExcludeFiles.length.should.equal(2) // only the 2 files in root
const rootOnlyFiles = [path.resolve(clinerulesDirPath, "config.json"), path.resolve(clinerulesDirPath, "settings.js")]
@@ -267,10 +267,10 @@ describe("Filesystem Utilities", () => {
await fs.writeFile(path.join(clinerulesDirPath, "config.json"), "{}")
await fs.writeFile(path.join(clinerulesDirPath, "settings.js"), "// settings")
// Create .clinerules/workflows directory and files
const workflowsDirPath = path.join(clinerulesDirPath, "workflows")
await fs.mkdir(workflowsDirPath, { recursive: true })
await fs.writeFile(path.join(workflowsDirPath, "workflow1.js"), "// workflow1")
// Create .clinerules/skills directory and files
const skillsDirPath = path.join(clinerulesDirPath, "skills")
await fs.mkdir(skillsDirPath, { recursive: true })
await fs.writeFile(path.join(skillsDirPath, "skill1.md"), "# skill1")
// Create .clinerules/hooks directory and files
const hooksDirPath = path.join(clinerulesDirPath, "hooks")
@@ -282,7 +282,7 @@ describe("Filesystem Utilities", () => {
const allFiles = await readDirectory(clinerulesDirPath)
// Verify all files are included
allFiles.length.should.equal(5) // 2 in root + 1 in workflows + 2 in hooks
allFiles.length.should.equal(5) // 2 in root + 1 in skills + 2 in hooks
allFiles.some((file) => file.includes("PreToolUse")).should.be.true()
allFiles.some((file) => file.includes("PostToolUse")).should.be.true()
@@ -290,23 +290,23 @@ describe("Filesystem Utilities", () => {
const filteredFiles = await readDirectory(clinerulesDirPath, [[".clinerules", "hooks"]])
// Verify hooks files are excluded but others remain
filteredFiles.length.should.equal(3) // 2 in root + 1 in workflows
filteredFiles.length.should.equal(3) // 2 in root + 1 in skills
const expectedFiles = [
path.resolve(clinerulesDirPath, "config.json"),
path.resolve(clinerulesDirPath, "settings.js"),
path.resolve(workflowsDirPath, "workflow1.js"),
path.resolve(skillsDirPath, "skill1.md"),
]
filteredFiles.sort().should.deepEqual(expectedFiles.sort())
// Test with multiple exclusions (both workflows and hooks)
// Test with multiple exclusions (both skills and hooks)
const multiExcludeFiles = await readDirectory(clinerulesDirPath, [
[".clinerules", "workflows"],
[".clinerules", "skills"],
[".clinerules", "hooks"],
])
// Verify both workflows and hooks directories are excluded
// Verify both skills and hooks directories are excluded
multiExcludeFiles.length.should.equal(2) // only the 2 files in root
const rootOnlyFiles = [path.resolve(clinerulesDirPath, "config.json"), path.resolve(clinerulesDirPath, "settings.js")]
@@ -1,6 +1,5 @@
{
"remoteRulesToggles": {},
"remoteWorkflowToggles": {},
"welcomeViewCompleted": true,
"actModeOpenRouterModelInfo": {
"name": "Anthropic: Claude Sonnet 4.6",
@@ -1,6 +1,5 @@
{
"remoteRulesToggles": {},
"remoteWorkflowToggles": {},
"actModeOpenRouterModelInfo": {
"name": "Anthropic: Claude Sonnet 4.6",
"maxTokens": 128000,
@@ -56,7 +55,6 @@
"enableNotifications": false
},
"primaryRootIndex": 0,
"globalWorkflowToggles": {},
"globalClineRulesToggles": {},
"isNewUser": false
}
@@ -31,7 +31,6 @@
"clineApiKey": null,
"taskHistory": [],
"remoteRulesToggles": {},
"remoteWorkflowToggles": {},
"actModeThinkingBudgetTokens": 0,
"planModeThinkingBudgetTokens": 0,
"welcomeViewCompleted": false
@@ -212,18 +212,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
},
ref,
) => {
const {
mode,
apiConfiguration,
openRouterModels,
platform,
localWorkflowToggles,
globalWorkflowToggles,
remoteWorkflowToggles,
remoteConfigSettings,
navigateToSettingsModelPicker,
mcpServers,
} = useExtensionState()
const { mode, apiConfiguration, openRouterModels, platform, navigateToSettingsModelPicker, mcpServers } =
useExtensionState()
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
const [isDraggingOver, setIsDraggingOver] = useState(false)
const [gitCommits, setGitCommits] = useState<GitCommit[]>([])
@@ -471,15 +461,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
event.preventDefault()
setSelectedSlashCommandsIndex((prevIndex) => {
const direction = event.key === "ArrowUp" ? -1 : 1
// Get commands with workflow toggles
const allCommands = getMatchingSlashCommands(
slashCommandsQuery,
localWorkflowToggles,
globalWorkflowToggles,
remoteWorkflowToggles,
remoteConfigSettings?.remoteGlobalWorkflows,
mcpServers,
)
const allCommands = getMatchingSlashCommands(slashCommandsQuery, mcpServers)
if (allCommands.length === 0) {
return prevIndex
@@ -497,14 +479,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
if ((event.key === "Enter" || event.key === "Tab") && selectedSlashCommandsIndex !== -1) {
event.preventDefault()
const commands = getMatchingSlashCommands(
slashCommandsQuery,
localWorkflowToggles,
globalWorkflowToggles,
remoteWorkflowToggles,
remoteConfigSettings?.remoteGlobalWorkflows,
mcpServers,
)
const commands = getMatchingSlashCommands(slashCommandsQuery, mcpServers)
if (commands.length > 0) {
handleSlashCommandsSelect(commands[selectedSlashCommandsIndex])
}
@@ -956,13 +931,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
// Extract just the command name (without the slash)
const commandName = command.substring(1)
const isValidCommand = validateSlashCommand(
commandName,
localWorkflowToggles,
globalWorkflowToggles,
remoteWorkflowToggles,
remoteConfigSettings?.remoteGlobalWorkflows,
)
const isValidCommand = validateSlashCommand(commandName, mcpServers)
if (isValidCommand) {
hasHighlightedSlashCommand = true
@@ -975,7 +944,7 @@ 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])
}, [mcpServers])
useLayoutEffect(() => {
updateHighlights()
@@ -1363,14 +1332,10 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
{showSlashCommandsMenu && (
<div ref={slashCommandsMenuContainerRef}>
<SlashCommandMenu
globalWorkflowToggles={globalWorkflowToggles}
localWorkflowToggles={localWorkflowToggles}
mcpServers={mcpServers}
onMouseDown={handleMenuMouseDown}
onSelect={handleSlashCommandsSelect}
query={slashCommandsQuery}
remoteWorkflows={remoteConfigSettings?.remoteGlobalWorkflows}
remoteWorkflowToggles={remoteWorkflowToggles}
selectedIndex={selectedSlashCommandsIndex}
setSelectedIndex={setSelectedSlashCommandsIndex}
/>
@@ -1492,7 +1457,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
/>
{!inputValue && selectedImages.length === 0 && selectedFiles.length === 0 && (
<div className="text-xs absolute bottom-5 left-6.5 right-16 text-(--vscode-input-placeholderForeground)/50 whitespace-nowrap overflow-hidden text-ellipsis pointer-events-none z-1">
Type @ for context, / for slash commands & workflows, hold shift to drag in files/images
Type @ for context, / for slash commands, hold shift to drag in files/images
</div>
)}
{(selectedImages.length > 0 || selectedFiles.length > 0) && (
@@ -1,8 +1,8 @@
import type { McpServer } from "@shared/mcp"
import type { SlashCommand } from "@/utils/slash-commands"
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 {
@@ -11,10 +11,6 @@ interface SlashCommandMenuProps {
setSelectedIndex: (index: number) => void
onMouseDown: () => void
query: string
localWorkflowToggles?: Record<string, boolean>
globalWorkflowToggles?: Record<string, boolean>
remoteWorkflowToggles?: Record<string, boolean>
remoteWorkflows?: any[]
mcpServers?: McpServer[]
}
@@ -24,25 +20,13 @@ const SlashCommandMenu: React.FC<SlashCommandMenuProps> = ({
setSelectedIndex,
onMouseDown,
query,
localWorkflowToggles = {},
globalWorkflowToggles = {},
remoteWorkflowToggles,
remoteWorkflows,
mcpServers = [],
}) => {
const menuRef = useRef<HTMLDivElement>(null)
// Filter commands based on query
const filteredCommands = getMatchingSlashCommands(
query,
localWorkflowToggles,
globalWorkflowToggles,
remoteWorkflowToggles,
remoteWorkflows,
mcpServers,
)
const filteredCommands = getMatchingSlashCommands(query, mcpServers)
const defaultCommands = filteredCommands.filter((cmd) => cmd.section === "default" || !cmd.section)
const workflowCommands = filteredCommands.filter((cmd) => cmd.section === "custom")
const mcpCommands = filteredCommands.filter((cmd) => cmd.section === "mcp")
// Screen reader announcements
@@ -139,13 +123,7 @@ const SlashCommandMenu: React.FC<SlashCommandMenuProps> = ({
{filteredCommands.length > 0 ? (
<>
{renderCommandSection(defaultCommands, "Default Commands", 0, true)}
{renderCommandSection(workflowCommands, "Workflow Commands", defaultCommands.length, false)}
{renderCommandSection(
mcpCommands,
"MCP Prompts",
defaultCommands.length + workflowCommands.length,
true,
)}
{renderCommandSection(mcpCommands, "MCP Prompts", defaultCommands.length, true)}
</>
) : (
<div aria-selected="false" className="py-2 px-3 cursor-default flex flex-col" role="option">
@@ -9,7 +9,6 @@ import {
ToggleCursorRuleRequest,
ToggleSkillRequest,
ToggleWindsurfRuleRequest,
ToggleWorkflowRequest,
} from "@shared/proto/cline/file"
import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import React, { useEffect, useRef, useState } from "react"
@@ -32,12 +31,9 @@ const ClineRulesToggleModal: React.FC = () => {
localCursorRulesToggles = {},
localWindsurfRulesToggles = {},
localAgentsRulesToggles = {},
localWorkflowToggles = {},
globalWorkflowToggles = {},
globalSkillsToggles = {},
localSkillsToggles = {},
remoteRulesToggles = {},
remoteWorkflowToggles = {},
remoteConfigSettings = {},
hooksEnabled,
setGlobalClineRulesToggles,
@@ -45,12 +41,9 @@ const ClineRulesToggleModal: React.FC = () => {
setLocalCursorRulesToggles,
setLocalWindsurfRulesToggles,
setLocalAgentsRulesToggles,
setLocalWorkflowToggles,
setGlobalWorkflowToggles,
setGlobalSkillsToggles,
setLocalSkillsToggles,
setRemoteRulesToggles,
setRemoteWorkflowToggles,
} = useExtensionState()
const [globalHooks, setGlobalHooks] = useState<Array<{ name: string; enabled: boolean; absolutePath: string }>>([])
const [workspaceHooks, setWorkspaceHooks] = useState<
@@ -66,7 +59,7 @@ const ClineRulesToggleModal: React.FC = () => {
const { width: viewportWidth, height: viewportHeight } = useWindowSize()
const [arrowPosition, setArrowPosition] = useState(0)
const [menuPosition, setMenuPosition] = useState(0)
const [currentView, setCurrentView] = useState<"rules" | "workflows" | "hooks" | "skills">("rules")
const [currentView, setCurrentView] = useState<"rules" | "hooks" | "skills">("rules")
// Auto-switch to rules tab if hooks become disabled while viewing hooks tab
useEffect(() => {
@@ -95,12 +88,6 @@ const ClineRulesToggleModal: React.FC = () => {
if (response.localAgentsRulesToggles?.toggles) {
setLocalAgentsRulesToggles(response.localAgentsRulesToggles.toggles)
}
if (response.localWorkflowToggles?.toggles) {
setLocalWorkflowToggles(response.localWorkflowToggles.toggles)
}
if (response.globalWorkflowToggles?.toggles) {
setGlobalWorkflowToggles(response.globalWorkflowToggles.toggles)
}
})
.catch((error) => {
console.error("Failed to refresh rules:", error)
@@ -110,10 +97,8 @@ const ClineRulesToggleModal: React.FC = () => {
isVisible,
setGlobalClineRulesToggles,
setLocalClineRulesToggles,
setGlobalWorkflowToggles,
setLocalCursorRulesToggles,
setLocalWindsurfRulesToggles,
setLocalWorkflowToggles,
])
// Refresh hooks when hooks tab becomes visible
@@ -213,21 +198,11 @@ const ClineRulesToggleModal: React.FC = () => {
.map(([path, enabled]): [string, boolean] => [path, enabled as boolean])
.sort(([a], [b]) => a.localeCompare(b))
const localWorkflows = Object.entries(localWorkflowToggles || {})
.map(([path, enabled]): [string, boolean] => [path, enabled as boolean])
.sort(([a], [b]) => a.localeCompare(b))
const globalWorkflows = Object.entries(globalWorkflowToggles || {})
.map(([path, enabled]): [string, boolean] => [path, enabled as boolean])
.sort(([a], [b]) => a.localeCompare(b))
// Get remote rules and workflows from remote config
// Get remote rules from remote config
const remoteGlobalRules = remoteConfigSettings.remoteGlobalRules || []
const remoteGlobalWorkflows = remoteConfigSettings.remoteGlobalWorkflows || []
// Check if we have any remote rules or workflows
// Check if we have any remote rules
const hasRemoteRules = remoteGlobalRules.length > 0
const hasRemoteWorkflows = remoteGlobalWorkflows.length > 0
// Handle toggle rule using gRPC
const toggleRule = (isGlobal: boolean, rulePath: string, enabled: boolean) => {
@@ -325,28 +300,6 @@ const ClineRulesToggleModal: React.FC = () => {
})
}
const toggleWorkflow = (isGlobal: boolean, workflowPath: string, enabled: boolean) => {
FileServiceClient.toggleWorkflow(
ToggleWorkflowRequest.create({
workflowPath,
enabled,
scope: isGlobal ? RuleScope.GLOBAL : RuleScope.LOCAL,
}),
)
.then((response) => {
if (response.toggles) {
if (isGlobal) {
setGlobalWorkflowToggles(response.toggles)
} else {
setLocalWorkflowToggles(response.toggles)
}
}
})
.catch((err: Error) => {
console.error("Failed to toggle workflow:", err)
})
}
// Handle toggle for remote rules
const toggleRemoteRule = (ruleName: string, enabled: boolean) => {
FileServiceClient.toggleClineRule(
@@ -367,25 +320,6 @@ const ClineRulesToggleModal: React.FC = () => {
})
}
// Handle toggle for remote workflows
const toggleRemoteWorkflow = (workflowName: string, enabled: boolean) => {
FileServiceClient.toggleWorkflow(
ToggleWorkflowRequest.create({
workflowPath: workflowName,
enabled,
scope: RuleScope.REMOTE,
}),
)
.then((response) => {
if (response.toggles) {
setRemoteWorkflowToggles(response.toggles)
}
})
.catch((error) => {
console.error("Error toggling remote workflow:", error)
})
}
// Handle toggle for skills
const toggleSkill = (isGlobal: boolean, skillPath: string, enabled: boolean) => {
FileServiceClient.toggleSkill(
@@ -435,11 +369,11 @@ const ClineRulesToggleModal: React.FC = () => {
<div className="inline-flex min-w-0 max-w-full items-center" ref={modalRef}>
<div className="inline-flex w-full items-center" ref={buttonRef}>
<Tooltip>
{!isVisible && <TooltipContent>Manage Cline Rules & Workflows</TooltipContent>}
{!isVisible && <TooltipContent>Manage Cline Rules, Hooks & Skills</TooltipContent>}
<TooltipTrigger>
<VSCodeButton
appearance="icon"
aria-label={isVisible ? "Hide Cline Rules & Workflows" : "Show Cline Rules & Workflows"}
aria-label={isVisible ? "Hide Cline Rules, Hooks & Skills" : "Show Cline Rules, Hooks & Skills"}
className="p-0 m-0 flex items-center"
onClick={() => setIsVisible(!isVisible)}>
<i className="codicon codicon-law" style={{ fontSize: "12.5px" }} />
@@ -470,9 +404,6 @@ const ClineRulesToggleModal: React.FC = () => {
<TabButton isActive={currentView === "rules"} onClick={() => setCurrentView("rules")}>
Rules
</TabButton>
<TabButton isActive={currentView === "workflows"} onClick={() => setCurrentView("workflows")}>
Workflows
</TabButton>
{hooksEnabled && (
<TabButton isActive={currentView === "hooks"} onClick={() => setCurrentView("hooks")}>
Hooks
@@ -485,14 +416,10 @@ const ClineRulesToggleModal: React.FC = () => {
</div>
{/* Remote config banner */}
{(currentView === "rules" && hasRemoteRules) || (currentView === "workflows" && hasRemoteWorkflows) ? (
{currentView === "rules" && hasRemoteRules ? (
<div className="flex items-center gap-2 px-3 py-3 mb-4 bg-vscode-textBlockQuote-background border-l-[3px] border-vscode-textLink-foreground">
<i className="codicon codicon-lock text-sm" />
<span className="text-base">
{currentView === "rules"
? "Your organization manages some rules"
: "Your organization manages some workflows"}
</span>
<span className="text-base">Your organization manages some rules</span>
</div>
) : null}
@@ -509,17 +436,6 @@ const ClineRulesToggleModal: React.FC = () => {
Docs
</VSCodeLink>
</p>
) : currentView === "workflows" ? (
<p>
Workflows allow you to define a series of steps to guide Cline through a repetitive set of
tasks, such as deploying a service or submitting a PR. To invoke a workflow, type{" "}
<span className="text-foreground font-bold">/workflow-name</span> in the chat.{" "}
<VSCodeLink
className="text-xs inline"
href="https://docs.cline.bot/features/slash-commands/workflows">
Docs
</VSCodeLink>
</p>
) : currentView === "skills" ? (
<p>
Skills are reusable instruction sets that Cline can activate on-demand. When a task matches a
@@ -621,63 +537,6 @@ const ClineRulesToggleModal: React.FC = () => {
/>
</div>
</>
) : currentView === "workflows" ? (
<>
{/* Remote Workflows Section */}
{hasRemoteWorkflows && (
<div className="mb-3">
<div className="text-sm font-normal mb-2">Enterprise Workflows</div>
<div className="flex flex-col gap-0">
{remoteGlobalWorkflows.map((workflow) => {
const enabled =
workflow.alwaysEnabled || remoteWorkflowToggles[workflow.name] === true
return (
<RuleRow
alwaysEnabled={workflow.alwaysEnabled}
enabled={enabled}
isGlobal={false}
isRemote={true}
key={workflow.name}
rulePath={workflow.name}
ruleType="workflow"
toggleRule={toggleRemoteWorkflow}
/>
)
})}
</div>
</div>
)}
{/* Global Workflows Section */}
<div className="mb-3">
<div className="text-sm font-normal mb-2">Global Workflows</div>
{/* File-based Global Workflows */}
<RulesToggleList
isGlobal={true}
listGap="small"
rules={globalWorkflows}
ruleType={"workflow"}
showNewRule={true}
showNoRules={false}
toggleRule={(rulePath, enabled) => toggleWorkflow(true, rulePath, enabled)}
/>
</div>
{/* Local Workflows Section */}
<div className="-mb-2.5">
<div className="text-sm font-normal mb-2">Workspace Workflows</div>
<RulesToggleList
isGlobal={false}
listGap="small"
rules={localWorkflows}
ruleType={"workflow"}
showNewRule={true}
showNoRules={false}
toggleRule={(rulePath, enabled) => toggleWorkflow(false, rulePath, enabled)}
/>
</div>
</>
) : currentView === "hooks" ? (
<>
<div className="text-xs text-description mb-4">
@@ -75,12 +75,9 @@ export interface ExtensionStateContextType extends ExtensionState {
setLocalCursorRulesToggles: (toggles: Record<string, boolean>) => void
setLocalWindsurfRulesToggles: (toggles: Record<string, boolean>) => void
setLocalAgentsRulesToggles: (toggles: Record<string, boolean>) => void
setLocalWorkflowToggles: (toggles: Record<string, boolean>) => void
setGlobalWorkflowToggles: (toggles: Record<string, boolean>) => void
setGlobalSkillsToggles: (toggles: Record<string, boolean>) => void
setLocalSkillsToggles: (toggles: Record<string, boolean>) => void
setRemoteRulesToggles: (toggles: Record<string, boolean>) => void
setRemoteWorkflowToggles: (toggles: Record<string, boolean>) => void
setMcpMarketplaceCatalog: (value: McpMarketplaceCatalog) => void
setTotalTasksSize: (value: number | null) => void
setExpandTaskHeader: (value: boolean) => void
@@ -248,8 +245,6 @@ export const ExtensionStateContextProvider: React.FC<{
localCursorRulesToggles: {},
localWindsurfRulesToggles: {},
localAgentsRulesToggles: {},
localWorkflowToggles: {},
globalWorkflowToggles: {},
shellIntegrationTimeout: 4000,
terminalReuseEnabled: true,
vscodeTerminalExecutionMode: "vscodeTerminal",
@@ -816,10 +811,7 @@ export const ExtensionStateContextProvider: React.FC<{
localCursorRulesToggles: state.localCursorRulesToggles || {},
localWindsurfRulesToggles: state.localWindsurfRulesToggles || {},
localAgentsRulesToggles: state.localAgentsRulesToggles || {},
localWorkflowToggles: state.localWorkflowToggles || {},
globalWorkflowToggles: state.globalWorkflowToggles || {},
remoteRulesToggles: state.remoteRulesToggles || {},
remoteWorkflowToggles: state.remoteWorkflowToggles || {},
enableCheckpointsSetting: state.enableCheckpointsSetting,
currentFocusChainChecklist: state.currentFocusChainChecklist,
@@ -879,16 +871,6 @@ export const ExtensionStateContextProvider: React.FC<{
...prevState,
localAgentsRulesToggles: toggles,
})),
setLocalWorkflowToggles: (toggles) =>
setState((prevState) => ({
...prevState,
localWorkflowToggles: toggles,
})),
setGlobalWorkflowToggles: (toggles) =>
setState((prevState) => ({
...prevState,
globalWorkflowToggles: toggles,
})),
setGlobalSkillsToggles: (toggles) =>
setState((prevState) => ({
...prevState,
@@ -904,11 +886,6 @@ export const ExtensionStateContextProvider: React.FC<{
...prevState,
remoteRulesToggles: toggles,
})),
setRemoteWorkflowToggles: (toggles) =>
setState((prevState) => ({
...prevState,
remoteWorkflowToggles: toggles,
})),
setMcpTab,
setTotalTasksSize,
refreshClineModels,
@@ -151,27 +151,32 @@ describe("slash-commands", () => {
]
it("should include MCP commands in results when no query", () => {
const result = getMatchingSlashCommands("", {}, {}, undefined, undefined, mcpServers)
const result = getMatchingSlashCommands("", mcpServers)
const mcpCommands = result.filter((cmd) => cmd.section === "mcp")
expect(mcpCommands).toHaveLength(2)
})
it("should filter MCP commands by query prefix", () => {
const result = getMatchingSlashCommands("mcp:test", {}, {}, undefined, undefined, mcpServers)
const result = getMatchingSlashCommands("mcp:test", mcpServers)
const mcpCommands = result.filter((cmd) => cmd.section === "mcp")
expect(mcpCommands).toHaveLength(2)
})
it("should filter to specific MCP prompt", () => {
const result = getMatchingSlashCommands("mcp:test-server:sum", {}, {}, undefined, undefined, mcpServers)
const result = getMatchingSlashCommands("mcp:test-server:sum", mcpServers)
expect(result).toHaveLength(1)
expect(result[0].name).toBe("mcp:test-server:summarize")
})
it("should return empty for non-matching MCP query", () => {
const result = getMatchingSlashCommands("mcp:nonexistent", {}, {}, undefined, undefined, mcpServers)
const result = getMatchingSlashCommands("mcp:nonexistent", mcpServers)
expect(result).toHaveLength(0)
})
it("should not treat workflow-like filenames as available commands", () => {
const result = getMatchingSlashCommands("release", mcpServers)
expect(result.some((cmd) => cmd.name === "release.md")).toBe(false)
})
})
describe("validateSlashCommand with MCP servers", () => {
@@ -183,22 +188,27 @@ describe("slash-commands", () => {
]
it("should return full for exact MCP command match", () => {
const result = validateSlashCommand("mcp:server:prompt", {}, {}, undefined, undefined, mcpServers)
const result = validateSlashCommand("mcp:server:prompt", mcpServers)
expect(result).toBe("full")
})
it("should return partial for partial MCP command match", () => {
const result = validateSlashCommand("mcp:server:pro", {}, {}, undefined, undefined, mcpServers)
const result = validateSlashCommand("mcp:server:pro", mcpServers)
expect(result).toBe("partial")
})
it("should return partial for server prefix only", () => {
const result = validateSlashCommand("mcp:serv", {}, {}, undefined, undefined, mcpServers)
const result = validateSlashCommand("mcp:serv", mcpServers)
expect(result).toBe("partial")
})
it("should return null for non-matching MCP command", () => {
const result = validateSlashCommand("mcp:unknown:cmd", {}, {}, undefined, undefined, mcpServers)
const result = validateSlashCommand("mcp:unknown:cmd", mcpServers)
expect(result).toBe(null)
})
it("should return null for workflow-like filenames", () => {
const result = validateSlashCommand("release.md", mcpServers)
expect(result).toBe(null)
})
})
+4 -93
View File
@@ -7,69 +7,6 @@ export type { SlashCommand }
export const DEFAULT_SLASH_COMMANDS: SlashCommand[] =
PLATFORM_CONFIG.type === PlatformType.VSCODE ? [...BASE_SLASH_COMMANDS, ...VSCODE_ONLY_COMMANDS] : BASE_SLASH_COMMANDS
export function getWorkflowCommands(
localWorkflowToggles: Record<string, boolean>,
globalWorkflowToggles: Record<string, boolean>,
remoteWorkflowToggles?: Record<string, boolean>,
remoteWorkflows?: any[],
): SlashCommand[] {
const { workflows: localWorkflows, nameSet: localWorkflowNames } = Object.entries(localWorkflowToggles)
.filter(([_, enabled]) => enabled)
.reduce(
(acc, [filePath, _]) => {
const fileName = filePath.replace(/^.*[/\\]/, "")
// Add to array of workflows
acc.workflows.push({
name: fileName,
section: "custom",
} as SlashCommand)
// Add to set of names
acc.nameSet.add(fileName)
return acc
},
{ workflows: [] as SlashCommand[], nameSet: new Set<string>() },
)
const globalWorkflows = Object.entries(globalWorkflowToggles)
.filter(([_, enabled]) => enabled)
.flatMap(([filePath, _]) => {
const fileName = filePath.replace(/^.*[/\\]/, "")
// skip if a local workflow with the same name exists
if (localWorkflowNames.has(fileName)) {
return []
}
return [
{
name: fileName,
section: "custom",
},
] as SlashCommand[]
})
// Add remote workflows that are enabled
const remoteWorkflowCommands: SlashCommand[] = []
if (remoteWorkflows && remoteWorkflowToggles) {
for (const workflow of remoteWorkflows) {
// Include if alwaysEnabled or if toggle is not explicitly false
const enabled = workflow.alwaysEnabled || remoteWorkflowToggles[workflow.name] !== false
if (enabled) {
remoteWorkflowCommands.push({
name: workflow.name,
section: "custom",
})
}
}
}
const workflows = [...localWorkflows, ...globalWorkflows, ...remoteWorkflowCommands]
return workflows
}
/**
* Gets MCP prompt commands from connected MCP servers
* Format: mcp:<server-name>:<prompt-name>
@@ -174,22 +111,9 @@ export function shouldShowSlashCommandsMenu(text: string, cursorPosition: number
/**
* Gets filtered slash commands that match the current input
*/
export function getMatchingSlashCommands(
query: string,
localWorkflowToggles: Record<string, boolean> = {},
globalWorkflowToggles: Record<string, boolean> = {},
remoteWorkflowToggles?: Record<string, boolean>,
remoteWorkflows?: any[],
mcpServers: McpServer[] = [],
): SlashCommand[] {
const workflowCommands = getWorkflowCommands(
localWorkflowToggles,
globalWorkflowToggles,
remoteWorkflowToggles,
remoteWorkflows,
)
export function getMatchingSlashCommands(query: string, mcpServers: McpServer[] = []): SlashCommand[] {
const mcpPromptCommands = getMcpPromptCommands(mcpServers)
const allCommands = [...DEFAULT_SLASH_COMMANDS, ...workflowCommands, ...mcpPromptCommands]
const allCommands = [...DEFAULT_SLASH_COMMANDS, ...mcpPromptCommands]
if (!query) {
return allCommands
@@ -226,26 +150,13 @@ export function insertSlashCommand(
* Determines the validation state of a slash command
* Returns partial if we have a partial match against valid commands, or full for full match
*/
export function validateSlashCommand(
command: string,
localWorkflowToggles: Record<string, boolean> = {},
globalWorkflowToggles: Record<string, boolean> = {},
remoteWorkflowToggles?: Record<string, boolean>,
remoteWorkflows?: any[],
mcpServers: McpServer[] = [],
): "full" | "partial" | null {
export function validateSlashCommand(command: string, mcpServers: McpServer[] = []): "full" | "partial" | null {
if (!command) {
return null
}
const workflowCommands = getWorkflowCommands(
localWorkflowToggles,
globalWorkflowToggles,
remoteWorkflowToggles,
remoteWorkflows,
)
const mcpPromptCommands = getMcpPromptCommands(mcpServers)
const allCommands = [...DEFAULT_SLASH_COMMANDS, ...workflowCommands, ...mcpPromptCommands]
const allCommands = [...DEFAULT_SLASH_COMMANDS, ...mcpPromptCommands]
// case insensitive matching
const exactMatch = allCommands.some((cmd) => cmd.name.toLowerCase() === command.toLowerCase())