diff --git a/CHANGELOG.md b/CHANGELOG.md index d92e6ca68a9..4a3a62d0c6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # kilo-code +## 4.141.0 + +### Minor Changes + +- [#4702](https://github.com/Kilo-Org/kilocode/pull/4702) [`b84a66f`](https://github.com/Kilo-Org/kilocode/commit/b84a66f5923cf2600a6d5c8e2b5fd49759406696) Thanks [@chrarnoldus](https://github.com/chrarnoldus)! - Add support for skills + +### Patch Changes + +- [#4710](https://github.com/Kilo-Org/kilocode/pull/4710) [`c128319`](https://github.com/Kilo-Org/kilocode/commit/c1283192df1b0e59fef8b9ab2d3442bf4a07abde) Thanks [@sebastiand-cerebras](https://github.com/sebastiand-cerebras)! - Update Cerebras maxTokens from 8192 to 16384 for all models + +- [#4718](https://github.com/Kilo-Org/kilocode/pull/4718) [`9a465b0`](https://github.com/Kilo-Org/kilocode/commit/9a465b06fe401f70dd166fb5b320a8070f07c727) Thanks [@marius-kilocode](https://github.com/marius-kilocode)! - Fix terminal scroll-flicker in CLI by disabling streaming output and enabling Ink incremental rendering + +- [#4719](https://github.com/Kilo-Org/kilocode/pull/4719) [`57b0873`](https://github.com/Kilo-Org/kilocode/commit/57b08737788cd504954563d46eb1e6323d619301) Thanks [@marius-kilocode](https://github.com/marius-kilocode)! - Confirm before exiting the CLI on Ctrl+C/Cmd+C. + ## 4.140.3 ### Patch Changes diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 0e343b8f160..ac5a59ec2a8 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -1,5 +1,5 @@ import { basename } from "node:path" -import { render, Instance } from "ink" +import { render, Instance, type RenderOptions } from "ink" import React from "react" import { createStore } from "jotai" import { createExtensionService, ExtensionService } from "./services/extension.js" @@ -33,6 +33,7 @@ import { getSelectedModelId } from "./utils/providers.js" import { KiloCodePathProvider, ExtensionMessengerAdapter } from "./services/session-adapters.js" import { getKiloToken } from "./config/persistence.js" import { SessionManager } from "../../src/shared/kilocode/cli-sessions/core/SessionManager.js" +import { triggerExitConfirmationAtom } from "./state/atoms/keyboard.js" /** * Main application class that orchestrates the CLI lifecycle @@ -330,6 +331,13 @@ export class CLI { // Disable stdin for Ink when in CI mode or when stdin is piped (not a TTY) // This prevents the "Raw mode is not supported" error const shouldDisableStdin = this.options.jsonInteractive || this.options.ci || !process.stdin.isTTY + const renderOptions: RenderOptions = { + // Enable Ink's incremental renderer to avoid redrawing the entire screen on every update. + // This reduces flickering for frequently updating UIs. + incrementalRendering: true, + exitOnCtrlC: false, + ...(shouldDisableStdin ? { stdout: process.stdout, stderr: process.stderr } : {}), + } this.ui = render( React.createElement(App, { @@ -349,12 +357,7 @@ export class CLI { }, onExit: () => this.dispose(), }), - shouldDisableStdin - ? { - stdout: process.stdout, - stderr: process.stderr, - } - : undefined, + renderOptions, ) // Wait for UI to exit @@ -671,6 +674,31 @@ export class CLI { return this.store } + /** + * Returns true if the CLI should show an exit confirmation prompt for SIGINT. + */ + shouldConfirmExitOnSigint(): boolean { + return ( + !!this.store && + !this.options.ci && + !this.options.json && + !this.options.jsonInteractive && + process.stdin.isTTY + ) + } + + /** + * Trigger the exit confirmation prompt. Returns true if handled. + */ + requestExitConfirmation(): boolean { + if (!this.shouldConfirmExitOnSigint()) { + return false + } + + this.store?.set(triggerExitConfirmationAtom) + return true + } + /** * Check if the application is initialized */ diff --git a/cli/src/index.ts b/cli/src/index.ts index a2f5d239620..03514c94357 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -275,6 +275,10 @@ program // Handle process termination signals process.on("SIGINT", async () => { + if (cli?.requestExitConfirmation()) { + return + } + if (cli) { await cli.dispose("SIGINT") } else { diff --git a/cli/src/state/atoms/__tests__/keyboard.test.ts b/cli/src/state/atoms/__tests__/keyboard.test.ts index 8d92aa7fee5..fbd591672e1 100644 --- a/cli/src/state/atoms/__tests__/keyboard.test.ts +++ b/cli/src/state/atoms/__tests__/keyboard.test.ts @@ -9,7 +9,13 @@ import { fileMentionSuggestionsAtom, } from "../ui.js" import { textBufferStringAtom, textBufferStateAtom } from "../textBuffer.js" -import { keyboardHandlerAtom, submissionCallbackAtom, submitInputAtom } from "../keyboard.js" +import { + exitPromptVisibleAtom, + exitRequestCounterAtom, + keyboardHandlerAtom, + submissionCallbackAtom, + submitInputAtom, +} from "../keyboard.js" import { pendingApprovalAtom } from "../approval.js" import { historyDataAtom, historyModeAtom, historyIndexAtom as _historyIndexAtom } from "../history.js" import { chatMessagesAtom } from "../extension.js" @@ -1087,5 +1093,26 @@ describe("keypress atoms", () => { // When not streaming, ESC should clear the buffer (normal behavior) expect(store.get(textBufferStringAtom)).toBe("") }) + + it("should require confirmation before exiting on Ctrl+C", async () => { + const ctrlCKey: Key = { + name: "c", + sequence: "\u0003", + ctrl: true, + meta: false, + shift: false, + paste: false, + } + + await store.set(keyboardHandlerAtom, ctrlCKey) + + expect(store.get(exitPromptVisibleAtom)).toBe(true) + expect(store.get(exitRequestCounterAtom)).toBe(0) + + await store.set(keyboardHandlerAtom, ctrlCKey) + + expect(store.get(exitPromptVisibleAtom)).toBe(false) + expect(store.get(exitRequestCounterAtom)).toBe(1) + }) }) }) diff --git a/cli/src/state/atoms/keyboard.ts b/cli/src/state/atoms/keyboard.ts index 3e8b7236467..0254b251230 100644 --- a/cli/src/state/atoms/keyboard.ts +++ b/cli/src/state/atoms/keyboard.ts @@ -92,6 +92,41 @@ export const kittyProtocolEnabledAtom = atom(false) */ export const debugKeystrokeLoggingAtom = atom(false) +// ============================================================================ +// Exit Confirmation State +// ============================================================================ + +const EXIT_CONFIRMATION_WINDOW_MS = 2000 + +type ExitPromptTimeout = ReturnType + +export const exitPromptVisibleAtom = atom(false) +const exitPromptTimeoutAtom = atom(null) +export const exitRequestCounterAtom = atom(0) + +export const triggerExitConfirmationAtom = atom(null, (get, set) => { + const exitPromptVisible = get(exitPromptVisibleAtom) + const existingTimeout = get(exitPromptTimeoutAtom) + + if (existingTimeout) { + clearTimeout(existingTimeout) + set(exitPromptTimeoutAtom, null) + } + + if (exitPromptVisible) { + set(exitPromptVisibleAtom, false) + set(exitRequestCounterAtom, (count) => count + 1) + return + } + + set(exitPromptVisibleAtom, true) + const timeout = setTimeout(() => { + set(exitPromptVisibleAtom, false) + set(exitPromptTimeoutAtom, null) + }, EXIT_CONFIRMATION_WINDOW_MS) + set(exitPromptTimeoutAtom, timeout) +}) + // ============================================================================ // Buffer Atoms // ============================================================================ @@ -795,7 +830,8 @@ function handleGlobalHotkeys(get: Getter, set: Setter, key: Key): boolean { switch (key.name) { case "c": if (key.ctrl) { - process.exit(0) + set(triggerExitConfirmationAtom) + return true } break case "x": diff --git a/cli/src/state/atoms/ui.ts b/cli/src/state/atoms/ui.ts index cbfe50456f8..1012d33522e 100644 --- a/cli/src/state/atoms/ui.ts +++ b/cli/src/state/atoms/ui.ts @@ -696,7 +696,7 @@ export const resetMessageCutoffAtom = atom(null, (get, set) => { */ export const splitMessagesAtom = atom((get) => { const allMessages = get(mergedMessagesAtom) - return splitMessages(allMessages) + return splitMessages(allMessages, { hidePartialMessages: true }) }) /** diff --git a/cli/src/ui/UI.tsx b/cli/src/ui/UI.tsx index 9f05dfc358c..3ee7abb8fbe 100644 --- a/cli/src/ui/UI.tsx +++ b/cli/src/ui/UI.tsx @@ -36,6 +36,7 @@ import { generateNotificationMessage } from "../utils/notifications.js" import { notificationsAtom } from "../state/atoms/notifications.js" import { workspacePathAtom } from "../state/atoms/shell.js" import { useTerminal } from "../state/hooks/useTerminal.js" +import { exitRequestCounterAtom } from "../state/atoms/keyboard.js" // Initialize commands on module load initializeCommands() @@ -65,6 +66,7 @@ export const UI: React.FC = ({ options, onExit }) => { const setWorkspacePath = useSetAtom(workspacePathAtom) const taskResumedViaSession = useAtomValue(taskResumedViaContinueOrSessionAtom) const { hasActiveTask } = useTaskState() + const exitRequestCounter = useAtomValue(exitRequestCounterAtom) // Use specialized hooks for command and message handling const { executeCommand, isExecuting: isExecutingCommand } = useCommandHandler() @@ -94,6 +96,17 @@ export const UI: React.FC = ({ options, onExit }) => { onExit: onExit, }) + const handledExitRequestRef = useRef(exitRequestCounter) + + useEffect(() => { + if (exitRequestCounter === handledExitRequestRef.current) { + return + } + + handledExitRequestRef.current = exitRequestCounter + void executeCommand("/exit", onExit) + }, [exitRequestCounter, executeCommand, onExit]) + // Track if prompt has been executed and welcome message shown const promptExecutedRef = useRef(false) const welcomeShownRef = useRef(false) diff --git a/cli/src/ui/components/StatusIndicator.tsx b/cli/src/ui/components/StatusIndicator.tsx index 57d22cf9d28..acfc55aa31b 100644 --- a/cli/src/ui/components/StatusIndicator.tsx +++ b/cli/src/ui/components/StatusIndicator.tsx @@ -12,6 +12,7 @@ import { ThinkingAnimation } from "./ThinkingAnimation.js" import { useAtomValue } from "jotai" import { isStreamingAtom } from "../../state/atoms/ui.js" import { hasResumeTaskAtom } from "../../state/atoms/extension.js" +import { exitPromptVisibleAtom } from "../../state/atoms/keyboard.js" export interface StatusIndicatorProps { /** Whether the indicator is disabled */ @@ -34,6 +35,8 @@ export const StatusIndicator: React.FC = ({ disabled = fal const { hotkeys, shouldShow } = useHotkeys() const isStreaming = useAtomValue(isStreamingAtom) const hasResumeTask = useAtomValue(hasResumeTaskAtom) + const exitPromptVisible = useAtomValue(exitPromptVisibleAtom) + const exitModifierKey = process.platform === "darwin" ? "Cmd" : "Ctrl" // Don't render if no hotkeys to show or disabled if (!shouldShow || disabled) { @@ -44,8 +47,14 @@ export const StatusIndicator: React.FC = ({ disabled = fal {/* Status text on the left */} - {isStreaming && } - {hasResumeTask && Task ready to resume} + {exitPromptVisible ? ( + Press {exitModifierKey}+C again to exit. + ) : ( + <> + {isStreaming && } + {hasResumeTask && Task ready to resume} + + )} {/* Hotkeys on the right */} diff --git a/cli/src/ui/components/__tests__/StatusIndicator.test.tsx b/cli/src/ui/components/__tests__/StatusIndicator.test.tsx index 3d0a31d856c..98830a5b6c2 100644 --- a/cli/src/ui/components/__tests__/StatusIndicator.test.tsx +++ b/cli/src/ui/components/__tests__/StatusIndicator.test.tsx @@ -10,6 +10,7 @@ import { createStore } from "jotai" import { StatusIndicator } from "../StatusIndicator.js" import { showFollowupSuggestionsAtom } from "../../../state/atoms/ui.js" import { chatMessagesAtom } from "../../../state/atoms/extension.js" +import { exitPromptVisibleAtom } from "../../../state/atoms/keyboard.js" import type { ExtensionChatMessage } from "../../../types/messages.js" // Mock the hooks @@ -92,6 +93,19 @@ describe("StatusIndicator", () => { expect(output).toContain("for commands") }) + it("should show exit confirmation prompt when Ctrl+C is pressed once", () => { + store.set(exitPromptVisibleAtom, true) + + const { lastFrame } = render( + + + , + ) + + const output = lastFrame() + expect(output).toMatch(/Press (?:Ctrl|Cmd)\+C again to exit\./) + }) + it("should not show Thinking status when not streaming", () => { // Complete message = not streaming const completeMessage: ExtensionChatMessage = { diff --git a/cli/src/ui/messages/MessageDisplay.tsx b/cli/src/ui/messages/MessageDisplay.tsx index 0bdd30b4faf..38925ff372c 100644 --- a/cli/src/ui/messages/MessageDisplay.tsx +++ b/cli/src/ui/messages/MessageDisplay.tsx @@ -2,21 +2,15 @@ * MessageDisplay component - displays chat messages from both CLI and extension state * Uses Ink Static component to optimize rendering of completed messages * - * Performance Optimization: - * ------------------------ - * Messages are split into two sections: - * 1. Static section: Completed messages that won't change (rendered once with Ink Static) - * 2. Dynamic section: Incomplete/updating messages (re-rendered as needed) - * - * This prevents unnecessary re-renders of completed messages, improving performance - * especially in long conversations. + * Pure Static Mode: + * ----------------- + * Partial/streaming messages are filtered out at the atom level (see splitMessagesAtom), + * so this component only ever renders completed messages using Ink Static. * * Message Completion Logic: * ------------------------- - * A message is considered complete when: - * - CLI messages: partial !== true - * - Extension messages: depends on type (see messageCompletion.ts) - * - Sequential rule: A message can only be static if all previous messages are complete + * In pure static mode, any message with `partial === true` is hidden and everything else is + * treated as complete for display purposes. * * Key Generation Strategy: * ----------------------- @@ -40,16 +34,9 @@ import React from "react" import { Box, Static } from "ink" import { useAtomValue } from "jotai" -import { type UnifiedMessage, staticMessagesAtom, dynamicMessagesAtom } from "../../state/atoms/ui.js" +import { type UnifiedMessage, staticMessagesAtom } from "../../state/atoms/ui.js" import { MessageRow } from "./MessageRow.js" -interface MessageDisplayProps { - /** Optional filter to show only specific message types */ - filterType?: "ask" | "say" - /** Maximum number of messages to display (default: all) */ - maxMessages?: number -} - /** * Generate a unique key for a unified message * Uses a composite key strategy to ensure uniqueness even when messages @@ -79,34 +66,22 @@ function getMessageKey(msg: UnifiedMessage, index: number): string { return `${subtypeKey}-${index}` } -export const MessageDisplay: React.FC = () => { +export const MessageDisplay: React.FC = () => { const staticMessages = useAtomValue(staticMessagesAtom) - const dynamicMessages = useAtomValue(dynamicMessagesAtom) - if (staticMessages.length === 0 && dynamicMessages.length === 0) { + if (staticMessages.length === 0) { return null } return ( - {/* Static section for completed messages - won't re-render */} - {/* Key includes resetCounter to force re-mount when messages are replaced */} - {staticMessages.length > 0 && ( - - {(message, index) => ( - - - - )} - - )} - - {/* Dynamic section for incomplete/updating messages - will re-render */} - {dynamicMessages.map((unifiedMsg, index) => ( - - - - ))} + + {(message, index) => ( + + + + )} + ) } diff --git a/cli/src/ui/messages/utils/__tests__/messageCompletion.test.ts b/cli/src/ui/messages/utils/__tests__/messageCompletion.test.ts index 326546fd30f..3a7d2fe4090 100644 --- a/cli/src/ui/messages/utils/__tests__/messageCompletion.test.ts +++ b/cli/src/ui/messages/utils/__tests__/messageCompletion.test.ts @@ -438,4 +438,30 @@ describe("messageCompletion", () => { expect(result.dynamicMessages).toHaveLength(0) }) }) + + describe("splitMessages with hidePartialMessages option", () => { + it("should filter out all partial messages when hidePartialMessages is true", () => { + const messages: UnifiedMessage[] = [ + { + source: "cli", + message: { id: "1", type: "assistant", content: "A", ts: 1, partial: false }, + }, + { + source: "cli", + message: { id: "2", type: "assistant", content: "B", ts: 2, partial: true }, + }, + { + source: "cli", + message: { id: "3", type: "assistant", content: "C", ts: 3, partial: false }, + }, + ] + + const result = splitMessages(messages, { hidePartialMessages: true }) + + expect(result.staticMessages).toHaveLength(2) + expect(result.dynamicMessages).toHaveLength(0) + expect((result.staticMessages[0]?.message as CliMessage).id).toBe("1") + expect((result.staticMessages[1]?.message as CliMessage).id).toBe("3") + }) + }) }) diff --git a/cli/src/ui/messages/utils/messageCompletion.ts b/cli/src/ui/messages/utils/messageCompletion.ts index c60b476551d..343c40ebf77 100644 --- a/cli/src/ui/messages/utils/messageCompletion.ts +++ b/cli/src/ui/messages/utils/messageCompletion.ts @@ -110,15 +110,38 @@ function deduplicateCheckpointMessages(messages: UnifiedMessage[]): UnifiedMessa * - Visual jumping when messages complete out of order * * @param messages - Array of unified messages in chronological order + * @param options - Optional behavior flags * @returns Object with staticMessages (complete) and dynamicMessages (incomplete) */ -export function splitMessages(messages: UnifiedMessage[]): { +export interface SplitMessagesOptions { + /** + * When true, hides all partial messages and treats everything else as static. + * This enables a "pure static" mode where nothing streams to the terminal. + */ + hidePartialMessages?: boolean +} + +export function splitMessages( + messages: UnifiedMessage[], + options?: SplitMessagesOptions, +): { staticMessages: UnifiedMessage[] dynamicMessages: UnifiedMessage[] } { // First, deduplicate checkpoint messages const deduplicatedMessages = deduplicateCheckpointMessages(messages) + // hide any partial messages and treat everything else as static. + if (options?.hidePartialMessages) { + const filteredMessages = deduplicatedMessages.filter( + (msg) => (msg.message as { partial?: boolean }).partial !== true, + ) + return { + staticMessages: filteredMessages, + dynamicMessages: [], + } + } + let lastCompleteIndex = -1 const incompleteReasons: Array<{ index: number; reason: string; message: unknown }> = [] diff --git a/packages/cloud/src/bridge/__tests__/ExtensionChannel.test.ts b/packages/cloud/src/bridge/__tests__/ExtensionChannel.test.ts index 04e15488088..7ff201978fe 100644 --- a/packages/cloud/src/bridge/__tests__/ExtensionChannel.test.ts +++ b/packages/cloud/src/bridge/__tests__/ExtensionChannel.test.ts @@ -33,6 +33,7 @@ describe("ExtensionChannel", () => { wrapperCode: null, wrapperVersion: null, machineId: null, + vscodeIsTelemetryEnabled: null, // kilocode_change end hostname: "test-host", } diff --git a/packages/cloud/src/bridge/__tests__/TaskChannel.test.ts b/packages/cloud/src/bridge/__tests__/TaskChannel.test.ts index 6119b4fe028..18478759f67 100644 --- a/packages/cloud/src/bridge/__tests__/TaskChannel.test.ts +++ b/packages/cloud/src/bridge/__tests__/TaskChannel.test.ts @@ -36,6 +36,7 @@ describe("TaskChannel", () => { wrapperCode: null, wrapperVersion: null, machineId: null, + vscodeIsTelemetryEnabled: null, // kilocode_change end hostname: "test-host", } diff --git a/packages/types/src/providers/cerebras.ts b/packages/types/src/providers/cerebras.ts index 1ac8f637040..1f28c00bdfd 100644 --- a/packages/types/src/providers/cerebras.ts +++ b/packages/types/src/providers/cerebras.ts @@ -7,7 +7,7 @@ export const cerebrasDefaultModelId: CerebrasModelId = "gpt-oss-120b" export const cerebrasModels = { "zai-glm-4.6": { - maxTokens: 8192, // Conservative default to avoid premature rate limiting (Cerebras reserves quota upfront) + maxTokens: 16384, // Conservative default to avoid premature rate limiting (Cerebras reserves quota upfront) contextWindow: 131072, supportsImages: false, supportsPromptCache: false, @@ -17,7 +17,7 @@ export const cerebrasModels = { description: "Highly intelligent general purpose model with up to 1,000 tokens/s", }, "qwen-3-235b-a22b-instruct-2507": { - maxTokens: 8192, // Conservative default to avoid premature rate limiting + maxTokens: 16384, // Conservative default to avoid premature rate limiting contextWindow: 64000, supportsImages: false, supportsPromptCache: false, @@ -27,7 +27,7 @@ export const cerebrasModels = { description: "Intelligent model with ~1400 tokens/s", }, "llama-3.3-70b": { - maxTokens: 8192, // Conservative default to avoid premature rate limiting + maxTokens: 16384, // Conservative default to avoid premature rate limiting contextWindow: 64000, supportsImages: false, supportsPromptCache: false, @@ -37,7 +37,7 @@ export const cerebrasModels = { description: "Powerful model with ~2600 tokens/s", }, "qwen-3-32b": { - maxTokens: 8192, // Conservative default to avoid premature rate limiting + maxTokens: 16384, // Conservative default to avoid premature rate limiting contextWindow: 64000, supportsImages: false, supportsPromptCache: false, @@ -47,7 +47,7 @@ export const cerebrasModels = { description: "SOTA coding performance with ~2500 tokens/s", }, "gpt-oss-120b": { - maxTokens: 8192, // Conservative default to avoid premature rate limiting + maxTokens: 16384, // Conservative default to avoid premature rate limiting contextWindow: 64000, supportsImages: false, supportsPromptCache: false, diff --git a/packages/types/src/telemetry.ts b/packages/types/src/telemetry.ts index c021a3d5ff2..023ce48afa1 100644 --- a/packages/types/src/telemetry.ts +++ b/packages/types/src/telemetry.ts @@ -133,6 +133,7 @@ export const staticAppPropertiesSchema = z.object({ wrapperCode: z.string().nullable(), wrapperVersion: z.string().nullable(), machineId: z.string().nullable(), + vscodeIsTelemetryEnabled: z.boolean().nullable(), // kilocode_change end hostname: z.string().optional(), }) diff --git a/src/api/providers/__tests__/kilocode-openrouter.spec.ts b/src/api/providers/__tests__/kilocode-openrouter.spec.ts index 3e94c90e023..016eac8fb40 100644 --- a/src/api/providers/__tests__/kilocode-openrouter.spec.ts +++ b/src/api/providers/__tests__/kilocode-openrouter.spec.ts @@ -2,14 +2,27 @@ // npx vitest run src/api/providers/__tests__/kilocode-openrouter.spec.ts // Mock vscode first to avoid import errors -vitest.mock("vscode", () => ({})) +vitest.mock("vscode", () => ({ + env: { + uriScheme: "vscode", + language: "en", + uiKind: 1, + appName: "Visual Studio Code", + }, + version: "1.85.0", +})) import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" import { KilocodeOpenrouterHandler } from "../kilocode-openrouter" import { ApiHandlerOptions } from "../../../shared/api" -import { X_KILOCODE_TASKID, X_KILOCODE_ORGANIZATIONID, X_KILOCODE_PROJECTID } from "../../../shared/kilocode/headers" +import { + X_KILOCODE_TASKID, + X_KILOCODE_ORGANIZATIONID, + X_KILOCODE_PROJECTID, + X_KILOCODE_EDITORNAME, +} from "../../../shared/kilocode/headers" import { streamSse } from "../../../services/continuedev/core/fetch/stream" // Mock the stream module @@ -69,6 +82,7 @@ describe("KilocodeOpenrouterHandler", () => { expect(result).toEqual({ headers: { [X_KILOCODE_TASKID]: "test-task-id", + [X_KILOCODE_EDITORNAME]: "Visual Studio Code 1.85.0", }, }) }) @@ -84,6 +98,7 @@ describe("KilocodeOpenrouterHandler", () => { headers: { [X_KILOCODE_TASKID]: "test-task-id", [X_KILOCODE_ORGANIZATIONID]: "test-org-id", + [X_KILOCODE_EDITORNAME]: "Visual Studio Code 1.85.0", }, }) }) @@ -104,6 +119,7 @@ describe("KilocodeOpenrouterHandler", () => { [X_KILOCODE_TASKID]: "test-task-id", [X_KILOCODE_ORGANIZATIONID]: "test-org-id", [X_KILOCODE_PROJECTID]: "https://github.com/user/repo.git", + [X_KILOCODE_EDITORNAME]: "Visual Studio Code 1.85.0", }, }) }) @@ -124,6 +140,7 @@ describe("KilocodeOpenrouterHandler", () => { [X_KILOCODE_TASKID]: "test-task-id", [X_KILOCODE_PROJECTID]: "https://github.com/user/repo.git", [X_KILOCODE_ORGANIZATIONID]: "test-org-id", + [X_KILOCODE_EDITORNAME]: "Visual Studio Code 1.85.0", }, }) }) @@ -139,6 +156,7 @@ describe("KilocodeOpenrouterHandler", () => { headers: { [X_KILOCODE_TASKID]: "test-task-id", [X_KILOCODE_ORGANIZATIONID]: "test-org-id", + [X_KILOCODE_EDITORNAME]: "Visual Studio Code 1.85.0", }, }) expect(result?.headers).not.toHaveProperty(X_KILOCODE_PROJECTID) @@ -155,16 +173,21 @@ describe("KilocodeOpenrouterHandler", () => { expect(result).toEqual({ headers: { [X_KILOCODE_TASKID]: "test-task-id", + [X_KILOCODE_EDITORNAME]: "Visual Studio Code 1.85.0", }, }) expect(result?.headers).not.toHaveProperty(X_KILOCODE_PROJECTID) }) - it("returns undefined when no headers are needed", () => { + it("returns only editorName header when no other headers are needed", () => { const handler = new KilocodeOpenrouterHandler(mockOptions) const result = handler.customRequestOptions() - expect(result).toBeUndefined() + expect(result).toEqual({ + headers: { + [X_KILOCODE_EDITORNAME]: "Visual Studio Code 1.85.0", + }, + }) }) }) @@ -209,6 +232,7 @@ describe("KilocodeOpenrouterHandler", () => { [X_KILOCODE_TASKID]: "test-task-id", [X_KILOCODE_PROJECTID]: "https://github.com/user/repo.git", [X_KILOCODE_ORGANIZATIONID]: "test-org-id", + [X_KILOCODE_EDITORNAME]: "Visual Studio Code 1.85.0", }), }), // kilocode_change end diff --git a/src/api/providers/kilocode-openrouter.ts b/src/api/providers/kilocode-openrouter.ts index 2fea1dc9709..6bcb55ffa27 100644 --- a/src/api/providers/kilocode-openrouter.ts +++ b/src/api/providers/kilocode-openrouter.ts @@ -14,10 +14,12 @@ import { X_KILOCODE_TASKID, X_KILOCODE_PROJECTID, X_KILOCODE_TESTER, + X_KILOCODE_EDITORNAME, } from "../../shared/kilocode/headers" import { KILOCODE_TOKEN_REQUIRED_ERROR } from "../../shared/kilocode/errorUtils" import { DEFAULT_HEADERS } from "./constants" import { streamSse } from "../../services/continuedev/core/fetch/stream" +import { getEditorNameHeader } from "../../core/kilocode/wrapper" /** * A custom OpenRouter handler that overrides the getModel function @@ -52,7 +54,9 @@ export class KilocodeOpenrouterHandler extends OpenRouterHandler { } override customRequestOptions(metadata?: ApiHandlerCreateMessageMetadata) { - const headers: Record = {} + const headers: Record = { + [X_KILOCODE_EDITORNAME]: getEditorNameHeader(), + } if (metadata?.taskId) { headers[X_KILOCODE_TASKID] = metadata.taskId diff --git a/src/core/kilocode/wrapper.ts b/src/core/kilocode/wrapper.ts index 50efbd189eb..42e57745af6 100644 --- a/src/core/kilocode/wrapper.ts +++ b/src/core/kilocode/wrapper.ts @@ -31,3 +31,14 @@ export const getKiloCodeWrapperProperties = (): KiloCodeWrapperProperties => { kiloCodeWrapperJetbrains, } } + +export const getEditorNameHeader = () => { + const props = getKiloCodeWrapperProperties() + return ( + props.kiloCodeWrapped + ? [props.kiloCodeWrapperTitle, props.kiloCodeWrapperVersion] + : [vscode.env.appName, vscode.version] + ) + .filter(Boolean) + .join(" ") +} diff --git a/src/core/prompts/sections/index.ts b/src/core/prompts/sections/index.ts index d06dbbfde1d..b88cddc15ce 100644 --- a/src/core/prompts/sections/index.ts +++ b/src/core/prompts/sections/index.ts @@ -8,3 +8,4 @@ export { getToolUseGuidelinesSection } from "./tool-use-guidelines" export { getCapabilitiesSection } from "./capabilities" export { getModesSection } from "./modes" export { markdownFormattingSection } from "./markdown-formatting" +export { getSkillsSection } from "./skills" diff --git a/src/core/prompts/sections/skills.ts b/src/core/prompts/sections/skills.ts new file mode 100644 index 00000000000..a05c0aea738 --- /dev/null +++ b/src/core/prompts/sections/skills.ts @@ -0,0 +1,71 @@ +import { SkillsManager, SkillMetadata } from "../../../services/skills/SkillsManager" + +/** + * Get a display-friendly relative path for a skill. + * Converts absolute paths to relative paths to avoid leaking sensitive filesystem info. + * + * @param skill - The skill metadata + * @returns A relative path like ".kilocode/skills/name/SKILL.md" or "~/.kilocode/skills/name/SKILL.md" + */ +function getDisplayPath(skill: SkillMetadata): string { + const basePath = skill.source === "project" ? ".kilocode" : "~/.kilocode" + const skillsDir = skill.mode ? `skills-${skill.mode}` : "skills" + return `${basePath}/${skillsDir}/${skill.name}/SKILL.md` +} + +/** + * Generate the skills section for the system prompt. + * Only includes skills relevant to the current mode. + * Format matches the modes section style. + * + * @param skillsManager - The SkillsManager instance + * @param currentMode - The current mode slug (e.g., 'code', 'architect') + */ +export async function getSkillsSection( + skillsManager: SkillsManager | undefined, + currentMode: string | undefined, +): Promise { + if (!skillsManager || !currentMode) return "" + + // Get skills filtered by current mode (with override resolution) + const skills = skillsManager.getSkillsForMode(currentMode) + if (skills.length === 0) return "" + + // Separate generic and mode-specific skills for display + const genericSkills = skills.filter((s) => !s.mode) + const modeSpecificSkills = skills.filter((s) => s.mode === currentMode) + + let skillsList = "" + + if (modeSpecificSkills.length > 0) { + skillsList += modeSpecificSkills + .map( + (skill) => + ` * "${skill.name}" skill (${currentMode} mode) - ${skill.description} [${getDisplayPath(skill)}]`, + ) + .join("\n") + } + + if (genericSkills.length > 0) { + if (skillsList) skillsList += "\n" + skillsList += genericSkills + .map((skill) => ` * "${skill.name}" skill - ${skill.description} [${getDisplayPath(skill)}]`) + .join("\n") + } + + return `==== + +AVAILABLE SKILLS + +Skills are pre-packaged instructions for specific tasks. When a user request matches a skill description, read the full SKILL.md file to get detailed instructions. + +- These are the currently available skills for "${currentMode}" mode: +${skillsList} + +To use a skill: +1. Identify which skill matches the user's request based on the description +2. Use read_file to load the full SKILL.md file from the path shown in brackets +3. Follow the instructions in the skill file +4. Access any bundled files (scripts, references, assets) as needed +` +} diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index 590a0ebb94c..5196937ab7a 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -17,6 +17,7 @@ import { formatLanguage } from "../../shared/language" import { isEmpty } from "../../utils/object" import { McpHub } from "../../services/mcp/McpHub" import { CodeIndexManager } from "../../services/code-index/manager" +import { SkillsManager } from "../../services/skills/SkillsManager" import { PromptVariables, loadSystemPromptFile } from "./sections/custom-system-prompt" @@ -33,6 +34,7 @@ import { getModesSection, addCustomInstructions, markdownFormattingSection, + getSkillsSection, } from "./sections" import { type ClineProviderState } from "../webview/ClineProvider" // kilocode_change @@ -69,6 +71,7 @@ async function generatePrompt( settings?: SystemPromptSettings, todoList?: TodoItem[], modelId?: string, + skillsManager?: SkillsManager, clineProviderState?: ClineProviderState, // kilocode_change ): Promise { if (!context) { @@ -92,7 +95,7 @@ async function generatePrompt( // Determine the effective protocol (defaults to 'xml') const effectiveProtocol = getEffectiveProtocol(settings?.toolProtocol) - const [modesSection, mcpServersSection] = await Promise.all([ + const [modesSection, mcpServersSection, skillsSection] = await Promise.all([ getModesSection(context), shouldIncludeMcp ? getMcpServersSection( @@ -102,6 +105,7 @@ async function generatePrompt( !isNativeProtocol(effectiveProtocol), ) : Promise.resolve(""), + getSkillsSection(skillsManager, mode as string), ]) // Build tools catalog section only for XML protocol @@ -137,7 +141,7 @@ ${mcpServersSection} ${getCapabilitiesSection(cwd, shouldIncludeMcp ? mcpHub : undefined)} ${modesSection} - +${skillsSection ? `\n${skillsSection}` : ""} ${getRulesSection(cwd, settings, clineProviderState /* kilocode_change */)} ${getSystemInfoSection(cwd)} @@ -175,6 +179,7 @@ export const SYSTEM_PROMPT = async ( settings?: SystemPromptSettings, todoList?: TodoItem[], modelId?: string, + skillsManager?: SkillsManager, clineProviderState?: ClineProviderState, // kilocode_change ): Promise => { if (!context) { @@ -251,6 +256,7 @@ ${customInstructions}` settings, todoList, modelId, + skillsManager, clineProviderState, // kilocode_change ) } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 52ef552b178..a1a1db04c89 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3773,9 +3773,8 @@ export class Task extends EventEmitter implements TaskLike { }, undefined, // todoList this.api.getModel().id, - // kilocode_change start - state, - // kilocode_change end + provider.getSkillsManager(), + state, // kilocode_change ) })() } diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index cec7e167d12..0339c14aed9 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -999,6 +999,8 @@ describe("Cline", () => { getState: vi.fn().mockResolvedValue({ apiConfiguration: mockApiConfig, }), + getMcpHub: vi.fn().mockReturnValue(undefined), + getSkillsManager: vi.fn().mockReturnValue(undefined), say: vi.fn(), postStateToWebview: vi.fn().mockResolvedValue(undefined), postMessageToWebview: vi.fn().mockResolvedValue(undefined), diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 4a6e208d4bc..9dbc296eefc 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -74,6 +74,7 @@ import { CodeIndexManager } from "../../services/code-index/manager" import type { IndexProgressUpdate } from "../../services/code-index/interfaces/manager" import { MdmService } from "../../services/mdm/MdmService" import { SessionManager } from "../../shared/kilocode/cli-sessions/core/SessionManager" +import { SkillsManager } from "../../services/skills/SkillsManager" import { fileExistsAtPath } from "../../utils/fs" import { setTtsEnabled, setTtsSpeed } from "../../utils/tts" @@ -157,6 +158,7 @@ export class ClineProvider private codeIndexManager?: CodeIndexManager private _workspaceTracker?: WorkspaceTracker // workSpaceTracker read-only for access outside this class protected mcpHub?: McpHub // Change from private to protected + protected skillsManager?: SkillsManager private marketplaceManager: MarketplaceManager private mdmService?: MdmService private taskCreationCallback: (task: Task) => void @@ -219,6 +221,12 @@ export class ClineProvider this.log(`Failed to initialize MCP Hub: ${error}`) }) + // Initialize Skills Manager for skill discovery + this.skillsManager = new SkillsManager(this) + this.skillsManager.initialize().catch((error) => { + this.log(`Failed to initialize Skills Manager: ${error}`) + }) + this.marketplaceManager = new MarketplaceManager(this.context, this.customModesManager) // Forward task events to the provider. @@ -678,6 +686,8 @@ export class ClineProvider this._workspaceTracker = undefined await this.mcpHub?.unregisterClient() this.mcpHub = undefined + await this.skillsManager?.dispose() + this.skillsManager = undefined this.marketplaceManager?.cleanup() this.customModesManager?.dispose() @@ -2851,6 +2861,10 @@ ${prompt} return this.mcpHub } + public getSkillsManager(): SkillsManager | undefined { + return this.skillsManager + } + /** * Check if the current state is compliant with MDM policy * @returns true if compliant or no MDM policy exists, false if MDM policy exists and user is non-compliant @@ -3293,6 +3307,7 @@ ${prompt} wrapperVersion: kiloCodeWrapperVersion, wrapperTitle: kiloCodeWrapperTitle, machineId: vscode.env.machineId, + vscodeIsTelemetryEnabled: vscode.env.isTelemetryEnabled, // kilocode_change end } } diff --git a/src/core/webview/__tests__/generateSystemPrompt.browser-capability.spec.ts b/src/core/webview/__tests__/generateSystemPrompt.browser-capability.spec.ts index 9b3f94f309b..5aa2ea2c63a 100644 --- a/src/core/webview/__tests__/generateSystemPrompt.browser-capability.spec.ts +++ b/src/core/webview/__tests__/generateSystemPrompt.browser-capability.spec.ts @@ -49,6 +49,7 @@ function makeProviderStub() { rooIgnoreController: { getInstructions: () => undefined }, }), getMcpHub: () => undefined, + getSkillsManager: () => undefined, // State must enable browser tool and provide apiConfiguration getState: async () => ({ apiConfiguration: { diff --git a/src/core/webview/generateSystemPrompt.ts b/src/core/webview/generateSystemPrompt.ts index c24c666f600..228d16a8e11 100644 --- a/src/core/webview/generateSystemPrompt.ts +++ b/src/core/webview/generateSystemPrompt.ts @@ -100,11 +100,10 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web toolProtocol, isStealthModel: modelInfo?.isStealthModel, }, - // kilocode_change start - undefined, - undefined, - state, - // kilocode_change end + undefined, // todoList + undefined, // modelId + provider.getSkillsManager(), + state, // kilocode_change ) return systemPrompt diff --git a/src/package.json b/src/package.json index 26a7bb26f93..687ea0d80ec 100644 --- a/src/package.json +++ b/src/package.json @@ -3,7 +3,7 @@ "displayName": "%extension.displayName%", "description": "%extension.description%", "publisher": "kilocode", - "version": "4.140.3", + "version": "4.141.0", "icon": "assets/icons/logo-outline-black.png", "galleryBanner": { "color": "#FFFFFF", diff --git a/src/services/skills/SkillsManager.ts b/src/services/skills/SkillsManager.ts new file mode 100644 index 00000000000..fd616df6a6f --- /dev/null +++ b/src/services/skills/SkillsManager.ts @@ -0,0 +1,329 @@ +import * as fs from "fs/promises" +import * as path from "path" +import * as vscode from "vscode" +import matter from "gray-matter" + +import type { ClineProvider } from "../../core/webview/ClineProvider" +import { getGlobalRooDirectory } from "../roo-config" +import { directoryExists, fileExists } from "../roo-config" +import { SkillMetadata, SkillContent } from "../../shared/skills" +import { modes, getAllModes } from "../../shared/modes" + +// Re-export for convenience +export type { SkillMetadata, SkillContent } + +export class SkillsManager { + private skills: Map = new Map() + private providerRef: WeakRef + private disposables: vscode.Disposable[] = [] + private isDisposed = false + + constructor(provider: ClineProvider) { + this.providerRef = new WeakRef(provider) + } + + async initialize(): Promise { + await this.discoverSkills() + await this.setupFileWatchers() + } + + /** + * Discover all skills from global and project directories. + * Supports both generic skills (skills/) and mode-specific skills (skills-{mode}/). + * Also supports symlinks: + * - .kilocode/skills can be a symlink to a directory containing skill subdirectories + * - .kilocode/skills/[dirname] can be a symlink to a skill directory + */ + async discoverSkills(): Promise { + this.skills.clear() + const skillsDirs = await this.getSkillsDirectories() + + for (const { dir, source, mode } of skillsDirs) { + await this.scanSkillsDirectory(dir, source, mode) + } + } + + /** + * Scan a skills directory for skill subdirectories. + * Handles two symlink cases: + * 1. The skills directory itself is a symlink (resolved by directoryExists using realpath) + * 2. Individual skill subdirectories are symlinks + */ + private async scanSkillsDirectory(dirPath: string, source: "global" | "project", mode?: string): Promise { + if (!(await directoryExists(dirPath))) { + return + } + + try { + // Get the real path (resolves if dirPath is a symlink) + const realDirPath = await fs.realpath(dirPath) + + // Read directory entries + const entries = await fs.readdir(realDirPath) + + for (const entryName of entries) { + const entryPath = path.join(realDirPath, entryName) + + // Check if this entry is a directory (follows symlinks automatically) + const stats = await fs.stat(entryPath).catch(() => null) + if (!stats?.isDirectory()) continue + + // Load skill metadata - the skill name comes from the entry name (symlink name if symlinked) + await this.loadSkillMetadata(entryPath, source, mode, entryName) + } + } catch { + // Directory doesn't exist or can't be read - this is fine + } + } + + /** + * Load skill metadata from a skill directory. + * @param skillDir - The resolved path to the skill directory (target of symlink if symlinked) + * @param source - Whether this is a global or project skill + * @param mode - The mode this skill is specific to (undefined for generic skills) + * @param skillName - The skill name (from symlink name if symlinked, otherwise from directory name) + */ + private async loadSkillMetadata( + skillDir: string, + source: "global" | "project", + mode?: string, + skillName?: string, + ): Promise { + const skillMdPath = path.join(skillDir, "SKILL.md") + if (!(await fileExists(skillMdPath))) return + + try { + const fileContent = await fs.readFile(skillMdPath, "utf-8") + + // Use gray-matter to parse frontmatter + const { data: frontmatter, content: body } = matter(fileContent) + + // Validate required fields (only name and description for now) + if (!frontmatter.name || typeof frontmatter.name !== "string") { + console.error(`Skill at ${skillDir} is missing required 'name' field`) + return + } + if (!frontmatter.description || typeof frontmatter.description !== "string") { + console.error(`Skill at ${skillDir} is missing required 'description' field`) + return + } + + // Validate that frontmatter name matches the skill name (directory name or symlink name) + // Per the Agent Skills spec: "name field must match the parent directory name" + const effectiveSkillName = skillName || path.basename(skillDir) + if (frontmatter.name !== effectiveSkillName) { + console.error(`Skill name "${frontmatter.name}" doesn't match directory "${effectiveSkillName}"`) + return + } + + // Create unique key combining name, source, and mode for override resolution + const skillKey = this.getSkillKey(effectiveSkillName, source, mode) + + this.skills.set(skillKey, { + name: effectiveSkillName, + description: frontmatter.description, + path: skillMdPath, + source, + mode, // undefined for generic skills, string for mode-specific + }) + } catch (error) { + console.error(`Failed to load skill at ${skillDir}:`, error) + } + } + + /** + * Get skills available for the current mode. + * Resolves overrides: project > global, mode-specific > generic. + * + * @param currentMode - The current mode slug (e.g., 'code', 'architect') + */ + getSkillsForMode(currentMode: string): SkillMetadata[] { + const resolvedSkills = new Map() + + for (const skill of this.skills.values()) { + // Skip mode-specific skills that don't match current mode + if (skill.mode && skill.mode !== currentMode) continue + + const existingSkill = resolvedSkills.get(skill.name) + + if (!existingSkill) { + resolvedSkills.set(skill.name, skill) + continue + } + + // Apply override rules + const shouldOverride = this.shouldOverrideSkill(existingSkill, skill) + if (shouldOverride) { + resolvedSkills.set(skill.name, skill) + } + } + + return Array.from(resolvedSkills.values()) + } + + /** + * Determine if newSkill should override existingSkill based on priority rules. + * Priority: project > global, mode-specific > generic + */ + private shouldOverrideSkill(existing: SkillMetadata, newSkill: SkillMetadata): boolean { + // Project always overrides global + if (newSkill.source === "project" && existing.source === "global") return true + if (newSkill.source === "global" && existing.source === "project") return false + + // Same source: mode-specific overrides generic + if (newSkill.mode && !existing.mode) return true + if (!newSkill.mode && existing.mode) return false + + // Same source and same mode-specificity: keep existing (first wins) + return false + } + + /** + * Get all skills (for UI display, debugging, etc.) + */ + getAllSkills(): SkillMetadata[] { + return Array.from(this.skills.values()) + } + + async getSkillContent(name: string, currentMode?: string): Promise { + // If mode is provided, try to find the best matching skill + let skill: SkillMetadata | undefined + + if (currentMode) { + const modeSkills = this.getSkillsForMode(currentMode) + skill = modeSkills.find((s) => s.name === name) + } else { + // Fall back to any skill with this name + skill = Array.from(this.skills.values()).find((s) => s.name === name) + } + + if (!skill) return null + + const fileContent = await fs.readFile(skill.path, "utf-8") + const { content: body } = matter(fileContent) + + return { + ...skill, + instructions: body.trim(), + } + } + + /** + * Get all skills directories to scan, including mode-specific directories. + */ + private async getSkillsDirectories(): Promise< + Array<{ + dir: string + source: "global" | "project" + mode?: string + }> + > { + const dirs: Array<{ dir: string; source: "global" | "project"; mode?: string }> = [] + const globalRooDir = getGlobalRooDirectory() + const provider = this.providerRef.deref() + const projectRooDir = provider?.cwd ? path.join(provider.cwd, ".kilocode") : null + + // Get list of modes to check for mode-specific skills + const modesList = await this.getAvailableModes() + + // Global directories + dirs.push({ dir: path.join(globalRooDir, "skills"), source: "global" }) + for (const mode of modesList) { + dirs.push({ dir: path.join(globalRooDir, `skills-${mode}`), source: "global", mode }) + } + + // Project directories + if (projectRooDir) { + dirs.push({ dir: path.join(projectRooDir, "skills"), source: "project" }) + for (const mode of modesList) { + dirs.push({ dir: path.join(projectRooDir, `skills-${mode}`), source: "project", mode }) + } + } + + return dirs + } + + /** + * Get list of available modes (built-in + custom) + */ + private async getAvailableModes(): Promise { + const provider = this.providerRef.deref() + const builtInModeSlugs = modes.map((m) => m.slug) + + if (!provider) { + return builtInModeSlugs + } + + try { + const customModes = await provider.customModesManager.getCustomModes() + const allModes = getAllModes(customModes) + return allModes.map((m) => m.slug) + } catch { + return builtInModeSlugs + } + } + + private getSkillKey(name: string, source: string, mode?: string): string { + return `${source}:${mode || "generic"}:${name}` + } + + private async setupFileWatchers(): Promise { + // Skip if test environment is detected or VSCode APIs are not available + if (process.env.NODE_ENV === "test" || !vscode.workspace.createFileSystemWatcher) { + return + } + + const provider = this.providerRef.deref() + if (!provider?.cwd) return + + // Watch for changes in skills directories + const globalSkillsDir = path.join(getGlobalRooDirectory(), "skills") + const projectSkillsDir = path.join(provider.cwd, ".kilocode", "skills") + + // Watch global skills directory + this.watchDirectory(globalSkillsDir) + + // Watch project skills directory + this.watchDirectory(projectSkillsDir) + + // Watch mode-specific directories for all available modes + const modesList = await this.getAvailableModes() + for (const mode of modesList) { + this.watchDirectory(path.join(getGlobalRooDirectory(), `skills-${mode}`)) + this.watchDirectory(path.join(provider.cwd, ".kilocode", `skills-${mode}`)) + } + } + + private watchDirectory(dirPath: string): void { + if (process.env.NODE_ENV === "test" || !vscode.workspace.createFileSystemWatcher) { + return + } + + const pattern = new vscode.RelativePattern(dirPath, "**/SKILL.md") + const watcher = vscode.workspace.createFileSystemWatcher(pattern) + + watcher.onDidChange(async (uri) => { + if (this.isDisposed) return + await this.discoverSkills() + }) + + watcher.onDidCreate(async (uri) => { + if (this.isDisposed) return + await this.discoverSkills() + }) + + watcher.onDidDelete(async (uri) => { + if (this.isDisposed) return + await this.discoverSkills() + }) + + this.disposables.push(watcher) + } + + async dispose(): Promise { + this.isDisposed = true + this.disposables.forEach((d) => d.dispose()) + this.disposables = [] + this.skills.clear() + } +} diff --git a/src/services/skills/__tests__/SkillsManager.spec.ts b/src/services/skills/__tests__/SkillsManager.spec.ts new file mode 100644 index 00000000000..4cb6714b55c --- /dev/null +++ b/src/services/skills/__tests__/SkillsManager.spec.ts @@ -0,0 +1,715 @@ +import * as path from "path" + +// Use vi.hoisted to ensure mocks are available during hoisting +const { mockStat, mockReadFile, mockReaddir, mockHomedir, mockDirectoryExists, mockFileExists, mockRealpath } = + vi.hoisted(() => ({ + mockStat: vi.fn(), + mockReadFile: vi.fn(), + mockReaddir: vi.fn(), + mockHomedir: vi.fn(), + mockDirectoryExists: vi.fn(), + mockFileExists: vi.fn(), + mockRealpath: vi.fn(), + })) + +// Platform-agnostic test paths +// Use forward slashes for consistency, then normalize with path.normalize +const HOME_DIR = process.platform === "win32" ? "C:\\Users\\testuser" : "/home/user" +const PROJECT_DIR = process.platform === "win32" ? "C:\\test\\project" : "/test/project" +const SHARED_DIR = process.platform === "win32" ? "C:\\shared\\skills" : "/shared/skills" + +// Helper to create platform-appropriate paths +const p = (...segments: string[]) => path.join(...segments) + +// Mock fs/promises module +vi.mock("fs/promises", () => ({ + default: { + stat: mockStat, + readFile: mockReadFile, + readdir: mockReaddir, + realpath: mockRealpath, + }, + stat: mockStat, + readFile: mockReadFile, + readdir: mockReaddir, + realpath: mockRealpath, +})) + +// Mock os module +vi.mock("os", () => ({ + homedir: mockHomedir, +})) + +// Mock vscode +vi.mock("vscode", () => ({ + workspace: { + createFileSystemWatcher: vi.fn(() => ({ + onDidChange: vi.fn(), + onDidCreate: vi.fn(), + onDidDelete: vi.fn(), + dispose: vi.fn(), + })), + }, + RelativePattern: vi.fn(), +})) + +// Global roo directory - computed once +const GLOBAL_ROO_DIR = p(HOME_DIR, ".kilocode") + +// Mock roo-config +vi.mock("../../roo-config", () => ({ + getGlobalRooDirectory: () => GLOBAL_ROO_DIR, + directoryExists: mockDirectoryExists, + fileExists: mockFileExists, +})) + +import { SkillsManager } from "../SkillsManager" +import { ClineProvider } from "../../../core/webview/ClineProvider" + +describe("SkillsManager", () => { + let skillsManager: SkillsManager + let mockProvider: Partial + + // Pre-computed paths for tests + const globalSkillsDir = p(GLOBAL_ROO_DIR, "skills") + const globalSkillsCodeDir = p(GLOBAL_ROO_DIR, "skills-code") + const globalSkillsArchitectDir = p(GLOBAL_ROO_DIR, "skills-architect") + const projectRooDir = p(PROJECT_DIR, ".kilocode") + const projectSkillsDir = p(projectRooDir, "skills") + + beforeEach(() => { + vi.clearAllMocks() + mockHomedir.mockReturnValue(HOME_DIR) + + // Create mock provider + mockProvider = { + cwd: PROJECT_DIR, + customModesManager: { + getCustomModes: vi.fn().mockResolvedValue([]), + } as any, + } + + skillsManager = new SkillsManager(mockProvider as ClineProvider) + }) + + afterEach(async () => { + await skillsManager.dispose() + }) + + describe("discoverSkills", () => { + it("should discover skills from global directory", async () => { + const pdfSkillDir = p(globalSkillsDir, "pdf-processing") + const pdfSkillMd = p(pdfSkillDir, "SKILL.md") + + // Setup mocks + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === globalSkillsDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === globalSkillsDir) { + return ["pdf-processing"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === pdfSkillDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + return file === pdfSkillMd + }) + + mockReadFile.mockImplementation(async (file: string) => { + if (file === pdfSkillMd) { + return `--- +name: pdf-processing +description: Extract text and tables from PDF files +--- + +# PDF Processing + +Instructions here...` + } + throw new Error("File not found") + }) + + await skillsManager.discoverSkills() + + const skills = skillsManager.getAllSkills() + expect(skills).toHaveLength(1) + expect(skills[0].name).toBe("pdf-processing") + expect(skills[0].description).toBe("Extract text and tables from PDF files") + expect(skills[0].source).toBe("global") + }) + + it("should discover skills from project directory", async () => { + const codeReviewDir = p(projectSkillsDir, "code-review") + const codeReviewMd = p(codeReviewDir, "SKILL.md") + + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === projectSkillsDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === projectSkillsDir) { + return ["code-review"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === codeReviewDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + return file === codeReviewMd + }) + + mockReadFile.mockImplementation(async (file: string) => { + if (file === codeReviewMd) { + return `--- +name: code-review +description: Review code for best practices +--- + +# Code Review + +Instructions here...` + } + throw new Error("File not found") + }) + + await skillsManager.discoverSkills() + + const skills = skillsManager.getAllSkills() + expect(skills).toHaveLength(1) + expect(skills[0].name).toBe("code-review") + expect(skills[0].source).toBe("project") + }) + + it("should discover mode-specific skills", async () => { + const refactoringDir = p(globalSkillsCodeDir, "refactoring") + const refactoringMd = p(refactoringDir, "SKILL.md") + + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === globalSkillsCodeDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === globalSkillsCodeDir) { + return ["refactoring"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === refactoringDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + return file === refactoringMd + }) + + mockReadFile.mockImplementation(async (file: string) => { + if (file === refactoringMd) { + return `--- +name: refactoring +description: Refactor code for better maintainability +--- + +# Refactoring + +Instructions here...` + } + throw new Error("File not found") + }) + + await skillsManager.discoverSkills() + + const skills = skillsManager.getAllSkills() + expect(skills).toHaveLength(1) + expect(skills[0].name).toBe("refactoring") + expect(skills[0].mode).toBe("code") + }) + + it("should skip skills with missing required fields", async () => { + const invalidSkillDir = p(globalSkillsDir, "invalid-skill") + const invalidSkillMd = p(invalidSkillDir, "SKILL.md") + + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === globalSkillsDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === globalSkillsDir) { + return ["invalid-skill"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === invalidSkillDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + return file === invalidSkillMd + }) + + mockReadFile.mockImplementation(async (file: string) => { + if (file === invalidSkillMd) { + return `--- +name: invalid-skill +--- + +# Missing description field` + } + throw new Error("File not found") + }) + + await skillsManager.discoverSkills() + + const skills = skillsManager.getAllSkills() + expect(skills).toHaveLength(0) + }) + + it("should skip skills where name doesn't match directory", async () => { + const mySkillDir = p(globalSkillsDir, "my-skill") + const mySkillMd = p(mySkillDir, "SKILL.md") + + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === globalSkillsDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === globalSkillsDir) { + return ["my-skill"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === mySkillDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + return file === mySkillMd + }) + + mockReadFile.mockImplementation(async (file: string) => { + if (file === mySkillMd) { + return `--- +name: different-name +description: Name doesn't match directory +--- + +# Mismatched name` + } + throw new Error("File not found") + }) + + await skillsManager.discoverSkills() + + const skills = skillsManager.getAllSkills() + expect(skills).toHaveLength(0) + }) + + it("should handle symlinked skills directory", async () => { + const sharedSkillDir = p(SHARED_DIR, "shared-skill") + const sharedSkillMd = p(sharedSkillDir, "SKILL.md") + + // Simulate .kilocode/skills being a symlink to /shared/skills + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === globalSkillsDir + }) + + // realpath resolves the symlink to the actual directory + mockRealpath.mockImplementation(async (pathArg: string) => { + if (pathArg === globalSkillsDir) { + return SHARED_DIR + } + return pathArg + }) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === SHARED_DIR) { + return ["shared-skill"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === sharedSkillDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + return file === sharedSkillMd + }) + + mockReadFile.mockImplementation(async (file: string) => { + if (file === sharedSkillMd) { + return `--- +name: shared-skill +description: A skill from a symlinked directory +--- + +# Shared Skill + +Instructions here...` + } + throw new Error("File not found") + }) + + await skillsManager.discoverSkills() + + const skills = skillsManager.getAllSkills() + expect(skills).toHaveLength(1) + expect(skills[0].name).toBe("shared-skill") + expect(skills[0].source).toBe("global") + }) + + it("should handle symlinked skill subdirectory", async () => { + const myAliasDir = p(globalSkillsDir, "my-alias") + const myAliasMd = p(myAliasDir, "SKILL.md") + + // Simulate .kilocode/skills/my-alias being a symlink to /external/actual-skill + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === globalSkillsDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === globalSkillsDir) { + return ["my-alias"] + } + return [] + }) + + // fs.stat follows symlinks, so it returns the target directory info + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === myAliasDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + return file === myAliasMd + }) + + // The skill name in frontmatter must match the symlink name (my-alias) + mockReadFile.mockImplementation(async (file: string) => { + if (file === myAliasMd) { + return `--- +name: my-alias +description: A skill accessed via symlink +--- + +# My Alias Skill + +Instructions here...` + } + throw new Error("File not found") + }) + + await skillsManager.discoverSkills() + + const skills = skillsManager.getAllSkills() + expect(skills).toHaveLength(1) + expect(skills[0].name).toBe("my-alias") + expect(skills[0].source).toBe("global") + }) + }) + + describe("getSkillsForMode", () => { + it("should return skills filtered by mode", async () => { + const genericSkillDir = p(globalSkillsDir, "generic-skill") + const codeSkillDir = p(globalSkillsCodeDir, "code-skill") + + // Setup skills for testing + mockDirectoryExists.mockImplementation(async (dir: string) => { + return [globalSkillsDir, globalSkillsCodeDir].includes(dir) + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === globalSkillsDir) { + return ["generic-skill"] + } + if (dir === globalSkillsCodeDir) { + return ["code-skill"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === genericSkillDir || pathArg === codeSkillDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockResolvedValue(true) + + mockReadFile.mockImplementation(async (file: string) => { + if (file.includes("generic-skill")) { + return `--- +name: generic-skill +description: Generic skill +--- +Instructions` + } + if (file.includes("code-skill")) { + return `--- +name: code-skill +description: Code skill +--- +Instructions` + } + throw new Error("File not found") + }) + + await skillsManager.discoverSkills() + + const codeSkills = skillsManager.getSkillsForMode("code") + + // Should include both generic and code-specific skills + expect(codeSkills.length).toBe(2) + expect(codeSkills.map((s) => s.name)).toContain("generic-skill") + expect(codeSkills.map((s) => s.name)).toContain("code-skill") + }) + + it("should apply project > global override", async () => { + const globalSharedSkillDir = p(globalSkillsDir, "shared-skill") + const projectSharedSkillDir = p(projectSkillsDir, "shared-skill") + + mockDirectoryExists.mockImplementation(async (dir: string) => { + return [globalSkillsDir, projectSkillsDir].includes(dir) + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === globalSkillsDir) { + return ["shared-skill"] + } + if (dir === projectSkillsDir) { + return ["shared-skill"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === globalSharedSkillDir || pathArg === projectSharedSkillDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockResolvedValue(true) + + mockReadFile.mockResolvedValue(`--- +name: shared-skill +description: Shared skill +--- +Instructions`) + + await skillsManager.discoverSkills() + + const skills = skillsManager.getSkillsForMode("code") + const sharedSkill = skills.find((s) => s.name === "shared-skill") + + // Project skill should override global + expect(sharedSkill?.source).toBe("project") + }) + + it("should apply mode-specific > generic override", async () => { + const genericTestSkillDir = p(globalSkillsDir, "test-skill") + const codeTestSkillDir = p(globalSkillsCodeDir, "test-skill") + + mockDirectoryExists.mockImplementation(async (dir: string) => { + return [globalSkillsDir, globalSkillsCodeDir].includes(dir) + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === globalSkillsDir) { + return ["test-skill"] + } + if (dir === globalSkillsCodeDir) { + return ["test-skill"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === genericTestSkillDir || pathArg === codeTestSkillDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockResolvedValue(true) + + mockReadFile.mockResolvedValue(`--- +name: test-skill +description: Test skill +--- +Instructions`) + + await skillsManager.discoverSkills() + + const skills = skillsManager.getSkillsForMode("code") + const testSkill = skills.find((s) => s.name === "test-skill") + + // Mode-specific should override generic + expect(testSkill?.mode).toBe("code") + }) + + it("should not include mode-specific skills for other modes", async () => { + const architectOnlyDir = p(globalSkillsArchitectDir, "architect-only") + + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === globalSkillsArchitectDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === globalSkillsArchitectDir) { + return ["architect-only"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === architectOnlyDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockResolvedValue(true) + + mockReadFile.mockResolvedValue(`--- +name: architect-only +description: Only for architect mode +--- +Instructions`) + + await skillsManager.discoverSkills() + + const codeSkills = skillsManager.getSkillsForMode("code") + const architectSkill = codeSkills.find((s) => s.name === "architect-only") + + expect(architectSkill).toBeUndefined() + }) + }) + + describe("getSkillContent", () => { + it("should return full skill content", async () => { + const testSkillDir = p(globalSkillsDir, "test-skill") + const testSkillMd = p(testSkillDir, "SKILL.md") + + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === globalSkillsDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === globalSkillsDir) { + return ["test-skill"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === testSkillDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + return file === testSkillMd + }) + + const skillContent = `--- +name: test-skill +description: A test skill +--- + +# Test Skill + +## Instructions + +1. Do this +2. Do that` + + mockReadFile.mockResolvedValue(skillContent) + + await skillsManager.discoverSkills() + + const content = await skillsManager.getSkillContent("test-skill") + + expect(content).not.toBeNull() + expect(content?.name).toBe("test-skill") + expect(content?.instructions).toContain("# Test Skill") + expect(content?.instructions).toContain("1. Do this") + }) + + it("should return null for non-existent skill", async () => { + mockDirectoryExists.mockResolvedValue(false) + mockRealpath.mockImplementation(async (p: string) => p) + mockReaddir.mockResolvedValue([]) + + await skillsManager.discoverSkills() + + const content = await skillsManager.getSkillContent("non-existent") + + expect(content).toBeNull() + }) + }) + + describe("dispose", () => { + it("should clean up resources", async () => { + await skillsManager.dispose() + + const skills = skillsManager.getAllSkills() + expect(skills).toHaveLength(0) + }) + }) +}) diff --git a/src/shared/kilocode/headers.ts b/src/shared/kilocode/headers.ts index 9f7be47c0ef..080606622a8 100644 --- a/src/shared/kilocode/headers.ts +++ b/src/shared/kilocode/headers.ts @@ -2,4 +2,5 @@ export const X_KILOCODE_VERSION = "X-KiloCode-Version" export const X_KILOCODE_ORGANIZATIONID = "X-KiloCode-OrganizationId" export const X_KILOCODE_TASKID = "X-KiloCode-TaskId" export const X_KILOCODE_PROJECTID = "X-KiloCode-ProjectId" +export const X_KILOCODE_EDITORNAME = "X-KiloCode-EditorName" export const X_KILOCODE_TESTER = "X-KILOCODE-TESTER" diff --git a/src/shared/skills.ts b/src/shared/skills.ts new file mode 100644 index 00000000000..7ed85816aa8 --- /dev/null +++ b/src/shared/skills.ts @@ -0,0 +1,18 @@ +/** + * Skill metadata for discovery (loaded at startup) + * Only name and description are required for now + */ +export interface SkillMetadata { + name: string // Required: skill identifier + description: string // Required: when to use this skill + path: string // Absolute path to SKILL.md + source: "global" | "project" // Where the skill was discovered + mode?: string // If set, skill is only available in this mode +} + +/** + * Full skill content (loaded on activation) + */ +export interface SkillContent extends SkillMetadata { + instructions: string // Full markdown body +}