Compare commits

...
22 changed files with 630 additions and 393 deletions
+6
View File
@@ -16,6 +16,12 @@ service FileService {
// Opens a file in the editor
rpc openFile(StringRequest) returns (Empty);
// Opens the current log file in the editor
rpc openLogFile(EmptyRequest) returns (Empty);
// Opens the logs folder in the OS file manager
rpc openLogsFolder(EmptyRequest) returns (Empty);
// Opens an image in the system viewer
rpc openImage(StringRequest) returns (Empty);
+9
View File
@@ -26,6 +26,9 @@ service WindowService {
// Opens a file in the IDE.
rpc openFile(OpenFileRequest) returns (OpenFileResponse);
// Reveals a file or folder in the OS file manager (Finder, Explorer, etc.)
rpc revealInFileManager(RevealInFileManagerRequest) returns (RevealInFileManagerResponse);
// Opens the host settings UI, optionally focusing a specific query/section.
rpc openSettings(OpenSettingsRequest) returns (OpenSettingsResponse);
@@ -130,6 +133,12 @@ message OpenFileRequest {
message OpenFileResponse {}
message RevealInFileManagerRequest {
string file_path = 1;
}
message RevealInFileManagerResponse {}
message OpenSettingsRequest {
// Optional query to focus a particular settings section/key.
// This value is host-specific. In VS Code, it is passed directly as the
+17
View File
@@ -0,0 +1,17 @@
import { openFile as openFileIntegration } from "@integrations/misc/open-file"
import { Empty, EmptyRequest } from "@shared/proto/cline/common"
import { Logger } from "@/services/logging/Logger"
import { Controller } from ".."
/**
* Opens the current log file in the editor.
* VS Code mode: Opens the log file from global storage.
* Standalone mode: Does nothing (logs are managed by parent process).
*/
export async function openLogFile(_controller: Controller, _request: EmptyRequest): Promise<Empty> {
const logPath = Logger.ensureLogFileAndGetPath()
if (logPath) {
await openFileIntegration(logPath)
}
return Empty.create()
}
@@ -0,0 +1,22 @@
import { Empty, EmptyRequest } from "@shared/proto/cline/common"
import { HostProvider } from "@/hosts/host-provider"
import { getVSCodeLogsDir } from "@/services/logging/constants"
import { Controller } from ".."
/**
* Opens the centralized logs folder in the OS file manager (Finder, Explorer, etc.)
* Opens ~/.cline/logs/ so users can see all Cline logs (VS Code, CLI, etc.)
* @param controller The controller instance
* @param request Empty request
* @returns Empty response
*/
export async function openLogsFolder(_controller: Controller, _request: EmptyRequest): Promise<Empty> {
// Open the centralized logs directory where all Cline logs are stored
const logsDir = getVSCodeLogsDir()
await HostProvider.window.revealInFileManager({
filePath: logsDir,
})
return Empty.create()
}
+1 -1
View File
@@ -2597,7 +2597,7 @@ export class Task {
// present content to user - we don't want the stream to break if present fails, so we catch errors here
await this.presentAssistantMessage().catch((error) =>
Logger.debug("[Task] Failed to present message: " + error),
Logger.error("[Task] Failed to present message: " + error),
)
if (this.taskState.abort) {
@@ -18,7 +18,7 @@ describe("WorkspaceResolver", () => {
beforeEach(() => {
resolver = new WorkspaceResolver()
loggerStub = sinon.stub(Logger, "debug")
loggerStub = sinon.stub(Logger, "log")
originalEnv = process.env.MULTI_ROOT_TRACE
})
+3
View File
@@ -486,6 +486,9 @@ export async function deactivate() {
tearDown()
// Clean up file logging resources
Logger.cleanup()
// Clean up test mode
cleanupTestMode()
@@ -0,0 +1,10 @@
import { RevealInFileManagerRequest, RevealInFileManagerResponse } from "@shared/proto/host/window"
import * as vscode from "vscode"
/**
* Reveals a file or folder in the OS file manager (Finder, Explorer, etc.)
*/
export async function revealInFileManager(request: RevealInFileManagerRequest): Promise<RevealInFileManagerResponse> {
await vscode.commands.executeCommand("revealFileInOS", vscode.Uri.file(request.filePath))
return RevealInFileManagerResponse.create()
}
@@ -43,7 +43,6 @@ export class AudioRecordingService {
if (this.outputFile && fs.existsSync(this.outputFile)) {
try {
fs.unlinkSync(this.outputFile)
Logger.info("Temporary audio file cleaned up")
} catch (error) {
Logger.warn("Failed to cleanup temporary audio file: " + (error instanceof Error ? error.message : String(error)))
} finally {
@@ -60,7 +59,6 @@ export class AudioRecordingService {
return
}
Logger.info("Terminating recording process...")
this.recordingProcess.kill("SIGINT")
// Wait for the process to finish with timeout
@@ -72,7 +70,6 @@ export class AudioRecordingService {
this.recordingProcess?.on("exit", (code) => {
clearTimeout(timeoutId)
Logger.info(`Recording process exited with code: ${code}`)
resolve()
})
})
@@ -96,7 +93,6 @@ export class AudioRecordingService {
try {
// Defensive cleanup before starting - ensures clean state
if (this.recordingProcess || this.outputFile) {
Logger.info("Performing pre-recording cleanup of stale resources...")
await this.performCleanup()
}
@@ -114,14 +110,11 @@ export class AudioRecordingService {
const tempDir = os.tmpdir()
this.outputFile = path.join(tempDir, `cline_recording_${Date.now()}.webm`)
Logger.info("Starting audio recording...")
// Get the recording program path
const recordProgram = this.getRecordProgram()
if (!recordProgram) {
return { success: false, error: "Recording program not found" }
}
Logger.info(`Using recording program: ${recordProgram.path}`)
// Set up recording arguments
const args = recordProgram.getArgs(this.outputFile)
@@ -150,7 +143,6 @@ export class AudioRecordingService {
}
})
Logger.info("Audio recording started successfully")
return { success: true }
} catch (error) {
await this.performCleanup()
@@ -166,8 +158,6 @@ export class AudioRecordingService {
return { success: false, error: "Not currently recording" }
}
Logger.info("Stopping audio recording...")
// Terminate the process but keep the file for reading
await this.terminateProcess()
this.resetRecordingState()
@@ -186,7 +176,6 @@ export class AudioRecordingService {
// Clean up temporary file after reading
await this.cleanupTempFile()
Logger.info("Audio recording stopped and converted to base64")
return { success: true, audioBase64 }
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
@@ -205,12 +194,9 @@ export class AudioRecordingService {
return { success: false, error: "Not currently recording" }
}
Logger.info("Canceling audio recording...")
// Perform full cleanup including file deletion
await this.performCleanup()
Logger.info("Audio recording canceled successfully")
return { success: true }
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
@@ -102,7 +102,7 @@ export class VoiceTranscriptionService {
async transcribeAudio(audioBase64: string, language?: string): Promise<{ text?: string; error?: string }> {
try {
Logger.info("Transcribing audio with Cline transcription service...")
Logger.log("Transcribing audio with Cline transcription service...")
// Check if using organization account for telemetry
const userInfo = await this.clineAccountService.fetchMe()
@@ -111,7 +111,7 @@ export class VoiceTranscriptionService {
const result = await this.clineAccountService.transcribeAudio(audioBase64, language)
Logger.info("Transcription successful")
Logger.log("Transcription successful")
// Capture telemetry with account type - use dynamic import to avoid circular dependency
const { telemetryService } = await import("@/services/telemetry")
@@ -60,12 +60,12 @@ export class FeatureFlagsProviderFactory {
*/
class NoOpFeatureFlagsProvider implements IFeatureFlagsProvider {
public async getFeatureFlag(flagName: string): Promise<boolean | string | undefined> {
Logger.info(`[NoOpFeatureFlagsProvider] getFeatureFlag called with flagName=${flagName}`)
Logger.log(`[NoOpFeatureFlagsProvider] getFeatureFlag called with flagName=${flagName}`)
return undefined
}
public async getFeatureFlagPayload(flagName: string) {
Logger.info(`[NoOpFeatureFlagsProvider] getFeatureFlagPayload called with flagName=${flagName}`)
Logger.log(`[NoOpFeatureFlagsProvider] getFeatureFlagPayload called with flagName=${flagName}`)
return null
}
@@ -81,6 +81,6 @@ class NoOpFeatureFlagsProvider implements IFeatureFlagsProvider {
}
public async dispose(): Promise<void> {
Logger.info("[NoOpFeatureFlagsProvider] Disposing")
Logger.log("[NoOpFeatureFlagsProvider] Disposing")
}
}
+134 -9
View File
@@ -1,40 +1,165 @@
import * as fs from "fs"
import * as path from "path"
import { HostProvider } from "@/hosts/host-provider"
import { ErrorService } from "../error"
import { getVSCodeLogsDir } from "./constants"
import { cleanupLogsOlderThan, LOG_RETENTION_MS } from "./retention"
import { formatLogFilenameTimestamp, formatLogMessageTimestamp } from "./timestamp"
/**
* Simple logging utility for the extension's backend code.
* In VS Code mode, logs to console and a file.
* In standalone mode, logs to console (which is redirected to file by the parent process).
*/
export class Logger {
public readonly channelName = "Cline Dev Logger"
private static fileStream?: fs.WriteStream
private static logFilePath?: string
/**
* Ensures log file is ready for writing. Creates a new log file if needed.
* This method is called automatically on first use.
*
* Note: If the active log file is deleted during the session, the WriteStream may keep writing to
* an unlinked file descriptor (so logs stop persisting to disk).
*/
private static ensureLogFileReady(): void {
// Skip file logging in standalone mode - console is redirected by parent process
if (process.env.IS_STANDALONE === "true") {
return
}
if (Logger.fileStream) {
return
}
try {
const logsDir = getVSCodeLogsDir()
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir, { recursive: true })
}
const timestamp = formatLogFilenameTimestamp()
const pid = process.pid
const logFileName = `cline-vscode-${timestamp}-${pid}.log`
Logger.logFilePath = path.join(logsDir, logFileName)
Logger.fileStream = fs.createWriteStream(Logger.logFilePath, { flags: "a" })
void cleanupLogsOlderThan({
logsDir,
retentionMs: LOG_RETENTION_MS,
})
console.log(`Logger initialized - logs will be written to: ${Logger.logFilePath}`)
} catch (error) {
console.error("Failed to initialize file logging:", error)
}
}
/**
* Clean up file logging resources.
* Should be called in extension deactivate().
*/
static cleanup(): void {
if (Logger.fileStream) {
Logger.fileStream.end()
Logger.fileStream = undefined
Logger.logFilePath = undefined
}
}
/**
* Get the path to the current log file (VS Code mode only)
*/
static getLogFilePath(): string | undefined {
return Logger.logFilePath
}
/**
* Ensures the log file is initialized and returns its path.
* This is useful when you need the log file path and want to guarantee it exists.
* VS Code mode: Creates the log file if needed and returns the path.
* Standalone mode: Returns undefined (logs are managed by parent process).
*/
static ensureLogFileAndGetPath(): string | undefined {
Logger.ensureLogFileReady()
return Logger.logFilePath
}
static error(message: string, error?: Error) {
Logger.#output("ERROR", message, error)
Logger.output("ERROR", message, error)
ErrorService.get().logMessage(message, "error")
error && ErrorService.get().logException(error)
}
static warn(message: string) {
Logger.#output("WARN", message)
Logger.output("WARN", message)
ErrorService.get().logMessage(message, "warning")
}
static log(message: string) {
Logger.#output("LOG", message)
Logger.output("LOG", message)
}
static debug(message: string) {
Logger.#output("DEBUG", message)
Logger.output("DEBUG", message)
}
static info(message: string) {
Logger.#output("INFO", message)
Logger.output("INFO", message)
}
static trace(message: string) {
Logger.#output("TRACE", message)
}
static #output(level: string, message: string, error?: Error) {
private static output(level: string, message: string, error?: Error) {
Logger.ensureLogFileReady()
let fullMessage = message
if (error?.message) {
fullMessage += ` ${error.message}`
}
// Log to the VS Code output channel
HostProvider.get().logToChannel(`${level} ${fullMessage}`)
if (error?.stack) {
console.log(`Stack trace:\n${error.stack}`)
}
// Pass through to standard output naturally - no formatting
switch (level) {
case "ERROR":
if (error) {
console.error(message, error)
} else {
console.error(message)
}
break
case "WARN":
console.warn(message)
break
case "DEBUG":
console.debug(message)
break
case "INFO":
console.info(message)
break
default:
console.log(message)
break
}
// If VS Code, format and write to file
if (Logger.fileStream) {
const timestamp = formatLogMessageTimestamp()
const formattedMessage = `[${timestamp}] ${level} ${fullMessage}`
try {
Logger.fileStream.write(formattedMessage + "\n")
if (error?.stack) {
Logger.fileStream.write(`Stack trace:\n${error.stack}\n`)
}
} catch (writeError) {
console.error("Failed to write to log file:", writeError)
}
}
}
}
@@ -0,0 +1,63 @@
import * as fs from "node:fs/promises"
import * as os from "node:os"
import * as path from "node:path"
import { describe, it } from "mocha"
import "should"
import { cleanupLogsOlderThan } from "../retention"
async function createTempDir(): Promise<string> {
return await fs.mkdtemp(path.join(os.tmpdir(), "cline-logs-retention-"))
}
async function touchWithMtime(filePath: string, mtime: Date): Promise<void> {
await fs.writeFile(filePath, "test")
await fs.utimes(filePath, mtime, mtime)
}
describe("logging retention", () => {
it("deletes files older than the retention window", async () => {
const dir = await createTempDir()
const oldFile = path.join(dir, "old.log")
const newFile = path.join(dir, "new.log")
const now = Date.now()
await touchWithMtime(oldFile, new Date(now - 10_000))
await touchWithMtime(newFile, new Date(now - 500))
await cleanupLogsOlderThan({ logsDir: dir, retentionMs: 1_000 })
await fs.access(newFile)
await fs
.access(oldFile)
.then(() => {
throw new Error("expected old file to be deleted")
})
.catch(() => undefined)
})
it("does not delete the active log file", async () => {
const dir = await createTempDir()
const recentlyWrittenFile = path.join(dir, "recent.log")
const oldFile = path.join(dir, "old.log")
const now = Date.now()
await touchWithMtime(recentlyWrittenFile, new Date(now - 500))
await touchWithMtime(oldFile, new Date(now - 10_000))
await cleanupLogsOlderThan({ logsDir: dir, retentionMs: 1_000 })
await fs.access(recentlyWrittenFile)
await fs
.access(oldFile)
.then(() => {
throw new Error("expected other old file to be deleted")
})
.catch(() => undefined)
})
it("no-ops when the logs directory does not exist", async () => {
const dir = path.join(os.tmpdir(), `cline-logs-retention-missing-${Date.now()}`)
await cleanupLogsOlderThan({ logsDir: dir, retentionMs: 1_000 })
})
})
+10
View File
@@ -0,0 +1,10 @@
import * as os from "os"
import * as path from "path"
/**
* Gets the VS Code logs directory path (~/.cline/logs/vscode)
* This is the centralized location for all VS Code extension logs.
*/
export function getVSCodeLogsDir(): string {
return path.join(os.homedir(), ".cline", "logs", "vscode")
}
+64
View File
@@ -0,0 +1,64 @@
import { promises as fs, type Stats } from "fs"
import * as path from "path"
export const LOG_RETENTION_DAYS = 30
export const LOG_RETENTION_MS = LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000
export interface CleanupLogsOlderThanOptions {
logsDir: string
retentionMs?: number
}
/**
* Opportunistically removes old log files.
*
* Deletes regular files in `logsDir` whose `mtime` is older than `retentionMs`.
* `mtime` is treated as "last active" (appends update it), which is safe across multiple VS Code windows.
*
* This function is non-fatal: it swallows filesystem errors (e.g. locked files on Windows,
* concurrent writers, files disappearing between stat/unlink) so logging/startup is never impacted.
*/
export async function cleanupLogsOlderThan(options: CleanupLogsOlderThanOptions): Promise<void> {
const { logsDir, retentionMs = LOG_RETENTION_MS } = options
// If the logs directory doesn't exist, nothing to do.
try {
await fs.access(logsDir)
} catch {
return
}
let entries: string[]
try {
entries = await fs.readdir(logsDir)
} catch {
return
}
const cutoffMs = Date.now() - retentionMs
await Promise.all(
entries.map(async (entry) => {
const filePath = path.join(logsDir, entry)
let stat: Stats
try {
stat = await fs.stat(filePath)
} catch {
return
}
if (!stat.isFile()) {
return
}
if (stat.mtimeMs < cutoffMs) {
try {
await fs.unlink(filePath)
} catch {
return
}
}
}),
)
}
+23
View File
@@ -0,0 +1,23 @@
/**
* Shared timestamp formatting utilities used across different logging contexts.
* This ensures consistent timestamp formats in logs across VS Code, CLI, and standalone modes.
*/
/**
* Formats a date into the timestamp format used in log filenames.
* Format: YYYY-MM-DDTHH-mm-ss (UTC)
* Example: 2025-01-15T14-30-45
*/
export function formatLogFilenameTimestamp(date: Date = new Date()): string {
// Use ISO format and replace colons with dashes for filename compatibility
return date.toISOString().replace(/:/g, "-").split(".")[0]
}
/**
* Formats a date into the timestamp format used in log messages.
* Format: YYYY-MM-DDTHH:mm:ss.SSSZ (UTC)
* Example: 2025-01-15T14:30:45.123Z
*/
export function formatLogMessageTimestamp(date: Date = new Date()): string {
return date.toISOString()
}
@@ -71,7 +71,7 @@ export class TelemetryProviderFactory {
if (meterProvider || loggerProvider) {
return await new OpenTelemetryTelemetryProvider().initialize()
}
Logger.info("TelemetryProviderFactory: OpenTelemetry providers not available")
Logger.log("TelemetryProviderFactory: OpenTelemetry providers not available")
return new NoOpTelemetryProvider()
}
case "no-op":
@@ -119,10 +119,10 @@ export class NoOpTelemetryProvider implements ITelemetryProvider {
Logger.log(`[NoOpTelemetryProvider] REQUIRED ${_event}: ${JSON.stringify(_properties)}`)
}
identifyUser(_userInfo: any, _properties?: TelemetryProperties): void {
Logger.info(`[NoOpTelemetryProvider] identifyUser - ${JSON.stringify(_userInfo)} - ${JSON.stringify(_properties)}`)
Logger.log(`[NoOpTelemetryProvider] identifyUser - ${JSON.stringify(_userInfo)} - ${JSON.stringify(_properties)}`)
}
setOptIn(_optIn: boolean): void {
Logger.info(`[NoOpTelemetryProvider] setOptIn(${_optIn})`)
Logger.log(`[NoOpTelemetryProvider] setOptIn(${_optIn})`)
this.isOptIn = _optIn
}
isEnabled(): boolean {
@@ -163,6 +163,6 @@ export class NoOpTelemetryProvider implements ITelemetryProvider {
// no-op
}
async dispose(): Promise<void> {
Logger.info(`[NoOpTelemetryProvider] Disposing (optIn=${this.isOptIn})`)
Logger.log(`[NoOpTelemetryProvider] Disposing (optIn=${this.isOptIn})`)
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ describe("SharedUriHandler", () => {
sandbox = sinon.createSandbox()
// Mock Logger methods to avoid HostProvider dependency
sandbox.stub(Logger, "info").returns()
sandbox.stub(Logger, "log").returns()
sandbox.stub(Logger, "error").returns()
// Mock ErrorService to avoid telemetry dependency
const mockErrorService = {
+2 -2
View File
@@ -19,7 +19,7 @@ export class SharedUriHandler {
const queryString = parsedUrl.search.slice(1) // Remove leading '?'
const query = new URLSearchParams(queryString.replace(/\+/g, "%2B"))
Logger.info(
Logger.log(
"SharedUriHandler: Processing URI:" +
JSON.stringify({
path: path,
@@ -58,7 +58,7 @@ export class SharedUriHandler {
case "/auth": {
const provider = query.get("provider")
Logger.info(`SharedUriHandler - Auth callback received for ${provider} - ${path}`)
Logger.log(`SharedUriHandler - Auth callback received for ${provider} - ${path}`)
const token = query.get("refreshToken") || query.get("idToken") || query.get("code")
if (token) {
+2 -11
View File
@@ -2,19 +2,10 @@ import * as protoLoader from "@grpc/proto-loader"
import * as fs from "fs"
import * as health from "grpc-health-check"
import { StreamingCallbacks } from "@/hosts/host-provider-types"
import { formatLogMessageTimestamp } from "@/services/logging/timestamp"
const log = (...args: unknown[]) => {
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, "0")
const day = String(now.getDate()).padStart(2, "0")
const hours = String(now.getHours()).padStart(2, "0")
const minutes = String(now.getMinutes()).padStart(2, "0")
const seconds = String(now.getSeconds()).padStart(2, "0")
const milliseconds = String(now.getMilliseconds()).padStart(3, "0")
const timestamp = `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${milliseconds}`
const timestamp = formatLogMessageTimestamp()
console.log(`[${timestamp}]`, "#bot.cline.server.ts", ...args)
}
+217 -345
View File
@@ -1,407 +1,279 @@
import { afterEach, beforeEach, describe, it } from "mocha";
import "should";
import * as sinon from "sinon";
import { Controller } from "../core/controller";
import { getAvailableSlashCommands } from "../core/controller/slash/getAvailableSlashCommands";
import { EmptyRequest } from "../shared/proto/cline/common";
import { BASE_SLASH_COMMANDS } from "../shared/slashCommands";
import { afterEach, beforeEach, describe, it } from "mocha"
import "should"
import * as sinon from "sinon"
import { Controller } from "../core/controller"
import { getAvailableSlashCommands } from "../core/controller/slash/getAvailableSlashCommands"
import { EmptyRequest } from "../shared/proto/cline/common"
import { BASE_SLASH_COMMANDS } from "../shared/slashCommands"
/**
* Unit tests for getAvailableSlashCommands RPC endpoint
* Tests the slash command discovery and filtering functionality
*/
describe("getAvailableSlashCommands", () => {
let mockController: Partial<Controller>;
let mockStateManager: {
getWorkspaceStateKey: sinon.SinonStub;
getGlobalSettingsKey: sinon.SinonStub;
getGlobalStateKey: sinon.SinonStub;
getRemoteConfigSettings: sinon.SinonStub;
};
let mockController: Partial<Controller>
let mockStateManager: {
getWorkspaceStateKey: sinon.SinonStub
getGlobalSettingsKey: sinon.SinonStub
getGlobalStateKey: sinon.SinonStub
getRemoteConfigSettings: sinon.SinonStub
}
beforeEach(() => {
mockStateManager = {
getWorkspaceStateKey: sinon.stub(),
getGlobalSettingsKey: sinon.stub(),
getGlobalStateKey: sinon.stub(),
getRemoteConfigSettings: sinon.stub(),
};
beforeEach(() => {
mockStateManager = {
getWorkspaceStateKey: sinon.stub(),
getGlobalSettingsKey: sinon.stub(),
getGlobalStateKey: sinon.stub(),
getRemoteConfigSettings: sinon.stub(),
}
// Default stubs return empty/null values
mockStateManager.getWorkspaceStateKey.returns(null);
mockStateManager.getGlobalSettingsKey.returns(null);
mockStateManager.getGlobalStateKey.returns(null);
mockStateManager.getRemoteConfigSettings.returns(null);
// Default stubs return empty/null values
mockStateManager.getWorkspaceStateKey.returns(null)
mockStateManager.getGlobalSettingsKey.returns(null)
mockStateManager.getGlobalStateKey.returns(null)
mockStateManager.getRemoteConfigSettings.returns(null)
mockController = {
stateManager: mockStateManager as any,
};
});
mockController = {
stateManager: mockStateManager as any,
}
})
afterEach(() => {
sinon.restore();
});
afterEach(() => {
sinon.restore()
})
describe("Base Slash Commands", () => {
it("should return all base slash commands", async () => {
const response = await getAvailableSlashCommands(
mockController as Controller,
EmptyRequest.create()
);
describe("Base Slash Commands", () => {
it("should return all base slash commands", async () => {
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
// Should have at least all base commands
response.commands.length.should.be.greaterThanOrEqual(
BASE_SLASH_COMMANDS.length
);
// Should have at least all base commands
response.commands.length.should.be.greaterThanOrEqual(BASE_SLASH_COMMANDS.length)
// Verify each base command is present
for (const baseCmd of BASE_SLASH_COMMANDS) {
const found = response.commands.find(
(cmd) => cmd.name === baseCmd.name
);
found!.should.not.be.undefined();
found!.description.should.equal(baseCmd.description);
found!.section.should.equal("default");
found!.cliCompatible.should.equal(baseCmd.cliCompatible ?? false);
}
});
// Verify each base command is present
for (const baseCmd of BASE_SLASH_COMMANDS) {
const found = response.commands.find((cmd) => cmd.name === baseCmd.name)
found!.should.not.be.undefined()
found!.description.should.equal(baseCmd.description)
found!.section.should.equal("default")
found!.cliCompatible.should.equal(baseCmd.cliCompatible ?? false)
}
})
it("should mark base commands with section 'default'", async () => {
const response = await getAvailableSlashCommands(
mockController as Controller,
EmptyRequest.create()
);
it("should mark base commands with section 'default'", async () => {
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
const baseCommandNames = BASE_SLASH_COMMANDS.map((cmd) => cmd.name);
for (const cmd of response.commands) {
if (baseCommandNames.includes(cmd.name)) {
cmd.section.should.equal("default");
}
}
});
});
const baseCommandNames = BASE_SLASH_COMMANDS.map((cmd) => cmd.name)
for (const cmd of response.commands) {
if (baseCommandNames.includes(cmd.name)) {
cmd.section.should.equal("default")
}
}
})
})
describe("Local Workflow Toggles", () => {
it("should include enabled local workflows", async () => {
mockStateManager.getWorkspaceStateKey
.withArgs("workflowToggles")
.returns({
"/path/to/my-workflow.md": true,
"/path/to/another-workflow.md": true,
});
describe("Local Workflow Toggles", () => {
it("should include enabled local workflows", async () => {
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({
"/path/to/my-workflow.md": true,
"/path/to/another-workflow.md": true,
})
const response = await getAvailableSlashCommands(
mockController as Controller,
EmptyRequest.create()
);
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
const myWorkflow = response.commands.find(
(cmd) => cmd.name === "my-workflow.md"
);
myWorkflow!.should.not.be.undefined();
myWorkflow!.section.should.equal("custom");
myWorkflow!.cliCompatible.should.equal(true);
const myWorkflow = response.commands.find((cmd) => cmd.name === "my-workflow.md")
myWorkflow!.should.not.be.undefined()
myWorkflow!.section.should.equal("custom")
myWorkflow!.cliCompatible.should.equal(true)
const anotherWorkflow = response.commands.find(
(cmd) => cmd.name === "another-workflow.md"
);
anotherWorkflow!.should.not.be.undefined();
});
const anotherWorkflow = response.commands.find((cmd) => cmd.name === "another-workflow.md")
anotherWorkflow!.should.not.be.undefined()
})
it("should exclude disabled local workflows", async () => {
mockStateManager.getWorkspaceStateKey
.withArgs("workflowToggles")
.returns({
"/path/to/enabled-workflow.md": true,
"/path/to/disabled-workflow.md": false,
});
it("should exclude disabled local workflows", async () => {
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({
"/path/to/enabled-workflow.md": true,
"/path/to/disabled-workflow.md": false,
})
const response = await getAvailableSlashCommands(
mockController as Controller,
EmptyRequest.create()
);
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
const enabled = response.commands.find(
(cmd) => cmd.name === "enabled-workflow.md"
);
enabled!.should.not.be.undefined();
const enabled = response.commands.find((cmd) => cmd.name === "enabled-workflow.md")
enabled!.should.not.be.undefined()
const disabled = response.commands.find(
(cmd) => cmd.name === "disabled-workflow.md"
);
(disabled === undefined).should.be.true();
});
const disabled = response.commands.find((cmd) => cmd.name === "disabled-workflow.md")
;(disabled === undefined).should.be.true()
})
it("should extract filename from full path", async () => {
mockStateManager.getWorkspaceStateKey
.withArgs("workflowToggles")
.returns({
"/Users/test/project/.clinerules/workflows/deep-analysis.md": true,
});
it("should extract filename from full path", async () => {
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({
"/Users/test/project/.clinerules/workflows/deep-analysis.md": true,
})
const response = await getAvailableSlashCommands(
mockController as Controller,
EmptyRequest.create()
);
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
const workflow = response.commands.find(
(cmd) => cmd.name === "deep-analysis.md"
);
workflow!.should.not.be.undefined();
});
const workflow = response.commands.find((cmd) => cmd.name === "deep-analysis.md")
workflow!.should.not.be.undefined()
})
it("should handle Windows-style paths", async () => {
mockStateManager.getWorkspaceStateKey
.withArgs("workflowToggles")
.returns({
"C:\\Users\\test\\project\\.clinerules\\workflows\\windows-workflow.md":
true,
});
it("should handle Windows-style paths", async () => {
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({
"C:\\Users\\test\\project\\.clinerules\\workflows\\windows-workflow.md": true,
})
const response = await getAvailableSlashCommands(
mockController as Controller,
EmptyRequest.create()
);
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
const workflow = response.commands.find(
(cmd) => cmd.name === "windows-workflow.md"
);
workflow!.should.not.be.undefined();
});
});
const workflow = response.commands.find((cmd) => cmd.name === "windows-workflow.md")
workflow!.should.not.be.undefined()
})
})
describe("Global Workflow Toggles", () => {
it("should include enabled global workflows", async () => {
mockStateManager.getGlobalSettingsKey
.withArgs("globalWorkflowToggles")
.returns({
"/global/path/global-workflow.md": true,
});
describe("Global Workflow Toggles", () => {
it("should include enabled global workflows", async () => {
mockStateManager.getGlobalSettingsKey.withArgs("globalWorkflowToggles").returns({
"/global/path/global-workflow.md": true,
})
const response = await getAvailableSlashCommands(
mockController as Controller,
EmptyRequest.create()
);
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
const workflow = response.commands.find(
(cmd) => cmd.name === "global-workflow.md"
);
workflow!.should.not.be.undefined();
workflow!.section.should.equal("custom");
});
const workflow = response.commands.find((cmd) => cmd.name === "global-workflow.md")
workflow!.should.not.be.undefined()
workflow!.section.should.equal("custom")
})
it("should exclude disabled global workflows", async () => {
mockStateManager.getGlobalSettingsKey
.withArgs("globalWorkflowToggles")
.returns({
"/global/path/disabled-global.md": false,
});
it("should exclude disabled global workflows", async () => {
mockStateManager.getGlobalSettingsKey.withArgs("globalWorkflowToggles").returns({
"/global/path/disabled-global.md": false,
})
const response = await getAvailableSlashCommands(
mockController as Controller,
EmptyRequest.create()
);
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
const workflow = response.commands.find(
(cmd) => cmd.name === "disabled-global.md"
);
(workflow === undefined).should.be.true();
});
});
const workflow = response.commands.find((cmd) => cmd.name === "disabled-global.md")
;(workflow === undefined).should.be.true()
})
})
describe("Workflow Deduplication", () => {
it("should prefer local workflows over global workflows with same name", async () => {
// Same filename in both local and global
mockStateManager.getWorkspaceStateKey
.withArgs("workflowToggles")
.returns({
"/local/path/shared-workflow.md": true,
});
mockStateManager.getGlobalSettingsKey
.withArgs("globalWorkflowToggles")
.returns({
"/global/path/shared-workflow.md": true,
});
describe("Workflow Deduplication", () => {
it("should prefer local workflows over global workflows with same name", async () => {
// Same filename in both local and global
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({
"/local/path/shared-workflow.md": true,
})
mockStateManager.getGlobalSettingsKey.withArgs("globalWorkflowToggles").returns({
"/global/path/shared-workflow.md": true,
})
const response = await getAvailableSlashCommands(
mockController as Controller,
EmptyRequest.create()
);
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
// Should only appear once
const matches = response.commands.filter(
(cmd) => cmd.name === "shared-workflow.md"
);
matches.length.should.equal(1);
});
// Should only appear once
const matches = response.commands.filter((cmd) => cmd.name === "shared-workflow.md")
matches.length.should.equal(1)
})
it("should include global workflow if local with same name is disabled", async () => {
mockStateManager.getWorkspaceStateKey
.withArgs("workflowToggles")
.returns({
"/local/path/shared-workflow.md": false, // disabled locally
});
mockStateManager.getGlobalSettingsKey
.withArgs("globalWorkflowToggles")
.returns({
"/global/path/shared-workflow.md": true, // enabled globally
});
it("should include global workflow if local with same name is disabled", async () => {
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({
"/local/path/shared-workflow.md": false, // disabled locally
})
mockStateManager.getGlobalSettingsKey.withArgs("globalWorkflowToggles").returns({
"/global/path/shared-workflow.md": true, // enabled globally
})
const response = await getAvailableSlashCommands(
mockController as Controller,
EmptyRequest.create()
);
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
// Global should appear since local is disabled
const workflow = response.commands.find(
(cmd) => cmd.name === "shared-workflow.md"
);
workflow!.should.not.be.undefined();
});
});
// Global should appear since local is disabled
const workflow = response.commands.find((cmd) => cmd.name === "shared-workflow.md")
workflow!.should.not.be.undefined()
})
})
describe("Remote Workflows", () => {
it("should include alwaysEnabled remote workflows", async () => {
mockStateManager.getRemoteConfigSettings.returns({
remoteGlobalWorkflows: [
{ name: "always-on-workflow", alwaysEnabled: true },
],
});
describe("Remote Workflows", () => {
it("should include alwaysEnabled remote workflows", async () => {
mockStateManager.getRemoteConfigSettings.returns({
remoteGlobalWorkflows: [{ name: "always-on-workflow", alwaysEnabled: true }],
})
const response = await getAvailableSlashCommands(
mockController as Controller,
EmptyRequest.create()
);
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
const workflow = response.commands.find(
(cmd) => cmd.name === "always-on-workflow"
);
workflow!.should.not.be.undefined();
workflow!.section.should.equal("custom");
});
const workflow = response.commands.find((cmd) => cmd.name === "always-on-workflow")
workflow!.should.not.be.undefined()
workflow!.section.should.equal("custom")
})
it("should include remote workflows enabled by toggle", async () => {
mockStateManager.getRemoteConfigSettings.returns({
remoteGlobalWorkflows: [
{ name: "toggle-workflow", alwaysEnabled: false },
],
});
mockStateManager.getGlobalStateKey
.withArgs("remoteWorkflowToggles")
.returns({
"toggle-workflow": true, // not explicitly disabled
});
it("should include remote workflows enabled by toggle", async () => {
mockStateManager.getRemoteConfigSettings.returns({
remoteGlobalWorkflows: [{ name: "toggle-workflow", alwaysEnabled: false }],
})
mockStateManager.getGlobalStateKey.withArgs("remoteWorkflowToggles").returns({
"toggle-workflow": true, // not explicitly disabled
})
const response = await getAvailableSlashCommands(
mockController as Controller,
EmptyRequest.create()
);
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
const workflow = response.commands.find(
(cmd) => cmd.name === "toggle-workflow"
);
workflow!.should.not.be.undefined();
});
const workflow = response.commands.find((cmd) => cmd.name === "toggle-workflow")
workflow!.should.not.be.undefined()
})
it("should exclude remote workflows explicitly disabled by toggle", async () => {
mockStateManager.getRemoteConfigSettings.returns({
remoteGlobalWorkflows: [
{ name: "disabled-remote", alwaysEnabled: false },
],
});
mockStateManager.getGlobalStateKey
.withArgs("remoteWorkflowToggles")
.returns({
"disabled-remote": false,
});
it("should exclude remote workflows explicitly disabled by toggle", async () => {
mockStateManager.getRemoteConfigSettings.returns({
remoteGlobalWorkflows: [{ name: "disabled-remote", alwaysEnabled: false }],
})
mockStateManager.getGlobalStateKey.withArgs("remoteWorkflowToggles").returns({
"disabled-remote": false,
})
const response = await getAvailableSlashCommands(
mockController as Controller,
EmptyRequest.create()
);
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
const workflow = response.commands.find(
(cmd) => cmd.name === "disabled-remote"
);
(workflow === undefined).should.be.true();
});
const workflow = response.commands.find((cmd) => cmd.name === "disabled-remote")
;(workflow === undefined).should.be.true()
})
it("should include remote workflows by default if not explicitly disabled", async () => {
mockStateManager.getRemoteConfigSettings.returns({
remoteGlobalWorkflows: [
{ name: "default-enabled", alwaysEnabled: false },
],
});
// No toggle entry for this workflow
mockStateManager.getGlobalStateKey
.withArgs("remoteWorkflowToggles")
.returns({});
it("should include remote workflows by default if not explicitly disabled", async () => {
mockStateManager.getRemoteConfigSettings.returns({
remoteGlobalWorkflows: [{ name: "default-enabled", alwaysEnabled: false }],
})
// No toggle entry for this workflow
mockStateManager.getGlobalStateKey.withArgs("remoteWorkflowToggles").returns({})
const response = await getAvailableSlashCommands(
mockController as Controller,
EmptyRequest.create()
);
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
const workflow = response.commands.find(
(cmd) => cmd.name === "default-enabled"
);
workflow!.should.not.be.undefined();
});
});
const workflow = response.commands.find((cmd) => cmd.name === "default-enabled")
workflow!.should.not.be.undefined()
})
})
describe("Edge Cases", () => {
it("should handle null/undefined state values gracefully", async () => {
mockStateManager.getWorkspaceStateKey.returns(null);
mockStateManager.getGlobalSettingsKey.returns(undefined);
mockStateManager.getGlobalStateKey.returns(null);
mockStateManager.getRemoteConfigSettings.returns(null);
describe("Edge Cases", () => {
it("should handle null/undefined state values gracefully", async () => {
mockStateManager.getWorkspaceStateKey.returns(null)
mockStateManager.getGlobalSettingsKey.returns(undefined)
mockStateManager.getGlobalStateKey.returns(null)
mockStateManager.getRemoteConfigSettings.returns(null)
const response = await getAvailableSlashCommands(
mockController as Controller,
EmptyRequest.create()
);
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
// Should still return base commands
response.commands.length.should.be.greaterThanOrEqual(
BASE_SLASH_COMMANDS.length
);
});
// Should still return base commands
response.commands.length.should.be.greaterThanOrEqual(BASE_SLASH_COMMANDS.length)
})
it("should handle empty workflow toggle objects", async () => {
mockStateManager.getWorkspaceStateKey
.withArgs("workflowToggles")
.returns({});
mockStateManager.getGlobalSettingsKey
.withArgs("globalWorkflowToggles")
.returns({});
mockStateManager.getGlobalStateKey
.withArgs("remoteWorkflowToggles")
.returns({});
mockStateManager.getRemoteConfigSettings.returns({
remoteGlobalWorkflows: [],
});
it("should handle empty workflow toggle objects", async () => {
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({})
mockStateManager.getGlobalSettingsKey.withArgs("globalWorkflowToggles").returns({})
mockStateManager.getGlobalStateKey.withArgs("remoteWorkflowToggles").returns({})
mockStateManager.getRemoteConfigSettings.returns({
remoteGlobalWorkflows: [],
})
const response = await getAvailableSlashCommands(
mockController as Controller,
EmptyRequest.create()
);
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
// Should only have base commands
response.commands.length.should.equal(BASE_SLASH_COMMANDS.length);
});
// Should only have base commands
response.commands.length.should.equal(BASE_SLASH_COMMANDS.length)
})
it("should handle remote config with no remoteGlobalWorkflows property", async () => {
mockStateManager.getRemoteConfigSettings.returns({});
it("should handle remote config with no remoteGlobalWorkflows property", async () => {
mockStateManager.getRemoteConfigSettings.returns({})
const response = await getAvailableSlashCommands(
mockController as Controller,
EmptyRequest.create()
);
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
// Should not throw, just return base commands
response.commands.length.should.be.greaterThanOrEqual(
BASE_SLASH_COMMANDS.length
);
});
});
});
// Should not throw, just return base commands
response.commands.length.should.be.greaterThanOrEqual(BASE_SLASH_COMMANDS.length)
})
})
})
@@ -1,4 +1,7 @@
import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import { PLATFORM_CONFIG, PlatformType } from "../../../config/platform.config"
import { FileServiceClient } from "../../../services/grpc-client"
import { Button } from "../../ui/button"
import Section from "../Section"
interface AboutSectionProps {
@@ -45,6 +48,39 @@ const AboutSection = ({ version, renderSectionHeader }: AboutSectionProps) => {
{" • "}
<VSCodeLink href="https://cline.bot/">https://cline.bot</VSCodeLink>
</p>
{PLATFORM_CONFIG.type === PlatformType.VSCODE && (
<>
<h3 className="text-md font-semibold">Logs</h3>
<p className="text-sm text-(--vscode-descriptionForeground)">
Cline writes detailed logs to help diagnose issues. Each session creates a separate log file. If
you encounter an error, these logs can be shared with the Cline team for troubleshooting.
</p>
<p className="text-xs text-(--vscode-descriptionForeground)">Old logs are automatically removed.</p>
<div className="flex flex-col gap-2 max-w-md">
<Button
className="w-full whitespace-normal min-h-[32px]"
onClick={() => FileServiceClient.openLogFile({})}
variant="secondary">
Current Log
</Button>
<p className="text-xs text-(--vscode-descriptionForeground) -mt-1 ml-1">
View the log file for this session
</p>
<Button
className="w-full whitespace-normal min-h-[32px]"
onClick={() => FileServiceClient.openLogsFolder({})}
variant="secondary">
All Logs
</Button>
<p className="text-xs text-(--vscode-descriptionForeground) -mt-1 ml-1">
Browse all session logs in your file manager
</p>
</div>
</>
)}
</div>
</Section>
</div>