From 731b88caf06960d2f48a8519dbc8a3e692429f3c Mon Sep 17 00:00:00 2001 From: James Date: Sat, 3 Jan 2026 02:32:08 +0100 Subject: [PATCH 01/10] update clineProvider.ts --- src/core/webview/ClineProvider.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 32305755704..679ff8e802d 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -92,6 +92,7 @@ import { VirtualQuotaFallbackHandler } from "../../api/providers/virtual-quota-f import { ContextProxy } from "../config/ContextProxy" import { getEnabledRules } from "./kilorules" +import { refreshWorkflowToggles } from "../context/instructions/workflows" import { ProviderSettingsManager } from "../config/ProviderSettingsManager" import { CustomModesManager } from "../config/CustomModesManager" import { Task } from "../task/Task" @@ -1951,6 +1952,8 @@ export class ClineProvider async postRulesDataToWebview() { const workspacePath = this.cwd if (workspacePath) { + // Refresh workflow toggles to ensure newly created workflow files are recognized + await refreshWorkflowToggles(this.context, workspacePath) this.postMessageToWebview({ type: "rulesData", ...(await getEnabledRules(workspacePath, this.contextProxy, this.context)), From 77e291c0d81fadab4b39afa60a8c99d9e1796c4e Mon Sep 17 00:00:00 2001 From: James Date: Thu, 15 Jan 2026 21:49:22 +0100 Subject: [PATCH 02/10] Resolve merge conflicts in RunSlashCommandTool.ts - Keep both import and kilocode_change end marker - Accept upstream's workflow.source and workflow.description - Keep mode switching functionality using workflow.mode - Add mode property to Workflow interface - Update frontmatter parsing to extract mode property --- .changeset/workflow-auto-experiment.md | 5 + .changeset/workflow-execution-tool.md | 5 + packages/types/src/experiment.ts | 4 +- .../__tests__/filter-tools-for-mode.spec.ts | 2 +- .../prompts/tools/filter-tools-for-mode.ts | 4 +- src/core/prompts/tools/index.ts | 2 +- .../tools/native-tools/run_slash_command.ts | 8 +- src/core/tools/RunSlashCommandTool.ts | 75 ++- .../__tests__/runSlashCommandTool.spec.ts | 138 +++-- .../workflow/__tests__/workflows.spec.ts | 115 +++++ src/services/workflow/workflows.ts | 370 ++++++++++++++ src/shared/__tests__/experiments.spec.ts | 6 +- src/shared/experiments.ts | 4 +- webview-ui/src/components/chat/ChatRow.tsx | 152 +----- .../src/components/chat/SlashCommandItem.tsx | 173 ++++++- .../chat/__tests__/SlashCommandItem.spec.tsx | 477 ++++++++++++++++++ webview-ui/src/i18n/locales/en/chat.json | 4 +- 17 files changed, 1302 insertions(+), 242 deletions(-) create mode 100644 .changeset/workflow-auto-experiment.md create mode 100644 .changeset/workflow-execution-tool.md create mode 100644 src/services/workflow/__tests__/workflows.spec.ts create mode 100644 src/services/workflow/workflows.ts create mode 100644 webview-ui/src/components/chat/__tests__/SlashCommandItem.spec.tsx diff --git a/.changeset/workflow-auto-experiment.md b/.changeset/workflow-auto-experiment.md new file mode 100644 index 00000000000..c7d1366d84c --- /dev/null +++ b/.changeset/workflow-auto-experiment.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Separate workflow discovery from auto-execution. Workflow discovery is now always available, while auto-execution without approval is controlled by the `autoExecuteWorkflow` experiment flag. diff --git a/.changeset/workflow-execution-tool.md b/.changeset/workflow-execution-tool.md new file mode 100644 index 00000000000..bf1f35acae6 --- /dev/null +++ b/.changeset/workflow-execution-tool.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Implement workflow execution tool for Kilo Code. Added workflow discovery service in `.kilocode/workflows/`, adapted RunSlashCommandTool to use workflows instead of commands, and created sample workflows for common tasks. diff --git a/packages/types/src/experiment.ts b/packages/types/src/experiment.ts index 403aa3f4d91..9584928d4b1 100644 --- a/packages/types/src/experiment.ts +++ b/packages/types/src/experiment.ts @@ -12,7 +12,7 @@ export const experimentIds = [ "multiFileApplyDiff", "preventFocusDisruption", "imageGeneration", - "runSlashCommand", + "autoExecuteWorkflow", "multipleNativeToolCalls", "customTools", ] as const @@ -32,7 +32,7 @@ export const experimentsSchema = z.object({ multiFileApplyDiff: z.boolean().optional(), preventFocusDisruption: z.boolean().optional(), imageGeneration: z.boolean().optional(), - runSlashCommand: z.boolean().optional(), + autoExecuteWorkflow: z.boolean().optional(), multipleNativeToolCalls: z.boolean().optional(), customTools: z.boolean().optional(), }) diff --git a/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts b/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts index 6a371a731d3..f69f9a897e4 100644 --- a/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts +++ b/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts @@ -417,7 +417,7 @@ describe("filterNativeToolsForMode", () => { toolsWithSlashCommand, "code", [codeMode], - { runSlashCommand: false }, + { autoExecuteWorkflow: false }, undefined, {}, undefined, diff --git a/src/core/prompts/tools/filter-tools-for-mode.ts b/src/core/prompts/tools/filter-tools-for-mode.ts index 5d72b089bf7..b57aae0c61c 100644 --- a/src/core/prompts/tools/filter-tools-for-mode.ts +++ b/src/core/prompts/tools/filter-tools-for-mode.ts @@ -314,7 +314,7 @@ export function filterNativeToolsForMode( } // Conditionally exclude run_slash_command if experiment is not enabled - if (!experiments?.runSlashCommand) { + if (!experiments?.autoExecuteWorkflow) { allowedToolNames.delete("run_slash_command") } @@ -415,7 +415,7 @@ export function isToolAllowedInMode( return experiments?.imageGeneration === true } if (toolName === "run_slash_command") { - return experiments?.runSlashCommand === true + return experiments?.autoExecuteWorkflow === true } return true } diff --git a/src/core/prompts/tools/index.ts b/src/core/prompts/tools/index.ts index 16902232425..d0bb7b0b5f6 100644 --- a/src/core/prompts/tools/index.ts +++ b/src/core/prompts/tools/index.ts @@ -163,7 +163,7 @@ export function getToolDescriptionsForMode( } // Conditionally exclude run_slash_command if experiment is not enabled - if (!experiments?.runSlashCommand) { + if (!experiments?.autoExecuteWorkflow) { tools.delete("run_slash_command") } diff --git a/src/core/prompts/tools/native-tools/run_slash_command.ts b/src/core/prompts/tools/native-tools/run_slash_command.ts index 71bf2528ddc..f84840ef3f9 100644 --- a/src/core/prompts/tools/native-tools/run_slash_command.ts +++ b/src/core/prompts/tools/native-tools/run_slash_command.ts @@ -1,10 +1,12 @@ +// kilocode_change start import type OpenAI from "openai" -const RUN_SLASH_COMMAND_DESCRIPTION = `Execute a slash command to get specific instructions or content. Slash commands are predefined templates that provide detailed guidance for common tasks.` +const RUN_SLASH_COMMAND_DESCRIPTION = `Execute a workflow to get specific instructions or content. Workflows are predefined templates stored in .kilocode/workflows/ that provide detailed guidance for common tasks. Always shows workflow content; requires user approval unless auto-execute experiment is enabled.` -const COMMAND_PARAMETER_DESCRIPTION = `Name of the slash command to run (e.g., init, test, deploy)` +const COMMAND_PARAMETER_DESCRIPTION = `Name of the workflow to execute (without .md extension)` -const ARGS_PARAMETER_DESCRIPTION = `Optional additional context or arguments for the command` +const ARGS_PARAMETER_DESCRIPTION = `Optional additional arguments or context to pass to the workflow` +// kilocode_change end export default { type: "function", diff --git a/src/core/tools/RunSlashCommandTool.ts b/src/core/tools/RunSlashCommandTool.ts index 69cb9dde95b..661f778f412 100644 --- a/src/core/tools/RunSlashCommandTool.ts +++ b/src/core/tools/RunSlashCommandTool.ts @@ -1,10 +1,12 @@ +// kilocode_change start import { Task } from "../task/Task" import { formatResponse } from "../prompts/responses" -import { getCommand, getCommandNames } from "../../services/command/commands" +import { getWorkflow, getWorkflowNames } from "../../services/workflow/workflows" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { BaseTool, ToolCallbacks } from "./BaseTool" import type { ToolUse } from "../../shared/tools" import { getModeBySlug } from "../../shared/modes" +// kilocode_change end interface RunSlashCommandParams { command: string @@ -25,23 +27,14 @@ export class RunSlashCommandTool extends BaseTool<"run_slash_command"> { const { command: commandName, args } = params const { askApproval, handleError, pushToolResult, toolProtocol } = callbacks - // Check if run slash command experiment is enabled + // Check if auto-execute workflow experiment is enabled const provider = task.providerRef.deref() const state = await provider?.getState() - const isRunSlashCommandEnabled = experiments.isEnabled( + const isAutoExecuteEnabled = experiments.isEnabled( state?.experiments ?? {}, - EXPERIMENT_IDS.RUN_SLASH_COMMAND, + EXPERIMENT_IDS.AUTO_EXECUTE_WORKFLOW, ) - if (!isRunSlashCommandEnabled) { - pushToolResult( - formatResponse.toolError( - "Run slash command is an experimental feature that must be enabled in settings. Please enable 'Run Slash Command' in the Experimental Settings section.", - ), - ) - return - } - try { if (!commandName) { task.consecutiveMistakeCount++ @@ -53,17 +46,17 @@ export class RunSlashCommandTool extends BaseTool<"run_slash_command"> { task.consecutiveMistakeCount = 0 - // Get the command from the commands service - const command = await getCommand(task.cwd, commandName) + // Get the workflow from the workflows service + const workflow = await getWorkflow(task.cwd, commandName) - if (!command) { - // Get available commands for error message - const availableCommands = await getCommandNames(task.cwd) + if (!workflow) { + // Get available workflows for error message + const availableWorkflows = await getWorkflowNames(task.cwd) task.recordToolError("run_slash_command") task.didToolFailInCurrentTurn = true pushToolResult( formatResponse.toolError( - `Command '${commandName}' not found. Available commands: ${availableCommands.join(", ") || "(none)"}`, + `Workflow '${commandName}' not found. Available workflows: ${availableWorkflows.join(", ") || "(none)"}`, ), ) return @@ -73,49 +66,53 @@ export class RunSlashCommandTool extends BaseTool<"run_slash_command"> { tool: "runSlashCommand", command: commandName, args: args, - source: command.source, - description: command.description, - mode: command.mode, + source: workflow.source, + description: workflow.description, + mode: workflow.mode, }) - const didApprove = await askApproval("tool", toolMessage) + // If auto-execute is disabled, ask for approval + // If auto-execute is enabled, skip approval and execute immediately + if (!isAutoExecuteEnabled) { + const didApprove = await askApproval("tool", toolMessage) - if (!didApprove) { - return + if (!didApprove) { + return + } } - // Switch mode if specified in the command frontmatter - if (command.mode) { + // Switch mode if specified in the workflow frontmatter + if (workflow.mode) { const provider = task.providerRef.deref() - const targetMode = getModeBySlug(command.mode, (await provider?.getState())?.customModes) + const targetMode = getModeBySlug(workflow.mode, (await provider?.getState())?.customModes) if (targetMode) { - await provider?.handleModeSwitch(command.mode) + await provider?.handleModeSwitch(workflow.mode) } } // Build the result message - let result = `Command: /${commandName}` + let result = `Workflow: /${commandName}` - if (command.description) { - result += `\nDescription: ${command.description}` + if (workflow.description) { + result += `\nDescription: ${workflow.description}` } - if (command.argumentHint) { - result += `\nArgument hint: ${command.argumentHint}` + if (workflow.arguments) { + result += `\nArguments: ${workflow.arguments}` } - if (command.mode) { - result += `\nMode: ${command.mode}` + if (workflow.mode) { + result += `\nMode: ${workflow.mode}` } if (args) { result += `\nProvided arguments: ${args}` } - result += `\nSource: ${command.source}` - result += `\n\n--- Command Content ---\n\n${command.content}` + result += `\nSource: ${workflow.source}` + result += `\n\n--- Workflow Content ---\n\n${workflow.content}` - // Return the command content as the tool result + // Return the workflow content as the tool result pushToolResult(result) } catch (error) { await handleError("running slash command", error as Error) diff --git a/src/core/tools/__tests__/runSlashCommandTool.spec.ts b/src/core/tools/__tests__/runSlashCommandTool.spec.ts index eef6259deb5..86213cd2cc1 100644 --- a/src/core/tools/__tests__/runSlashCommandTool.spec.ts +++ b/src/core/tools/__tests__/runSlashCommandTool.spec.ts @@ -1,15 +1,17 @@ +// kilocode_change start import { describe, it, expect, vi, beforeEach } from "vitest" import { runSlashCommandTool } from "../RunSlashCommandTool" import { Task } from "../../task/Task" import { formatResponse } from "../../prompts/responses" -import { getCommand, getCommandNames } from "../../../services/command/commands" +import { getWorkflow, getWorkflowNames } from "../../../services/workflow/workflows" import type { ToolUse } from "../../../shared/tools" // Mock dependencies -vi.mock("../../../services/command/commands", () => ({ - getCommand: vi.fn(), - getCommandNames: vi.fn(), +vi.mock("../../../services/workflow/workflows", () => ({ + getWorkflow: vi.fn(), + getWorkflowNames: vi.fn(), })) +// kilocode_change end describe("runSlashCommandTool", () => { let mockTask: any @@ -28,7 +30,7 @@ describe("runSlashCommandTool", () => { deref: vi.fn().mockReturnValue({ getState: vi.fn().mockResolvedValue({ experiments: { - runSlashCommand: true, + autoExecuteWorkflow: false, }, }), }), @@ -59,7 +61,7 @@ describe("runSlashCommandTool", () => { expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith("Missing parameter error") }) - it("should handle command not found", async () => { + it("should handle workflow not found", async () => { const block: ToolUse<"run_slash_command"> = { type: "tool_use" as const, name: "run_slash_command" as const, @@ -69,18 +71,18 @@ describe("runSlashCommandTool", () => { partial: false, } - vi.mocked(getCommand).mockResolvedValue(undefined) - vi.mocked(getCommandNames).mockResolvedValue(["init", "test", "deploy"]) + vi.mocked(getWorkflow).mockResolvedValue(undefined) + vi.mocked(getWorkflowNames).mockResolvedValue(["init", "test", "deploy"]) await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) expect(mockTask.recordToolError).toHaveBeenCalledWith("run_slash_command") expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith( - formatResponse.toolError("Command 'nonexistent' not found. Available commands: init, test, deploy"), + formatResponse.toolError("Workflow 'nonexistent' not found. Available workflows: init, test, deploy"), ) }) - it("should handle user rejection", async () => { + it("should ask for approval when auto-execute is disabled", async () => { const block: ToolUse<"run_slash_command"> = { type: "tool_use" as const, name: "run_slash_command" as const, @@ -90,15 +92,15 @@ describe("runSlashCommandTool", () => { partial: false, } - const mockCommand = { + const mockWorkflow = { name: "init", content: "Initialize project", - source: "built-in" as const, - filePath: "", + source: "project" as const, + filePath: ".kilocode/workflows/init.md", description: "Initialize the project", } - vi.mocked(getCommand).mockResolvedValue(mockCommand) + vi.mocked(getWorkflow).mockResolvedValue(mockWorkflow) mockCallbacks.askApproval.mockResolvedValue(false) await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) @@ -107,7 +109,7 @@ describe("runSlashCommandTool", () => { expect(mockCallbacks.pushToolResult).not.toHaveBeenCalled() }) - it("should successfully execute built-in command", async () => { + it("should auto-execute when auto-execute experiment is enabled", async () => { const block: ToolUse<"run_slash_command"> = { type: "tool_use" as const, name: "run_slash_command" as const, @@ -117,15 +119,57 @@ describe("runSlashCommandTool", () => { partial: false, } - const mockCommand = { + const mockWorkflow = { + name: "init", + content: "Initialize project", + source: "project" as const, + filePath: ".kilocode/workflows/init.md", + description: "Initialize the project", + } + + vi.mocked(getWorkflow).mockResolvedValue(mockWorkflow) + + // Mock task with auto-execute enabled + const mockTaskWithAutoExecute = { + ...mockTask, + providerRef: { + deref: vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + experiments: { + autoExecuteWorkflow: true, + }, + }), + }), + }, + } + + await runSlashCommandTool.handle(mockTaskWithAutoExecute as Task, block, mockCallbacks) + + // Should not ask for approval when auto-execute is enabled + expect(mockCallbacks.askApproval).not.toHaveBeenCalled() + // Should still push the workflow result + expect(mockCallbacks.pushToolResult).toHaveBeenCalled() + }) + + it("should successfully execute project workflow", async () => { + const block: ToolUse<"run_slash_command"> = { + type: "tool_use" as const, + name: "run_slash_command" as const, + params: { + command: "init", + }, + partial: false, + } + + const mockWorkflow = { name: "init", content: "Initialize project content here", - source: "built-in" as const, - filePath: "", + source: "project" as const, + filePath: ".kilocode/workflows/init.md", description: "Analyze codebase and create AGENTS.md", } - vi.mocked(getCommand).mockResolvedValue(mockCommand) + vi.mocked(getWorkflow).mockResolvedValue(mockWorkflow) await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) @@ -135,23 +179,23 @@ describe("runSlashCommandTool", () => { tool: "runSlashCommand", command: "init", args: undefined, - source: "built-in", + source: "project", description: "Analyze codebase and create AGENTS.md", }), ) expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith( - `Command: /init + `Workflow: /init Description: Analyze codebase and create AGENTS.md -Source: built-in +Source: project ---- Command Content --- +--- Workflow Content --- Initialize project content here`, ) }) - it("should successfully execute command with arguments", async () => { + it("should successfully execute workflow with arguments", async () => { const block: ToolUse<"run_slash_command"> = { type: "tool_use" as const, name: "run_slash_command" as const, @@ -162,33 +206,33 @@ Initialize project content here`, partial: false, } - const mockCommand = { + const mockWorkflow = { name: "test", content: "Run tests with specific focus", source: "project" as const, - filePath: ".roo/commands/test.md", + filePath: ".kilocode/workflows/test.md", description: "Run project tests", - argumentHint: "test type or focus area", + arguments: "test type or focus area", } - vi.mocked(getCommand).mockResolvedValue(mockCommand) + vi.mocked(getWorkflow).mockResolvedValue(mockWorkflow) await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith( - `Command: /test + `Workflow: /test Description: Run project tests -Argument hint: test type or focus area +Arguments: test type or focus area Provided arguments: focus on unit tests Source: project ---- Command Content --- +--- Workflow Content --- Run tests with specific focus`, ) }) - it("should handle global command", async () => { + it("should handle global workflow", async () => { const block: ToolUse<"run_slash_command"> = { type: "tool_use" as const, name: "run_slash_command" as const, @@ -198,22 +242,22 @@ Run tests with specific focus`, partial: false, } - const mockCommand = { + const mockWorkflow = { name: "deploy", content: "Deploy application to production", source: "global" as const, - filePath: "~/.roo/commands/deploy.md", + filePath: "~/.kilocode/workflows/deploy.md", } - vi.mocked(getCommand).mockResolvedValue(mockCommand) + vi.mocked(getWorkflow).mockResolvedValue(mockWorkflow) await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith( - `Command: /deploy + `Workflow: /deploy Source: global ---- Command Content --- +--- Workflow Content --- Deploy application to production`, ) @@ -255,14 +299,14 @@ Deploy application to production`, } const error = new Error("Test error") - vi.mocked(getCommand).mockRejectedValue(error) + vi.mocked(getWorkflow).mockRejectedValue(error) await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) expect(mockCallbacks.handleError).toHaveBeenCalledWith("running slash command", error) }) - it("should handle empty available commands list", async () => { + it("should handle empty available workflows list", async () => { const block: ToolUse<"run_slash_command"> = { type: "tool_use" as const, name: "run_slash_command" as const, @@ -272,17 +316,17 @@ Deploy application to production`, partial: false, } - vi.mocked(getCommand).mockResolvedValue(undefined) - vi.mocked(getCommandNames).mockResolvedValue([]) + vi.mocked(getWorkflow).mockResolvedValue(undefined) + vi.mocked(getWorkflowNames).mockResolvedValue([]) await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith( - formatResponse.toolError("Command 'nonexistent' not found. Available commands: (none)"), + formatResponse.toolError("Workflow 'nonexistent' not found. Available workflows: (none)"), ) }) - it("should reset consecutive mistake count on valid command", async () => { + it("should reset consecutive mistake count on valid workflow", async () => { const block: ToolUse<"run_slash_command"> = { type: "tool_use" as const, name: "run_slash_command" as const, @@ -294,14 +338,14 @@ Deploy application to production`, mockTask.consecutiveMistakeCount = 5 - const mockCommand = { + const mockWorkflow = { name: "init", content: "Initialize project", - source: "built-in" as const, - filePath: "", + source: "project" as const, + filePath: ".kilocode/workflows/init.md", } - vi.mocked(getCommand).mockResolvedValue(mockCommand) + vi.mocked(getWorkflow).mockResolvedValue(mockWorkflow) await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) diff --git a/src/services/workflow/__tests__/workflows.spec.ts b/src/services/workflow/__tests__/workflows.spec.ts new file mode 100644 index 00000000000..6ffb077adfe --- /dev/null +++ b/src/services/workflow/__tests__/workflows.spec.ts @@ -0,0 +1,115 @@ +// kilocode_change - new file + +import { describe, it, expect } from "vitest" +import * as path from "path" +import { getWorkflows, getWorkflow, getWorkflowNames, getWorkflowNameFromFile, isMarkdownFile } from "../workflows" + +const testWorkspaceDir = path.join(__dirname, "../../../") + +describe("getWorkflowNameFromFile", () => { + it("should strip .md extension only", () => { + expect(getWorkflowNameFromFile("my-workflow.md")).toBe("my-workflow") + expect(getWorkflowNameFromFile("test.txt")).toBe("test.txt") + expect(getWorkflowNameFromFile("no-extension")).toBe("no-extension") + expect(getWorkflowNameFromFile("multiple.dots.file.md")).toBe("multiple.dots.file") + expect(getWorkflowNameFromFile("api.config.md")).toBe("api.config") + expect(getWorkflowNameFromFile("deploy_prod.md")).toBe("deploy_prod") + }) + + it("should handle edge cases", () => { + // Files without extensions + expect(getWorkflowNameFromFile("workflow")).toBe("workflow") + expect(getWorkflowNameFromFile("my-workflow")).toBe("my-workflow") + + // Files with multiple dots - only strip .md extension + expect(getWorkflowNameFromFile("my.complex.workflow.md")).toBe("my.complex.workflow") + expect(getWorkflowNameFromFile("v1.2.3.txt")).toBe("v1.2.3.txt") + + // Edge cases + expect(getWorkflowNameFromFile(".")).toBe(".") + expect(getWorkflowNameFromFile("..")).toBe("..") + expect(getWorkflowNameFromFile(".hidden.md")).toBe(".hidden") + }) +}) + +describe("isMarkdownFile", () => { + it("should identify markdown files correctly", () => { + expect(isMarkdownFile("workflow.md")).toBe(true) + expect(isMarkdownFile("WORKFLOW.MD")).toBe(true) + expect(isMarkdownFile("Workflow.Md")).toBe(true) + expect(isMarkdownFile("workflow.markdown")).toBe(false) + expect(isMarkdownFile("workflow.txt")).toBe(false) + expect(isMarkdownFile("workflow")).toBe(false) + }) +}) + +describe("getWorkflows", () => { + it("should return array when workflow directories exist", async () => { + const workflows = await getWorkflows(testWorkspaceDir) + expect(Array.isArray(workflows)).toBe(true) + }) + + it("should return workflows with valid properties", async () => { + const workflows = await getWorkflows(testWorkspaceDir) + + workflows.forEach((workflow) => { + expect(workflow.name).toBeDefined() + expect(typeof workflow.name).toBe("string") + expect(workflow.source).toMatch(/^(project|global)$/) + expect(workflow.content).toBeDefined() + expect(typeof workflow.content).toBe("string") + }) + }) +}) + +describe("getWorkflowNames", () => { + it("should return array of strings", async () => { + const names = await getWorkflowNames(testWorkspaceDir) + expect(Array.isArray(names)).toBe(true) + + // If workflow names exist, they should be strings + names.forEach((name) => { + expect(typeof name).toBe("string") + expect(name.length).toBeGreaterThan(0) + }) + }) +}) + +describe("getWorkflow", () => { + it("should return undefined for non-existent workflow", async () => { + const result = await getWorkflow(testWorkspaceDir, "non-existent") + expect(result).toBeUndefined() + }) + + it("should load workflow with valid properties", async () => { + const workflows = await getWorkflows(testWorkspaceDir) + + if (workflows.length > 0) { + const firstWorkflow = workflows[0] + const loadedWorkflow = await getWorkflow(testWorkspaceDir, firstWorkflow.name) + + expect(loadedWorkflow).toBeDefined() + expect(loadedWorkflow?.name).toBe(firstWorkflow.name) + expect(loadedWorkflow?.source).toMatch(/^(project|global)$/) + expect(loadedWorkflow?.content).toBeDefined() + expect(typeof loadedWorkflow?.content).toBe("string") + } + }) +}) + +describe("workflow loading behavior", () => { + it("should handle multiple calls to getWorkflows", async () => { + const workflows1 = await getWorkflows(testWorkspaceDir) + const workflows2 = await getWorkflows(testWorkspaceDir) + + expect(Array.isArray(workflows1)).toBe(true) + expect(Array.isArray(workflows2)).toBe(true) + }) + + it("should handle invalid workflow names gracefully", async () => { + // These should not throw errors + expect(await getWorkflow(testWorkspaceDir, "")).toBeUndefined() + expect(await getWorkflow(testWorkspaceDir, " ")).toBeUndefined() + expect(await getWorkflow(testWorkspaceDir, "non/existent/path")).toBeUndefined() + }) +}) diff --git a/src/services/workflow/workflows.ts b/src/services/workflow/workflows.ts new file mode 100644 index 00000000000..085f2b7cd15 --- /dev/null +++ b/src/services/workflow/workflows.ts @@ -0,0 +1,370 @@ +// kilocode_change - new file + +import fs from "fs/promises" +import * as path from "path" +import { Dirent } from "fs" +import matter from "gray-matter" + +/** + * Maximum depth for resolving symlinks to prevent cyclic symlink loops + */ +const MAX_DEPTH = 5 + +export interface Workflow { + name: string + content: string + source: "project" | "global" + filePath: string + description?: string + arguments?: string + mode?: string +} + +/** + * Information about a resolved workflow file + */ +interface WorkflowFileInfo { + /** Original path (symlink path if symlinked, otherwise the file path) */ + originalPath: string + /** Resolved path (target of symlink if symlinked, otherwise the file path) */ + resolvedPath: string +} + +/** + * Recursively resolve a symbolic link and collect workflow file info + */ +async function resolveWorkflowSymLink(symlinkPath: string, fileInfo: WorkflowFileInfo[], depth: number): Promise { + // Avoid cyclic symlinks + if (depth > MAX_DEPTH) { + return + } + try { + // Get the symlink target + const linkTarget = await fs.readlink(symlinkPath) + // Resolve the target path (relative to the symlink location) + const resolvedTarget = path.resolve(path.dirname(symlinkPath), linkTarget) + + // Check if the target is a file (use lstat to detect nested symlinks) + const stats = await fs.lstat(resolvedTarget) + if (stats.isFile()) { + // Only include markdown files + if (isMarkdownFile(resolvedTarget)) { + // For symlinks to files, store the symlink path as original and target as resolved + fileInfo.push({ originalPath: symlinkPath, resolvedPath: resolvedTarget }) + } + } else if (stats.isDirectory()) { + // Read the target directory and process its entries + const entries = await fs.readdir(resolvedTarget, { withFileTypes: true }) + const directoryPromises: Promise[] = [] + for (const entry of entries) { + directoryPromises.push(resolveWorkflowDirectoryEntry(entry, resolvedTarget, fileInfo, depth + 1)) + } + await Promise.all(directoryPromises) + } else if (stats.isSymbolicLink()) { + // Handle nested symlinks + await resolveWorkflowSymLink(resolvedTarget, fileInfo, depth + 1) + } + } catch { + // Skip invalid symlinks + } +} + +/** + * Recursively resolve directory entries and collect workflow file paths + */ +async function resolveWorkflowDirectoryEntry( + entry: Dirent, + dirPath: string, + fileInfo: WorkflowFileInfo[], + depth: number, +): Promise { + // Avoid cyclic symlinks + if (depth > MAX_DEPTH) { + return + } + + const fullPath = path.resolve(entry.parentPath || dirPath, entry.name) + if (entry.isFile()) { + // Only include markdown files + if (isMarkdownFile(entry.name)) { + // Regular file - both original and resolved paths are the same + fileInfo.push({ originalPath: fullPath, resolvedPath: fullPath }) + } + } else if (entry.isSymbolicLink()) { + // Await the resolution of the symbolic link + await resolveWorkflowSymLink(fullPath, fileInfo, depth + 1) + } +} + +/** + * Try to resolve a symlinked workflow file + */ +async function tryResolveSymlinkedWorkflow(filePath: string): Promise { + try { + const lstat = await fs.lstat(filePath) + if (lstat.isSymbolicLink()) { + // Get the symlink target + const linkTarget = await fs.readlink(filePath) + // Resolve the target path (relative to the symlink location) + const resolvedTarget = path.resolve(path.dirname(filePath), linkTarget) + + // Check if the target is a file + const stats = await fs.stat(resolvedTarget) + if (stats.isFile()) { + return resolvedTarget + } + } + } catch { + // Not a symlink or invalid symlink + } + return undefined +} + +/** + * Get all available workflows from global and project directories + * Priority order: project > global (later sources override earlier ones) + */ +export async function getWorkflows(cwd: string): Promise { + const workflows = new Map() + + // Scan global workflows first (lower priority) + const globalDir = path.join(getGlobalKiloCodeDirectory(), "workflows") + await scanWorkflowDirectory(globalDir, "global", workflows) + + // Scan project workflows (higher priority - override global) + const projectDir = path.join(getProjectKiloCodeDirectoryForCwd(cwd), "workflows") + await scanWorkflowDirectory(projectDir, "project", workflows) + + return Array.from(workflows.values()) +} + +/** + * Get a specific workflow by name (optimized to avoid scanning all workflows) + * Priority order: project > global + */ +export async function getWorkflow(cwd: string, name: string): Promise { + // Try to find the workflow directly without scanning all workflows + const projectDir = path.join(getProjectKiloCodeDirectoryForCwd(cwd), "workflows") + const globalDir = path.join(getGlobalKiloCodeDirectory(), "workflows") + + // Check project directory first (highest priority) + const projectWorkflow = await tryLoadWorkflow(projectDir, name, "project") + if (projectWorkflow) { + return projectWorkflow + } + + // Check global directory if not found in project + return await tryLoadWorkflow(globalDir, name, "global") +} + +/** + * Try to load a specific workflow from a directory (supports symlinks) + */ +async function tryLoadWorkflow( + dirPath: string, + name: string, + source: "global" | "project", +): Promise { + try { + const stats = await fs.stat(dirPath) + if (!stats.isDirectory()) { + return undefined + } + + // Try to find the workflow file directly + const workflowFileName = `${name}.md` + const filePath = path.join(dirPath, workflowFileName) + + // Check if this is a regular file first + let resolvedPath = filePath + let content: string | undefined + + try { + content = await fs.readFile(filePath, "utf-8") + } catch { + // File doesn't exist or can't be read - try resolving as symlink + const symlinkedPath = await tryResolveSymlinkedWorkflow(filePath) + if (symlinkedPath) { + try { + content = await fs.readFile(symlinkedPath, "utf-8") + resolvedPath = symlinkedPath + } catch { + // Symlink target can't be read + return undefined + } + } else { + return undefined + } + } + + if (!content) { + return undefined + } + + let parsed + let description: string | undefined + let argumentsHint: string | undefined + let mode: string | undefined + let workflowContent: string + + try { + // Try to parse frontmatter with gray-matter + parsed = matter(content) + description = + typeof parsed.data.description === "string" && parsed.data.description.trim() + ? parsed.data.description.trim() + : undefined + argumentsHint = + typeof parsed.data.arguments === "string" && parsed.data.arguments.trim() + ? parsed.data.arguments.trim() + : undefined + mode = typeof parsed.data.mode === "string" && parsed.data.mode.trim() ? parsed.data.mode.trim() : undefined + workflowContent = parsed.content.trim() + } catch { + // If frontmatter parsing fails, treat the entire content as workflow content + description = undefined + argumentsHint = undefined + mode = undefined + workflowContent = content.trim() + } + + return { + name, + content: workflowContent, + source, + filePath: resolvedPath, + description, + arguments: argumentsHint, + mode, + } + } catch { + // Directory doesn't exist or can't be read + return undefined + } +} + +/** + * Get workflow names for autocomplete + */ +export async function getWorkflowNames(cwd: string): Promise { + const workflows = await getWorkflows(cwd) + return workflows.map((workflow) => workflow.name) +} + +/** + * Scan a specific workflow directory (supports symlinks) + */ +async function scanWorkflowDirectory( + dirPath: string, + source: "global" | "project", + workflows: Map, +): Promise { + try { + const stats = await fs.stat(dirPath) + if (!stats.isDirectory()) { + return + } + + const entries = await fs.readdir(dirPath, { withFileTypes: true }) + + // Collect all workflow files, including those from symlinks + const fileInfo: WorkflowFileInfo[] = [] + const initialPromises: Promise[] = [] + + for (const entry of entries) { + initialPromises.push(resolveWorkflowDirectoryEntry(entry, dirPath, fileInfo, 0)) + } + + // Wait for all files to be resolved + await Promise.all(initialPromises) + + // Process each collected file + for (const { originalPath, resolvedPath } of fileInfo) { + // Workflow name comes from the original path (symlink name if symlinked) + const workflowName = getWorkflowNameFromFile(path.basename(originalPath)) + + try { + const content = await fs.readFile(resolvedPath, "utf-8") + + let parsed + let description: string | undefined + let argumentsHint: string | undefined + let mode: string | undefined + let workflowContent: string + + try { + // Try to parse frontmatter with gray-matter + parsed = matter(content) + description = + typeof parsed.data.description === "string" && parsed.data.description.trim() + ? parsed.data.description.trim() + : undefined + argumentsHint = + typeof parsed.data.arguments === "string" && parsed.data.arguments.trim() + ? parsed.data.arguments.trim() + : undefined + mode = + typeof parsed.data.mode === "string" && parsed.data.mode.trim() + ? parsed.data.mode.trim() + : undefined + workflowContent = parsed.content.trim() + } catch { + // If frontmatter parsing fails, treat the entire content as workflow content + description = undefined + argumentsHint = undefined + mode = undefined + workflowContent = content.trim() + } + + // Project workflows override global ones + if (source === "project" || !workflows.has(workflowName)) { + workflows.set(workflowName, { + name: workflowName, + content: workflowContent, + source, + filePath: resolvedPath, + description, + arguments: argumentsHint, + mode, + }) + } + } catch (error) { + console.warn(`Failed to read workflow file ${resolvedPath}:`, error) + } + } + } catch { + // Directory doesn't exist or can't be read - this is fine + } +} + +/** + * Extract workflow name from filename (strip .md extension only) + */ +export function getWorkflowNameFromFile(filename: string): string { + if (filename.toLowerCase().endsWith(".md")) { + return filename.slice(0, -3) + } + return filename +} + +/** + * Check if a file is a markdown file + */ +export function isMarkdownFile(filename: string): boolean { + return filename.toLowerCase().endsWith(".md") +} + +/** + * Get the global Kilo Code directory path + */ +function getGlobalKiloCodeDirectory(): string { + const homeDir = process.env.HOME || process.env.USERPROFILE || "" + return path.join(homeDir, ".kilocode") +} + +/** + * Get the project-level Kilo Code directory path for a given working directory + */ +function getProjectKiloCodeDirectoryForCwd(cwd: string): string { + return path.join(cwd, ".kilocode") +} diff --git a/src/shared/__tests__/experiments.spec.ts b/src/shared/__tests__/experiments.spec.ts index bd1a8895c50..4a57d5ea3aa 100644 --- a/src/shared/__tests__/experiments.spec.ts +++ b/src/shared/__tests__/experiments.spec.ts @@ -41,7 +41,7 @@ describe("experiments", () => { multiFileApplyDiff: false, preventFocusDisruption: false, imageGeneration: false, - runSlashCommand: false, + autoExecuteWorkflow: false, multipleNativeToolCalls: false, customTools: false, } @@ -56,7 +56,7 @@ describe("experiments", () => { multiFileApplyDiff: false, preventFocusDisruption: false, imageGeneration: false, - runSlashCommand: false, + autoExecuteWorkflow: false, multipleNativeToolCalls: false, customTools: false, } @@ -71,7 +71,7 @@ describe("experiments", () => { multiFileApplyDiff: false, preventFocusDisruption: false, imageGeneration: false, - runSlashCommand: false, + autoExecuteWorkflow: false, multipleNativeToolCalls: false, customTools: false, } diff --git a/src/shared/experiments.ts b/src/shared/experiments.ts index 09eb7bdaa73..2344fc1ee58 100644 --- a/src/shared/experiments.ts +++ b/src/shared/experiments.ts @@ -7,7 +7,7 @@ export const EXPERIMENT_IDS = { POWER_STEERING: "powerSteering", PREVENT_FOCUS_DISRUPTION: "preventFocusDisruption", IMAGE_GENERATION: "imageGeneration", - RUN_SLASH_COMMAND: "runSlashCommand", + AUTO_EXECUTE_WORKFLOW: "autoExecuteWorkflow", MULTIPLE_NATIVE_TOOL_CALLS: "multipleNativeToolCalls", CUSTOM_TOOLS: "customTools", } as const satisfies Record @@ -27,7 +27,7 @@ export const experimentConfigsMap: Record = { POWER_STEERING: { enabled: false }, PREVENT_FOCUS_DISRUPTION: { enabled: false }, IMAGE_GENERATION: { enabled: false }, - RUN_SLASH_COMMAND: { enabled: false }, + AUTO_EXECUTE_WORKFLOW: { enabled: false }, // kilocode_change: Auto-execute workflows without approval MULTIPLE_NATIVE_TOOL_CALLS: { enabled: false }, CUSTOM_TOOLS: { enabled: false }, } diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 3aac7907096..57b935c0de1 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -2,7 +2,6 @@ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from " import { useSize } from "react-use" import { useTranslation, Trans } from "react-i18next" import deepEqual from "fast-deep-equal" -import { VSCodeBadge } from "@vscode/webview-ui-toolkit/react" import type { ClineMessage, FollowUpData, SuggestionItem } from "@roo-code/types" import { Mode } from "@roo/modes" @@ -17,6 +16,8 @@ import { vscode } from "@src/utils/vscode" import { formatPathTooltip } from "@src/utils/formatPathTooltip" import { ToolUseBlock, ToolUseBlockHeader } from "../common/ToolUseBlock" +// kilocode_change: Use extended SlashCommandItem for workflow execution +import { SlashCommandItem } from "./SlashCommandItem" import UpdateTodoListToolBlock from "./UpdateTodoListToolBlock" import { TodoChangeDisplay } from "./TodoChangeDisplay" import CodeAccordian from "../common/CodeAccordian" @@ -993,74 +994,15 @@ export const ChatRowContent = ({ ) case "runSlashCommand": { - const slashCommandInfo = tool + // kilocode_change: Use extended SlashCommandItem for workflow execution return ( - <> -
- {toolIcon("play")} - - {message.type === "ask" - ? t("chat:slashCommand.wantsToRun") - : t("chat:slashCommand.didRun")} - -
-
- -
- - /{slashCommandInfo.command} - - {slashCommandInfo.source && ( - - {slashCommandInfo.source} - - )} -
- -
- {isExpanded && (slashCommandInfo.args || slashCommandInfo.description) && ( -
- {slashCommandInfo.args && ( -
- Arguments: - - {slashCommandInfo.args} - -
- )} - {slashCommandInfo.description && ( -
- {slashCommandInfo.description} -
- )} -
- )} -
- + ) } case "generateImage": @@ -1578,73 +1520,15 @@ export const ChatRowContent = ({ switch (sayTool.tool) { case "runSlashCommand": { - const slashCommandInfo = sayTool + // kilocode_change: Use extended SlashCommandItem for workflow execution return ( - <> -
- - {t("chat:slashCommand.didRun")} -
-
- - -
- - /{slashCommandInfo.command} - - {slashCommandInfo.args && ( - - {slashCommandInfo.args} - - )} -
- {slashCommandInfo.description && ( -
- {slashCommandInfo.description} -
- )} - {slashCommandInfo.source && ( -
- - {slashCommandInfo.source} - -
- )} -
-
-
- + ) } default: diff --git a/webview-ui/src/components/chat/SlashCommandItem.tsx b/webview-ui/src/components/chat/SlashCommandItem.tsx index 90d5b39e16d..4af2bc96064 100644 --- a/webview-ui/src/components/chat/SlashCommandItem.tsx +++ b/webview-ui/src/components/chat/SlashCommandItem.tsx @@ -1,4 +1,6 @@ import React from "react" +import { useTranslation } from "react-i18next" +import { VSCodeBadge } from "@vscode/webview-ui-toolkit/react" import { Edit, Trash2 } from "lucide-react" import type { Command } from "@roo/ExtensionMessage" @@ -6,15 +8,174 @@ import type { Command } from "@roo/ExtensionMessage" import { useAppTranslation } from "@/i18n/TranslationContext" import { Button, StandardTooltip } from "@/components/ui" import { vscode } from "@/utils/vscode" +import { ToolUseBlock, ToolUseBlockHeader } from "../common/ToolUseBlock" +// kilocode_change start: Add workflow execution support interface SlashCommandItemProps { - command: Command - onDelete: (command: Command) => void + // Original props for command list mode + command?: Command + onDelete?: (command: Command) => void onClick?: (command: Command) => void + + // New props for workflow execution mode + isWorkflowExecution?: boolean + tool?: { + tool: "runSlashCommand" + command: string + args?: string + source?: string + description?: string + } + messageType?: "ask" | "say" + isExpanded?: boolean + onToggleExpand?: () => void } +// kilocode_change end + +export const SlashCommandItem: React.FC = ({ + command, + onDelete, + onClick, + isWorkflowExecution, + tool, + messageType = "say", + isExpanded = false, + onToggleExpand, +}) => { + const { t: tList } = useAppTranslation() // kilocode_change: always call hooks at top level + const { t: t2 } = useTranslation() // kilocode_change: for workflow execution translations + + // kilocode_change start: Workflow execution mode + if (isWorkflowExecution && tool) { + const slashCommandInfo = tool -export const SlashCommandItem: React.FC = ({ command, onDelete, onClick }) => { - const { t } = useAppTranslation() + return ( + <> +
+ + + {messageType === "ask" ? t2("chat:slashCommand.wantsToRun") : t2("chat:slashCommand.didRun")} + +
+ {messageType === "ask" ? ( +
+ +
+ + /{slashCommandInfo.command} + + {slashCommandInfo.source && ( + + {slashCommandInfo.source} + + )} +
+ +
+ {isExpanded && (slashCommandInfo.args || slashCommandInfo.description) && ( +
+ {slashCommandInfo.args && ( +
+ Arguments: + + {slashCommandInfo.args} + +
+ )} + {slashCommandInfo.description && ( +
+ {slashCommandInfo.description} +
+ )} +
+ )} +
+ ) : ( +
+ + +
+ + /{slashCommandInfo.command} + + {slashCommandInfo.args && ( + + {slashCommandInfo.args} + + )} +
+ {slashCommandInfo.description && ( +
+ {slashCommandInfo.description} +
+ )} + {slashCommandInfo.source && ( +
+ + {slashCommandInfo.source} + +
+ )} +
+
+
+ )} + + ) + } + // kilocode_change end + + // Original command list mode + if (!command || !onDelete) { + return null + } // Built-in commands cannot be edited or deleted const isBuiltIn = command.source === "built-in" @@ -56,7 +217,7 @@ export const SlashCommandItem: React.FC = ({ command, onD {/* Action buttons - only show for non-built-in commands */} {!isBuiltIn && (
- + - +