refactor(vscode)!: demolish extension host to bare-bones inert SDK shell

Gut the VSCode extension host down to a minimal, inert shell so it can be rebuilt
from near-zero on the Cline SDK, the way apps/cli is. The webview UI is kept fully
intact (it renders and you can click around), but every backend action is now a
no-op.

Approach: the webview talks to the host only through generated proto service
clients over a postMessage bridge and never imports handler implementations. So
each gRPC handler under core/controller was gutted to an inert stub that returns
an empty/default proto response and imports nothing downstream, severing the
handler layer from all implementation. With nothing left referencing it, the
implementation was deleted; the shell (extension.ts, common.ts, Controller,
hosts/) was rewritten to the minimum needed to render the webview and route gRPC.

Deleted entirely: src/sdk, src/services, src/integrations,
src/core/{task,context,hooks,storage,prompts,mentions,ignore,locks}, most of
src/hosts/vscode (terminal, diff, review, commit-message generation), and all
host-side tests.

Survived: the gRPC plumbing + gutted handlers, a minimal inert Controller, the
webview provider, src/shared (incl. proto types), and src/utils. extension.ts
shrank from 760 to 106 lines.

Verified: 'bun run protos && tsc --noEmit' is clean for the host and
'tsc --noEmit' is clean for the webview. NOTE: only typechecking is verified --
the bundle builds (esbuild/vite) and a real Extension Development Host launch have
not been run yet, and package.json still declares commands whose handlers were
removed.

BREAKING CHANGE: the extension is intentionally non-functional; this is a
foundation for an SDK-backed rebuild, not a shippable state.
This commit is contained in:
Saoud Rizwan
2026-06-21 13:23:10 -07:00
parent 2807b088e5
commit c4c126bee9
561 changed files with 720 additions and 92173 deletions
-705
View File
@@ -1,705 +0,0 @@
import { afterEach, beforeEach, describe, it, mock } from "bun:test"
import "should"
import fs from "fs/promises"
import * as actualOs from "os"
import path from "path"
import sinon from "sinon"
// The SUT does `import * as os from "os"; os.homedir()`. Under bun, sinon's
// `stub(os, "homedir")` on the test's own `os` binding does NOT propagate to the
// SUT's namespace import, so inject a module-level homedir stub via mock.module
// (the rest of `os` — tmpdir() etc. — keeps its real behavior).
const homedirStub = sinon.stub()
const osMockNamespace = { ...actualOs, homedir: homedirStub }
const osMock = () => ({ ...osMockNamespace, default: osMockNamespace })
mock.module("os", osMock)
mock.module("node:os", osMock)
import os from "os"
import { ClineConfigurationError, ClineEndpoint, ClineEnv, Environment } from "../config"
describe("ClineEndpoint configuration", () => {
let sandbox: sinon.SinonSandbox
let tempDir: string
let originalHomedir: typeof os.homedir
beforeEach(async () => {
sandbox = sinon.createSandbox()
tempDir = path.join(os.tmpdir(), `config-test-${Date.now()}-${Math.random().toString(36).slice(2)}`)
await fs.mkdir(tempDir, { recursive: true })
// Create .cline directory
await fs.mkdir(path.join(tempDir, ".cline"), { recursive: true })
// Stub os.homedir to return our temp directory (via mock.module homedirStub)
originalHomedir = os.homedir
homedirStub.reset()
homedirStub.returns(tempDir)
// Reset the singleton state using internal method
;(ClineEndpoint as any)._instance = null
;(ClineEndpoint as any)._initialized = false
;(ClineEndpoint as any)._extensionFsPath = undefined
})
afterEach(async () => {
sandbox.restore()
// Reset singleton state
;(ClineEndpoint as any)._instance = null
;(ClineEndpoint as any)._initialized = false
try {
await fs.rm(tempDir, { recursive: true, force: true })
} catch {
// Ignore cleanup errors
}
})
describe("valid config parsing", () => {
it("should parse valid endpoints.json with all required fields", async () => {
const validConfig = {
appBaseUrl: "https://app.enterprise.com",
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(validConfig), "utf8")
await ClineEndpoint.initialize(tempDir)
const config = ClineEndpoint.config
config.appBaseUrl.should.equal("https://app.enterprise.com")
config.apiBaseUrl.should.equal("https://api.enterprise.com")
config.mcpBaseUrl.should.equal("https://mcp.enterprise.com")
config.environment.should.equal(Environment.selfHosted)
})
it("should work without endpoints.json (standard mode)", async () => {
// No endpoints.json file exists
await ClineEndpoint.initialize(tempDir)
const config = ClineEndpoint.config
config.environment.should.not.equal(Environment.selfHosted)
// Should use production defaults
config.appBaseUrl.should.equal("https://app.cline.bot")
config.apiBaseUrl.should.equal("https://api.cline.bot")
})
it("should accept URLs with ports", async () => {
const validConfig = {
appBaseUrl: "http://localhost:3000",
apiBaseUrl: "http://localhost:7777",
mcpBaseUrl: "http://localhost:8080/mcp",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(validConfig), "utf8")
await ClineEndpoint.initialize(tempDir)
const config = ClineEndpoint.config
config.appBaseUrl.should.equal("http://localhost:3000")
config.apiBaseUrl.should.equal("http://localhost:7777")
config.mcpBaseUrl.should.equal("http://localhost:8080/mcp")
})
it("should accept URLs with paths", async () => {
const validConfig = {
appBaseUrl: "https://proxy.enterprise.com/cline/app",
apiBaseUrl: "https://proxy.enterprise.com/cline/api",
mcpBaseUrl: "https://proxy.enterprise.com/cline/mcp",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(validConfig), "utf8")
await ClineEndpoint.initialize(tempDir)
const config = ClineEndpoint.config
config.appBaseUrl.should.equal("https://proxy.enterprise.com/cline/app")
})
})
describe("invalid JSON handling", () => {
it("should throw ClineConfigurationError for invalid JSON syntax", async () => {
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), "{ invalid json }", "utf8")
try {
await ClineEndpoint.initialize(tempDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql("Invalid JSON")
}
})
it("should throw ClineConfigurationError for truncated JSON", async () => {
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), '{"appBaseUrl": "https://test.com"', "utf8")
try {
await ClineEndpoint.initialize(tempDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql("Invalid JSON")
}
})
it("should throw ClineConfigurationError for empty file", async () => {
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), "", "utf8")
try {
await ClineEndpoint.initialize(tempDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
}
})
it("should throw ClineConfigurationError for non-object JSON", async () => {
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), '"just a string"', "utf8")
try {
await ClineEndpoint.initialize(tempDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql("must contain a JSON object")
}
})
it("should throw ClineConfigurationError for array JSON", async () => {
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), "[]", "utf8")
try {
await ClineEndpoint.initialize(tempDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
// Arrays pass the object check but fail on required fields
error.message.should.containEql("Missing required field")
}
})
it("should throw ClineConfigurationError for null JSON", async () => {
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), "null", "utf8")
try {
await ClineEndpoint.initialize(tempDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql("must contain a JSON object")
}
})
})
describe("missing required fields", () => {
it("should throw ClineConfigurationError when appBaseUrl is missing", async () => {
const config = {
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
try {
await ClineEndpoint.initialize(tempDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql('Missing required field "appBaseUrl"')
}
})
it("should throw ClineConfigurationError when apiBaseUrl is missing", async () => {
const config = {
appBaseUrl: "https://app.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
try {
await ClineEndpoint.initialize(tempDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql('Missing required field "apiBaseUrl"')
}
})
it("should throw ClineConfigurationError when mcpBaseUrl is missing", async () => {
const config = {
appBaseUrl: "https://app.enterprise.com",
apiBaseUrl: "https://api.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
try {
await ClineEndpoint.initialize(tempDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql('Missing required field "mcpBaseUrl"')
}
})
it("should throw ClineConfigurationError when all fields are missing", async () => {
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), "{}", "utf8")
try {
await ClineEndpoint.initialize(tempDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql("Missing required field")
}
})
it("should throw ClineConfigurationError when field is null", async () => {
const config = {
appBaseUrl: null,
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
try {
await ClineEndpoint.initialize(tempDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql('Missing required field "appBaseUrl"')
}
})
it("should throw ClineConfigurationError when field is empty string", async () => {
const config = {
appBaseUrl: "",
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
try {
await ClineEndpoint.initialize(tempDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql("cannot be empty")
}
})
it("should throw ClineConfigurationError when field is whitespace only", async () => {
const config = {
appBaseUrl: " ",
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
try {
await ClineEndpoint.initialize(tempDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql("cannot be empty")
}
})
it("should throw ClineConfigurationError when field is non-string", async () => {
const config = {
appBaseUrl: 12345,
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
try {
await ClineEndpoint.initialize(tempDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql("must be a string")
}
})
})
describe("invalid URL detection", () => {
it("should throw ClineConfigurationError for invalid URL format", async () => {
const config = {
appBaseUrl: "not-a-valid-url",
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
try {
await ClineEndpoint.initialize(tempDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql("must be a valid URL")
}
})
it("should throw ClineConfigurationError for URL without protocol", async () => {
const config = {
appBaseUrl: "app.enterprise.com",
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
try {
await ClineEndpoint.initialize(tempDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql("must be a valid URL")
}
})
it("should throw ClineConfigurationError for malformed URL", async () => {
const config = {
appBaseUrl: "https://",
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
try {
await ClineEndpoint.initialize(tempDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql("must be a valid URL")
}
})
it("should include the invalid URL value in error message", async () => {
const invalidUrl = "definitely-not-a-url"
const config = {
appBaseUrl: invalidUrl,
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
try {
await ClineEndpoint.initialize(tempDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql(invalidUrl)
}
})
})
describe("environment switching blocked in self-hosted mode", () => {
it("should throw error when trying to change environment in self-hosted mode", async () => {
const config = {
appBaseUrl: "https://app.enterprise.com",
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
await ClineEndpoint.initialize(tempDir)
// Verify we're in self-hosted mode
ClineEndpoint.config.environment.should.equal(Environment.selfHosted)
// Try to change environment - should throw
try {
ClineEnv.setEnvironment("staging")
throw new Error("Should have thrown")
} catch (error: any) {
error.message.should.containEql("Cannot change environment in on-premise mode")
}
})
it("should throw error for all environment values in self-hosted mode", async () => {
const config = {
appBaseUrl: "https://app.enterprise.com",
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
await ClineEndpoint.initialize(tempDir)
const environments = ["staging", "local", "production", "anything"]
for (const env of environments) {
try {
ClineEnv.setEnvironment(env)
throw new Error(`Should have thrown for environment: ${env}`)
} catch (error: any) {
error.message.should.containEql("Cannot change environment in on-premise mode")
}
}
})
it("should allow environment switching in standard mode", async () => {
// No endpoints.json file - standard mode
await ClineEndpoint.initialize(tempDir)
// Verify we're NOT in self-hosted mode
ClineEndpoint.config.environment.should.not.equal(Environment.selfHosted)
// Should be able to change environment
ClineEnv.setEnvironment("staging")
ClineEnv.getEnvironment().environment.should.equal("staging")
ClineEnv.setEnvironment("local")
ClineEnv.getEnvironment().environment.should.equal("local")
ClineEnv.setEnvironment("production")
ClineEnv.getEnvironment().environment.should.equal("production")
})
})
describe("self-hosted mode behavior", () => {
it("should report selfHosted environment in self-hosted mode", async () => {
const config = {
appBaseUrl: "https://app.enterprise.com",
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
await ClineEndpoint.initialize(tempDir)
const envConfig = ClineEndpoint.config
envConfig.environment.should.equal(Environment.selfHosted)
})
it("should use custom endpoints from file", async () => {
const customConfig = {
appBaseUrl: "https://custom-app.internal",
apiBaseUrl: "https://custom-api.internal",
mcpBaseUrl: "https://custom-mcp.internal/v1",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(customConfig), "utf8")
await ClineEndpoint.initialize(tempDir)
const config = ClineEndpoint.config
config.appBaseUrl.should.equal("https://custom-app.internal")
config.apiBaseUrl.should.equal("https://custom-api.internal")
config.mcpBaseUrl.should.equal("https://custom-mcp.internal/v1")
})
})
describe("initialization behavior", () => {
it("should only initialize once", async () => {
await ClineEndpoint.initialize(tempDir)
ClineEndpoint.isInitialized().should.be.true()
// Second initialize should be a no-op
await ClineEndpoint.initialize(tempDir)
ClineEndpoint.isInitialized().should.be.true()
})
it("should throw error when accessing config before initialization", async () => {
// Already reset in beforeEach, so accessing should throw
try {
const _ = ClineEndpoint.config
throw new Error("Should have thrown")
} catch (error: any) {
error.message.should.containEql("not initialized")
}
})
})
describe("isSelfHosted() method", () => {
it("should return true when not initialized (safety fallback)", async () => {
// Reset singleton state - already done in beforeEach, not initialized
ClineEndpoint.isInitialized().should.be.false()
ClineEndpoint.isSelfHosted().should.be.true()
})
it("should return true when in self-hosted mode", async () => {
const config = {
appBaseUrl: "https://app.enterprise.com",
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
await ClineEndpoint.initialize(tempDir)
ClineEndpoint.isSelfHosted().should.be.true()
})
it("should return false when in normal mode (no endpoints.json)", async () => {
// No endpoints.json file exists
await ClineEndpoint.initialize(tempDir)
ClineEndpoint.isSelfHosted().should.be.false()
})
})
describe("bundled endpoints.json behavior", () => {
let bundledDir: string
let setVscodeHostProviderMock: (mock: { extensionFsPath: string; globalStorageFsPath: string }) => void
beforeEach(async () => {
// Create a separate directory for bundled config
bundledDir = path.join(os.tmpdir(), `config-bundled-test-${Date.now()}-${Math.random().toString(36).slice(2)}`)
await fs.mkdir(bundledDir, { recursive: true })
// Import HostProvider utilities
const hostProviderModule = await import("../test/host-provider-test-utils")
setVscodeHostProviderMock = hostProviderModule.setVscodeHostProviderMock
})
afterEach(async () => {
try {
await fs.rm(bundledDir, { recursive: true, force: true })
} catch {
// Ignore cleanup errors
}
})
it("should use bundled endpoints.json when available", async () => {
const bundledConfig = {
appBaseUrl: "https://bundled.enterprise.com",
apiBaseUrl: "https://bundled-api.enterprise.com",
mcpBaseUrl: "https://bundled-mcp.enterprise.com",
}
// Set up bundled config
await fs.writeFile(path.join(bundledDir, "endpoints.json"), JSON.stringify(bundledConfig), "utf8")
await ClineEndpoint.initialize(bundledDir)
const config = ClineEndpoint.config
config.appBaseUrl.should.equal("https://bundled.enterprise.com")
config.apiBaseUrl.should.equal("https://bundled-api.enterprise.com")
config.mcpBaseUrl.should.equal("https://bundled-mcp.enterprise.com")
config.environment.should.equal(Environment.selfHosted)
})
it("should prefer bundled endpoints.json over user file", async () => {
const bundledConfig = {
appBaseUrl: "https://bundled.enterprise.com",
apiBaseUrl: "https://bundled-api.enterprise.com",
mcpBaseUrl: "https://bundled-mcp.enterprise.com",
}
const userConfig = {
appBaseUrl: "https://user.enterprise.com",
apiBaseUrl: "https://user-api.enterprise.com",
mcpBaseUrl: "https://user-mcp.enterprise.com",
}
// Set up both configs
await fs.writeFile(path.join(bundledDir, "endpoints.json"), JSON.stringify(bundledConfig), "utf8")
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(userConfig), "utf8")
await ClineEndpoint.initialize(bundledDir)
// Should use bundled config, not user config
const config = ClineEndpoint.config
config.appBaseUrl.should.equal("https://bundled.enterprise.com")
config.apiBaseUrl.should.equal("https://bundled-api.enterprise.com")
config.mcpBaseUrl.should.equal("https://bundled-mcp.enterprise.com")
})
it("should fall back to user endpoints.json when bundled is not present", async () => {
const userConfig = {
appBaseUrl: "https://user.enterprise.com",
apiBaseUrl: "https://user-api.enterprise.com",
mcpBaseUrl: "https://user-mcp.enterprise.com",
}
// Only create user config, no bundled config
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(userConfig), "utf8")
await ClineEndpoint.initialize(bundledDir)
// Should use user config
const config = ClineEndpoint.config
config.appBaseUrl.should.equal("https://user.enterprise.com")
config.apiBaseUrl.should.equal("https://user-api.enterprise.com")
config.mcpBaseUrl.should.equal("https://user-mcp.enterprise.com")
})
it("should use standard mode when neither bundled nor user file exists", async () => {
// No config files at all
await ClineEndpoint.initialize(bundledDir)
// Should use production defaults
const config = ClineEndpoint.config
config.environment.should.not.equal(Environment.selfHosted)
config.appBaseUrl.should.equal("https://app.cline.bot")
config.apiBaseUrl.should.equal("https://api.cline.bot")
})
it("should throw ClineConfigurationError for invalid bundled file", async () => {
const invalidConfig = {
appBaseUrl: "not-a-url",
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
// Set up invalid bundled config
await fs.writeFile(path.join(bundledDir, "endpoints.json"), JSON.stringify(invalidConfig), "utf8")
try {
await ClineEndpoint.initialize(bundledDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql("must be a valid URL")
error.message.should.containEql("bundled")
}
})
it("should throw ClineConfigurationError for invalid JSON in bundled file", async () => {
// Set up invalid JSON in bundled file
await fs.writeFile(path.join(bundledDir, "endpoints.json"), "{ invalid json }", "utf8")
try {
await ClineEndpoint.initialize(bundledDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql("Invalid JSON")
error.message.should.containEql("bundled")
}
})
it("should indicate bundled source in error messages", async () => {
const incompleteConfig = {
appBaseUrl: "https://bundled.enterprise.com",
// Missing apiBaseUrl and mcpBaseUrl
}
await fs.writeFile(path.join(bundledDir, "endpoints.json"), JSON.stringify(incompleteConfig), "utf8")
try {
await ClineEndpoint.initialize(bundledDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql("Missing required field")
error.message.should.containEql(path.join(bundledDir, "endpoints.json"))
}
})
})
})
+8 -143
View File
@@ -4,173 +4,38 @@ import "./utils/path" // necessary to have access to String.prototype.toPosix
import { HostProvider } from "@/hosts/host-provider"
import { Logger } from "@/shared/services/Logger"
import type { StorageContext } from "@/shared/storage/storage-context"
import { clearOnboardingModelsCache } from "./core/controller/models/getClineOnboardingModels"
import { HookDiscoveryCache } from "./core/hooks/HookDiscoveryCache"
import { HookProcessRegistry } from "./core/hooks/HookProcessRegistry"
import { StateManager } from "./core/storage/StateManager"
import { AgentConfigLoader } from "./core/task/tools/subagent/AgentConfigLoader"
import { ExtensionRegistryInfo } from "./registry"
import { registerVsCodeLmHandler } from "./sdk/vscode-lm/register-vscode-lm"
import { ErrorService } from "./services/error"
import { featureFlagsService } from "./services/feature-flags"
import { getDistinctId } from "./services/logging/distinctId"
import { telemetryService } from "./services/telemetry"
import { PostHogClientProvider } from "./services/telemetry/providers/posthog/PostHogClientProvider"
import { ClineTempManager } from "./services/temp"
import { cleanupTestMode } from "./services/test/TestMode"
import { ShowMessageType } from "./shared/proto/host/window"
import { syncWorker } from "./shared/services/worker/sync"
import { getBlobStoreSettingsFromEnv } from "./shared/services/worker/worker"
import { getLatestAnnouncementId } from "./utils/announcements"
import { arePathsEqual } from "./utils/path"
/**
* Performs intialization for Cline that is common to all platforms.
* Performs initialization for Cline that is common to all platforms.
*
* @param context
* This is the MINIMAL inert-shell version: it only wires up logging,
* initializes the bundled endpoint configuration, and creates the webview
* provider so the UI can render. All heavy services (state manager,
* telemetry, error service, sync worker, hooks, etc.) have been removed.
*
* @param _storageContext kept for signature compatibility with callers.
* @returns The webview provider
* @throws ClineConfigurationError if endpoints.json exists but is invalid
*/
export async function initialize(storageContext: StorageContext): Promise<WebviewProvider> {
export async function initialize(_storageContext: StorageContext): Promise<WebviewProvider> {
// Configure the shared Logging class to use HostProvider's output channels and debug logger
Logger.subscribe((msg: string) => HostProvider.get().logToChannel(msg)) // File system logging
Logger.subscribe((msg: string) => HostProvider.env.debugLog({ value: msg })) // Host debug logging
// Initialize ClineEndpoint configuration (reads bundled and ~/.cline/endpoints.json if present)
// This must be done before any other code that calls ClineEnv.config()
// Throws ClineConfigurationError if config file exists but is invalid
const { ClineEndpoint } = await import("./config")
await ClineEndpoint.initialize(HostProvider.get().extensionFsPath)
try {
await StateManager.initialize(storageContext)
} catch (error) {
Logger.error("[Cline] CRITICAL: Failed to initialize StateManager:", error)
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message: "Failed to initialize storage. Please check logs for details or try restarting the client.",
})
}
// Register host-only SDK provider handlers (e.g. VS Code Language Model API),
// which depend on the `vscode` module and cannot live in the SDK package.
// Must run before any handler is built (standalone utilities or task loop).
registerVsCodeLmHandler()
// =============== External services ===============
await ErrorService.initialize()
// Initialize PostHog client provider (skip in self-hosted mode)
if (!ClineEndpoint.isSelfHosted()) {
PostHogClientProvider.getInstance()
}
// =============== Webview services ===============
const webview = HostProvider.get().createWebviewProvider()
const stateManager = StateManager.get()
// Non-blocking announcement check and display
showVersionUpdateAnnouncement(stateManager)
// Check if this workspace was opened from worktree quick launch
await checkWorktreeAutoOpen(stateManager)
// =============== Background sync and cleanup tasks ===============
// Use remote config blobStoreConfig if available, otherwise fall back to env vars
const blobStoreSettings = stateManager.getRemoteConfigSettings()?.blobStoreConfig ?? getBlobStoreSettingsFromEnv()
syncWorker().init({ ...blobStoreSettings, userDistinctId: getDistinctId() })
// Clean up old temp files in background (non-blocking) and start periodic cleanup every 24 hours
ClineTempManager.startPeriodicCleanup()
telemetryService.captureExtensionActivated()
return webview
}
async function showVersionUpdateAnnouncement(stateManager: StateManager) {
// Version checking for autoupdate notification
const currentVersion = ExtensionRegistryInfo.version
const previousVersion = stateManager.getGlobalStateKey("clineVersion")
// Perform post-update actions if necessary
try {
if (!previousVersion || currentVersion !== previousVersion) {
Logger.log(`Cline version changed: ${previousVersion} -> ${currentVersion}. First run or update detected.`)
// Check if there's a new announcement to show
const lastShownAnnouncementId = stateManager.getGlobalStateKey("lastShownAnnouncementId")
const latestAnnouncementId = getLatestAnnouncementId()
if (lastShownAnnouncementId !== latestAnnouncementId) {
// Show notification when there's a new announcement (major/minor updates or fresh installs)
const message = previousVersion
? `Cline has been updated to v${currentVersion}`
: `Welcome to Cline v${currentVersion}`
HostProvider.window.showMessage({
type: ShowMessageType.INFORMATION,
message,
})
}
// Always update the main version tracker for the next launch.
stateManager.setGlobalState("clineVersion", currentVersion)
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
Logger.error(`Error during post-update actions: ${errorMessage}, Stack trace: ${error.stack}`)
}
}
/**
* Checks if this workspace was opened from the worktree quick launch button.
* If so, opens the Cline sidebar and clears the state.
*/
async function checkWorktreeAutoOpen(stateManager: StateManager): Promise<void> {
try {
// Read directly from globalState (not StateManager cache) since this may have been
// set by another window right before this one opened
const worktreeAutoOpenPath = stateManager.getGlobalStateKey("worktreeAutoOpenPath")
if (!worktreeAutoOpenPath) {
return
}
// Get current workspace path
const workspacePaths = (await HostProvider.workspace.getWorkspacePaths({})).paths
if (workspacePaths.length === 0) {
return
}
const currentPath = workspacePaths[0]
// Check if current workspace matches the worktree path
if (arePathsEqual(currentPath, worktreeAutoOpenPath)) {
// Clear the state first to prevent re-triggering
stateManager.setGlobalState("worktreeAutoOpenPath", undefined)
// Open the Cline sidebar
await HostProvider.workspace.openClineSidebarPanel({})
}
} catch (error) {
Logger.error("Error checking worktree auto-open", error)
}
}
/**
* Performs cleanup when Cline is deactivated that is common to all platforms.
*/
export async function tearDown(): Promise<void> {
AgentConfigLoader.getInstance()?.dispose()
PostHogClientProvider.getInstance().dispose()
telemetryService.dispose()
ErrorService.get().dispose()
featureFlagsService.dispose()
// Dispose all webview instances
await WebviewProvider.disposeAllInstances()
syncWorker().dispose()
clearOnboardingModelsCache()
// Kill any running hook processes to prevent zombies
await HookProcessRegistry.terminateAll()
// Clean up hook discovery cache
HookDiscoveryCache.getInstance().dispose()
// Stop periodic temp file cleanup
ClineTempManager.stopPeriodicCleanup()
// Clean up test mode
cleanupTestMode()
}
@@ -1,32 +0,0 @@
// Type definitions for FileContextTracker
export interface FileMetadataEntry {
path: string
record_state: "active" | "stale"
record_source: "read_tool" | "user_edited" | "cline_edited" | "file_mentioned"
cline_read_date: number | null
cline_edit_date: number | null
user_edit_date?: number | null
}
interface ModelMetadataEntry {
ts: number
model_id: string
model_provider_id: string
mode: string
}
interface EnvironmentMetadataEntry {
ts: number
os_name: string
os_version: string
os_arch: string
host_name: string
host_version: string
cline_version: string
}
export interface TaskMetadata {
files_in_context: FileMetadataEntry[]
model_usage: ModelMetadataEntry[]
environment_history: EnvironmentMetadataEntry[]
}
@@ -1,263 +0,0 @@
import { afterEach, beforeEach, describe, it, mock } from "bun:test"
import * as actualDiskModule from "@core/storage/disk"
import { expect } from "chai"
import * as actualChokidar from "chokidar"
import * as path from "path"
import * as sinon from "sinon"
import * as vscode from "vscode"
import { Controller } from "@/core/controller"
import { setVscodeHostProviderMock } from "@/test/host-provider-test-utils"
import type { FileMetadataEntry, TaskMetadata } from "./ContextTrackerTypes"
// bun loads real ESM, so sinon cannot stub the `@core/storage/disk` and
// `chokidar` namespace exports ("ES Modules cannot be stubbed"). Inject
// module-level sinon stubs via mock.module so the full sinon stub API keeps
// working. (`vscode` is the writable unit-test stub, still sinon-stubbed below.)
const getTaskMetadataStub: sinon.SinonStub = sinon.stub()
const saveTaskMetadataStub: sinon.SinonStub = sinon.stub()
const chokidarWatchStub: sinon.SinonStub = sinon.stub()
const diskMock = () => ({
...actualDiskModule,
getTaskMetadata: getTaskMetadataStub,
saveTaskMetadata: saveTaskMetadataStub,
})
const chokidarNamespace = { ...actualChokidar, watch: chokidarWatchStub }
const chokidarMock = () => ({ ...chokidarNamespace, default: chokidarNamespace })
mock.module("@core/storage/disk", diskMock)
mock.module("@/core/storage/disk", diskMock)
mock.module("chokidar", chokidarMock)
import { FileContextTracker } from "./FileContextTracker"
describe("FileContextTracker", () => {
const filePath = "src/test-file.ts"
const taskId = "test-task-id"
let sandbox: sinon.SinonSandbox
let _mockWorkspace: sinon.SinonStub
let mockFileSystemWatcher: any
let tracker: FileContextTracker
let mockTaskMetadata: TaskMetadata
beforeEach(() => {
sandbox = sinon.createSandbox()
// Mock vscode workspace
_mockWorkspace = sandbox.stub(vscode.workspace, "workspaceFolders").value([
{
uri: {
fsPath: "/mock/workspace",
},
} as vscode.WorkspaceFolder,
])
// Mock chokidar file watcher
mockFileSystemWatcher = {
close: sandbox.stub().resolves(),
on: sandbox.stub(),
}
// Return the watcher itself for chaining
mockFileSystemWatcher.on.returns(mockFileSystemWatcher)
// Reset the module-level chokidar.watch stub to return our mock watcher
chokidarWatchStub.reset()
chokidarWatchStub.returns(mockFileSystemWatcher as any)
// Reset the module-level disk stubs
mockTaskMetadata = { files_in_context: [], model_usage: [], environment_history: [] }
getTaskMetadataStub.reset()
getTaskMetadataStub.resolves(mockTaskMetadata)
saveTaskMetadataStub.reset()
saveTaskMetadataStub.resolves()
setVscodeHostProviderMock()
// Create tracker instance
tracker = new FileContextTracker({} as Controller, taskId)
})
afterEach(() => {
sandbox.restore()
})
it("should add a record when a file is read by a tool", async () => {
await tracker.trackFileContext(filePath, "read_tool")
// Verify getTaskMetadata was called
expect(getTaskMetadataStub.calledOnce).to.be.true
expect(getTaskMetadataStub.firstCall.args[0]).to.equal(taskId)
// Verify saveTaskMetadata was called with the correct data
expect(saveTaskMetadataStub.calledOnce).to.be.true
const savedMetadata = saveTaskMetadataStub.firstCall.args[1]
expect(savedMetadata.files_in_context.length).to.equal(1)
const fileEntry = savedMetadata.files_in_context[0]
expect(fileEntry.path).to.equal(filePath)
expect(fileEntry.record_state).to.equal("active")
expect(fileEntry.record_source).to.equal("read_tool")
expect(fileEntry.cline_read_date).to.be.a("number")
expect(fileEntry.cline_edit_date).to.be.null
})
it("should add a record when a file is edited by Cline", async () => {
await tracker.trackFileContext(filePath, "cline_edited")
// Verify saveTaskMetadata was called with the correct data
expect(saveTaskMetadataStub.calledOnce).to.be.true
const savedMetadata = saveTaskMetadataStub.firstCall.args[1]
// Check that we have at least one entry in files_in_context
expect(savedMetadata.files_in_context).to.be.an("array").that.is.not.empty
// Find the active entry for this file
const activeEntry = savedMetadata.files_in_context.find(
(entry: FileMetadataEntry) => entry.path === filePath && entry.record_state === "active",
)
// Assert that we found an active entry
expect(activeEntry).to.exist
// Now check the properties of the active entry
expect(activeEntry.path).to.equal(filePath)
expect(activeEntry.record_state).to.equal("active")
expect(activeEntry.record_source).to.equal("cline_edited")
expect(activeEntry.cline_read_date).to.be.a("number")
expect(activeEntry.cline_edit_date).to.be.a("number")
})
it("should add a record when a file is mentioned", async () => {
await tracker.trackFileContext(filePath, "file_mentioned")
// Verify saveTaskMetadata was called with the correct data
const savedMetadata = saveTaskMetadataStub.firstCall.args[1]
const fileEntry = savedMetadata.files_in_context[0]
expect(fileEntry.path).to.equal(filePath)
expect(fileEntry.record_state).to.equal("active")
expect(fileEntry.record_source).to.equal("file_mentioned")
expect(fileEntry.cline_read_date).to.be.a("number")
expect(fileEntry.cline_edit_date).to.be.null
})
it("should add a record when a file is edited by the user", async () => {
await tracker.trackFileContext(filePath, "user_edited")
// Verify saveTaskMetadata was called with the correct data
const savedMetadata = saveTaskMetadataStub.firstCall.args[1]
const fileEntry = savedMetadata.files_in_context[0]
expect(fileEntry.path).to.equal(filePath)
expect(fileEntry.record_state).to.equal("active")
expect(fileEntry.record_source).to.equal("user_edited")
expect(fileEntry.user_edit_date).to.be.a("number")
// Verify the file was added to recentlyModifiedFiles
const modifiedFiles = tracker.getAndClearRecentlyModifiedFiles()
expect(modifiedFiles).to.include(filePath)
})
it("should mark existing entries as stale when adding a new entry for the same file", async () => {
// Add an initial entry
mockTaskMetadata.files_in_context = [
{
path: filePath,
record_state: "active",
record_source: "read_tool",
cline_read_date: Date.now() - 1000, // 1 second ago
cline_edit_date: null,
user_edit_date: null,
},
]
// Track a new operation on the same file
await tracker.trackFileContext(filePath, "cline_edited")
// Verify the metadata now has two entries - one stale and one active
const savedMetadata = saveTaskMetadataStub.firstCall.args[1]
expect(savedMetadata.files_in_context.length).to.equal(2)
// First entry should be marked as stale
expect(savedMetadata.files_in_context[0].record_state).to.equal("stale")
// New entry should be active
const newEntry = savedMetadata.files_in_context[1]
expect(newEntry.record_state).to.equal("active")
expect(newEntry.record_source).to.equal("cline_edited")
})
it("should setup a file watcher for tracked files", async () => {
await tracker.trackFileContext(filePath, "read_tool")
// Verify chokidar.watch was called
expect(chokidarWatchStub.called).to.be.true
// Verify change listener was set up
expect(mockFileSystemWatcher.on.called).to.be.true
})
it("should track user edits when file watcher detects changes", async () => {
// First track the file to set up the watcher
await tracker.trackFileContext(filePath, "read_tool")
// Reset the stubs to check the next calls
getTaskMetadataStub.resetHistory()
saveTaskMetadataStub.resetHistory()
// Create a spy on trackFileContext to verify it's called with the right parameters
const trackFileContextSpy = sandbox.spy(tracker, "trackFileContext")
// Get the callback that was registered with chokidar "change" event
const callback = mockFileSystemWatcher.on.firstCall.args[1]
// Directly call the callback to simulate a file change event
callback(vscode.Uri.file(path.resolve("/mock/workspace", filePath)))
// Verify trackFileContext was called with the right parameters
expect(trackFileContextSpy.calledWith(filePath, "user_edited")).to.be.true
// Verify the file was added to recentlyModifiedFiles
const modifiedFiles = tracker.getAndClearRecentlyModifiedFiles()
expect(modifiedFiles).to.include(filePath)
})
it("should not track Cline edits as user edits", async () => {
// First track the file to set up the watcher
await tracker.trackFileContext(filePath, "read_tool")
// Mark the file as edited by Cline
tracker.markFileAsEditedByCline(filePath)
// Reset the stubs to check the next calls
getTaskMetadataStub.resetHistory()
saveTaskMetadataStub.resetHistory()
// Create a spy on trackFileContext to verify it's not called
const trackFileContextSpy = sandbox.spy(tracker, "trackFileContext")
// Get the callback that was registered with chokidar "change" event
const callback = mockFileSystemWatcher.on.firstCall.args[1]
// Directly call the callback to simulate a file change event
callback(vscode.Uri.file(path.resolve("/mock/workspace", filePath)))
// Verify trackFileContext was not called with user_edited
expect(trackFileContextSpy.calledWith(filePath, "user_edited")).to.be.false
// Verify the file was not added to recentlyModifiedFiles
const modifiedFiles = tracker.getAndClearRecentlyModifiedFiles()
expect(modifiedFiles).to.not.include(filePath)
})
it("should dispose file watchers when dispose is called", async () => {
// Track a file to set up the watcher
await tracker.trackFileContext(filePath, "read_tool")
// Call dispose
await tracker.dispose()
// Verify the watcher was closed
expect(mockFileSystemWatcher.close.called).to.be.true
})
})
@@ -1,279 +0,0 @@
import { getTaskMetadata, saveTaskMetadata } from "@core/storage/disk"
import type { ClineMessage } from "@shared/ExtensionMessage"
import chokidar, { FSWatcher } from "chokidar"
import * as path from "path"
import { Controller } from "@/core/controller"
import { Logger } from "@/shared/services/Logger"
import { getCwd } from "@/utils/path"
import type { FileMetadataEntry } from "./ContextTrackerTypes"
// This class is responsible for tracking file operations that may result in stale context.
// If a user modifies a file outside of Cline, the context may become stale and need to be updated.
// We do not want Cline to reload the context every time a file is modified, so we use this class merely
// to inform Cline that the change has occurred, and tell Cline to reload the file before making
// any changes to it. This fixes an issue with diff editing, where Cline was unable to complete a diff edit.
// a diff edit because the file was modified since Cline last read it.
// FileContextTracker
/**
This class is responsible for tracking file operations.
If the full contents of a file are passed to Cline via a tool, mention, or edit, the file is marked as active.
If a file is modified outside of Cline, we detect and track this change to prevent stale context.
This is used when restoring a task (non-git "checkpoint" restore), and mid-task.
*/
export class FileContextTracker {
private controller: Controller
readonly taskId: string
// File tracking and watching
private fileWatchers = new Map<string, FSWatcher>()
private recentlyModifiedFiles = new Set<string>()
private recentlyEditedByCline = new Set<string>()
constructor(controller: Controller, taskId: string) {
this.controller = controller
this.taskId = taskId
}
/**
* File watchers are set up for each file that is tracked in the task metadata.
*/
async setupFileWatcher(filePath: string) {
// Only setup watcher if it doesn't already exist for this file
if (this.fileWatchers.has(filePath)) {
return
}
const cwd = await getCwd()
if (!cwd) {
Logger.info("No workspace folder available - cannot determine current working directory")
return
}
// Create a chokidar file watcher for this specific file
const resolvedFilePath = path.resolve(cwd, filePath)
const watcher = chokidar.watch(resolvedFilePath, {
persistent: true, // Keep process alive while watching
ignoreInitial: true, // Don't emit events for existing files on startup
atomic: true, // Handle atomic writes (editors that use temp files)
awaitWriteFinish: {
// Wait for writes to finish before emitting events
stabilityThreshold: 100, // Wait 100ms for file size to stabilize
pollInterval: 100, // Check every 100ms while waiting
},
})
// Track file changes
watcher.on("change", () => {
if (this.recentlyEditedByCline.has(filePath)) {
this.recentlyEditedByCline.delete(filePath) // This was an edit by Cline, no need to inform Cline
} else {
this.recentlyModifiedFiles.add(filePath) // This was a user edit, we will inform Cline
this.trackFileContext(filePath, "user_edited") // Update the task metadata with file tracking
}
})
// Store the watcher so we can dispose it later
this.fileWatchers.set(filePath, watcher)
}
/**
* Tracks a file operation in metadata and sets up a watcher for the file
* This is the main entry point for FileContextTracker and is called when a file is passed to Cline via a tool, mention, or edit.
*/
async trackFileContext(filePath: string, operation: "read_tool" | "user_edited" | "cline_edited" | "file_mentioned") {
try {
const cwd = await getCwd()
if (!cwd) {
Logger.info("No workspace folder available - cannot determine current working directory")
return
}
// Add file to metadata
await this.addFileToFileContextTracker(this.taskId, filePath, operation)
// Set up file watcher for this file
await this.setupFileWatcher(filePath)
} catch (error) {
Logger.error("Failed to track file operation:", error)
}
}
/**
* Adds a file to the metadata tracker
* This handles the business logic of determining if the file is new, stale, or active.
* It also updates the metadata with the latest read/edit dates.
*/
async addFileToFileContextTracker(taskId: string, filePath: string, source: FileMetadataEntry["record_source"]) {
try {
const metadata = await getTaskMetadata(taskId)
const now = Date.now()
// Mark existing entries for this file as stale
metadata.files_in_context.forEach((entry) => {
if (entry.path === filePath && entry.record_state === "active") {
entry.record_state = "stale"
}
})
// Helper to get the latest date for a specific field and file
const getLatestDateForField = (path: string, field: keyof FileMetadataEntry): number | null => {
const relevantEntries = metadata.files_in_context
.filter((entry) => entry.path === path && entry[field])
.sort((a, b) => (b[field] as number) - (a[field] as number))
return relevantEntries.length > 0 ? (relevantEntries[0][field] as number) : null
}
const newEntry: FileMetadataEntry = {
path: filePath,
record_state: "active",
record_source: source,
cline_read_date: getLatestDateForField(filePath, "cline_read_date"),
cline_edit_date: getLatestDateForField(filePath, "cline_edit_date"),
user_edit_date: getLatestDateForField(filePath, "user_edit_date"),
}
switch (source) {
// user_edited: The user has edited the file
case "user_edited":
newEntry.user_edit_date = now
this.recentlyModifiedFiles.add(filePath)
break
// cline_edited: Cline has edited the file
case "cline_edited":
newEntry.cline_read_date = now
newEntry.cline_edit_date = now
break
// read_tool/file_mentioned: Cline has read the file via a tool or file mention
case "read_tool":
case "file_mentioned":
newEntry.cline_read_date = now
break
}
metadata.files_in_context.push(newEntry)
await saveTaskMetadata(taskId, metadata)
} catch (error) {
Logger.error("Failed to add file to metadata:", error)
}
}
/**
* Returns (and then clears) the set of recently modified files
*/
getAndClearRecentlyModifiedFiles(): string[] {
const files = Array.from(this.recentlyModifiedFiles)
this.recentlyModifiedFiles.clear()
return files
}
/**
* Marks a file as edited by Cline to prevent false positives in file watchers
*/
markFileAsEditedByCline(filePath: string): void {
this.recentlyEditedByCline.add(filePath)
}
/**
* Disposes all file watchers
*/
async dispose(): Promise<void> {
const closePromises = Array.from(this.fileWatchers.values()).map((watcher) => watcher.close())
await Promise.all(closePromises)
this.fileWatchers.clear()
}
/**
* Detects files that were edited by Cline or users after a specific message timestamp
* This is used when restoring checkpoints to warn about potential file content mismatches
*/
async detectFilesEditedAfterMessage(messageTs: number, deletedMessages: ClineMessage[]): Promise<string[]> {
const editedFiles: string[] = []
try {
// Check task metadata for files that were edited by Cline or users after the message timestamp
const taskMetadata = await getTaskMetadata(this.taskId)
if (taskMetadata?.files_in_context) {
for (const fileEntry of taskMetadata.files_in_context) {
const clineEditedAfter = fileEntry.cline_edit_date && fileEntry.cline_edit_date > messageTs
const userEditedAfter = fileEntry.user_edit_date && fileEntry.user_edit_date > messageTs
if (clineEditedAfter || userEditedAfter) {
editedFiles.push(fileEntry.path)
}
}
}
} catch (error) {
Logger.error("Error checking file context metadata:", error)
}
// Also check deleted task messages for file operations
for (const message of deletedMessages) {
if (message.say === "tool" && message.text) {
try {
const toolData = JSON.parse(message.text)
if ((toolData.tool === "editedExistingFile" || toolData.tool === "newFileCreated") && toolData.path) {
if (!editedFiles.includes(toolData.path)) {
editedFiles.push(toolData.path)
}
}
} catch (error) {
Logger.error("Error checking task messages:", error)
}
}
}
return [...new Set(editedFiles)]
}
/**
* Stores pending file context warning in workspace state so it persists across task reinitialization
*/
async storePendingFileContextWarning(files: string[]): Promise<void> {
try {
const key = `pendingFileContextWarning_${this.taskId}`
// NOTE: Using 'as any' because dynamic keys like pendingFileContextWarning_${taskId}
// are legitimate workspace state keys but don't fit the strict LocalStateKey type system
this.controller.stateManager.setWorkspaceState(key as any, files)
} catch (error) {
Logger.error("Error storing pending file context warning:", error)
}
}
/**
* Retrieves pending file context warning from workspace state (without clearing it)
*/
async retrievePendingFileContextWarning(): Promise<string[] | undefined> {
try {
const key = `pendingFileContextWarning_${this.taskId}`
// NOTE: Using 'as any' because dynamic keys like pendingFileContextWarning_${taskId}
// are legitimate workspace state keys but don't fit the strict LocalStateKey type system
const files = this.controller.stateManager.getWorkspaceStateKey(key as any) as string[]
return files
} catch (error) {
Logger.error("Error retrieving pending file context warning:", error)
}
return undefined
}
/**
* Retrieves and clears pending file context warning from workspace state
*/
async retrieveAndClearPendingFileContextWarning(): Promise<string[] | undefined> {
try {
const files = await this.retrievePendingFileContextWarning()
if (files) {
// NOTE: Using 'as any' because dynamic keys like pendingFileContextWarning_${taskId}
// are legitimate workspace state keys but don't fit the strict LocalStateKey type system
this.controller.stateManager.setWorkspaceState(`pendingFileContextWarning_${this.taskId}` as any, undefined)
return files
}
} catch (error) {
Logger.error("Error retrieving pending file context warning:", error)
}
return undefined
}
}
@@ -1,59 +0,0 @@
import { describe, it } from "bun:test"
import { expect } from "chai"
import { parseYamlFrontmatter } from "../frontmatter"
describe("parseYamlFrontmatter", () => {
it("returns original content when no frontmatter", () => {
const input = "Just text"
const result = parseYamlFrontmatter(input)
expect(result.hadFrontmatter).to.equal(false)
expect(result.data).to.deep.equal({})
expect(result.body).to.equal(input)
})
it("parses valid YAML frontmatter", () => {
const input = `---\npaths:\n - "src/**"\n---\n\nHello`
const result = parseYamlFrontmatter(input)
expect(result.hadFrontmatter).to.equal(true)
expect(result.parseError).to.equal(undefined)
expect(result.data).to.deep.equal({ paths: ["src/**"] })
expect(result.body.trim()).to.equal("Hello")
})
it("fails open on malformed YAML", () => {
const input = `---\npaths: [invalid\n---\nBody`
const result = parseYamlFrontmatter(input)
expect(result.hadFrontmatter).to.equal(true)
expect(result.data).to.deep.equal({})
expect(result.body).to.equal(input)
expect(result.parseError).to.be.a("string")
})
it("rejects YAML custom tags (security: prevents unsafe deserialization)", () => {
// !!js/function is the classic RCE vector in js-yaml v3.
// With JSON_SCHEMA, any custom tag should be rejected.
const input = `---\nfoo: !!js/function 'function(){ return 1 }'\n---\nBody`
const result = parseYamlFrontmatter(input)
expect(result.hadFrontmatter).to.equal(true)
expect(result.data).to.deep.equal({})
expect(result.body).to.equal(input)
expect(result.parseError).to.be.a("string")
})
it("rejects !!python/object YAML tag", () => {
const input = `---\nfoo: !!python/object:os.system 'echo pwned'\n---\nBody`
const result = parseYamlFrontmatter(input)
expect(result.hadFrontmatter).to.equal(true)
expect(result.data).to.deep.equal({})
expect(result.parseError).to.be.a("string")
})
it("parses JSON-compatible YAML values correctly", () => {
const input = `---\ncount: 42\nenabled: true\ntags:\n - "a"\n - "b"\n---\nContent`
const result = parseYamlFrontmatter(input)
expect(result.hadFrontmatter).to.equal(true)
expect(result.parseError).to.equal(undefined)
expect(result.data).to.deep.equal({ count: 42, enabled: true, tags: ["a", "b"] })
expect(result.body.trim()).to.equal("Content")
})
})
@@ -1,74 +0,0 @@
import { describe, it } from "bun:test"
import { expect } from "chai"
import { evaluateRuleConditionals, extractPathLikeStrings } from "../rule-conditionals"
describe("rule-conditionals", () => {
describe("evaluateRuleConditionals(paths)", () => {
it("treats missing paths as universal", () => {
const res = evaluateRuleConditionals({}, { paths: [] })
expect(res.passed).to.equal(true)
})
it("treats empty paths list in frontmatter as match-nothing (fail-closed)", () => {
const res = evaluateRuleConditionals({ paths: [] }, { paths: ["src/index.ts"] })
expect(res.passed).to.equal(false)
})
it("does not activate path-scoped rules with empty context", () => {
const res = evaluateRuleConditionals({ paths: ["src/**"] }, { paths: [] })
expect(res.passed).to.equal(false)
})
it("matches when any candidate path matches any glob", () => {
const res = evaluateRuleConditionals({ paths: ["src/**", "apps/**"] }, { paths: ["src/index.ts"] })
expect(res.passed).to.equal(true)
expect(res.matchedConditions.paths).to.deep.equal(["src/**"])
})
it("ignores invalid paths type (fail-open)", () => {
const res = evaluateRuleConditionals({ paths: "src/**" as any }, { paths: [] })
expect(res.passed).to.equal(true)
})
})
describe("extractPathLikeStrings", () => {
it("extracts basic relative paths", () => {
const res = extractPathLikeStrings("edit apps/web/src/App.tsx and packages/foo/src")
expect(res).to.deep.equal(["apps/web/src/App.tsx", "packages/foo/src"])
})
it("extracts simple filenames with extensions (no slashes)", () => {
const res = extractPathLikeStrings("Does foo.md exist? If not, create foo.md")
expect(res).to.deep.equal(["foo.md"])
})
it("does not extract bare words without an extension", () => {
const res = extractPathLikeStrings("Please create foo and then update bar")
expect(res).to.deep.equal([])
})
it("ignores URLs", () => {
const res = extractPathLikeStrings("see https://example.com/a/b and edit src/index.ts")
expect(res).to.deep.equal(["src/index.ts"])
})
it("ignores fenced code blocks", () => {
const text =
"Please update src/index.ts\n\n```ts\n// example code\nconst p = 'apps/web/src/App.tsx'\n// also: packages/foo/src\n```\n\nThanks!"
const res = extractPathLikeStrings(text)
expect(res).to.deep.equal(["src/index.ts"])
})
it("does not extract URLs inside code fences", () => {
const text = "```\nSee https://example.com/a/b and src/index.ts\n```\nBut edit docs/readme.md"
const res = extractPathLikeStrings(text)
expect(res).to.deep.equal(["docs/readme.md"])
})
it("extracts paths from stack traces (outside code fences)", () => {
const text = "Error: boom\n at foo (src/index.ts:12:3)\n at bar (apps/web/src/App.tsx:5:1)"
const res = extractPathLikeStrings(text)
expect(res).to.deep.equal(["src/index.ts", "apps/web/src/App.tsx"])
})
})
})
@@ -1,118 +0,0 @@
import { describe, it } from "bun:test"
import { expect } from "chai"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { getRuleFilesTotalContentWithMetadata } from "../rule-helpers"
describe("rule loading with paths frontmatter", () => {
it("filters rules by evaluationContext.paths", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "cline-rules-test-"))
try {
const rulesDir = path.join(tmp, ".clinerules")
await fs.mkdir(rulesDir, { recursive: true })
await fs.writeFile(path.join(rulesDir, "universal.md"), "Always on")
await fs.writeFile(path.join(rulesDir, "scoped.md"), `---\npaths:\n - "src/**"\n---\n\nOnly for src`)
const files = ["universal.md", "scoped.md"]
const toggles: Record<string, boolean> = {
[path.join(rulesDir, "universal.md")]: true,
[path.join(rulesDir, "scoped.md")]: true,
}
const res1 = await getRuleFilesTotalContentWithMetadata(files, rulesDir, toggles, {
evaluationContext: { paths: ["src/index.ts"] },
})
expect(res1.content).to.contain("universal.md")
expect(res1.content).to.contain("scoped.md")
expect(res1.content).to.not.contain("paths:")
expect(res1.activatedConditionalRules.map((r) => r.name)).to.include("global:scoped.md")
const res2 = await getRuleFilesTotalContentWithMetadata(files, rulesDir, toggles, {
evaluationContext: { paths: ["docs/readme.md"] },
})
expect(res2.content).to.contain("universal.md")
expect(res2.content).to.not.contain("scoped.md")
} finally {
await fs.rm(tmp, { recursive: true, force: true })
}
})
it("treats invalid YAML frontmatter as fail-open and preserves the raw frontmatter for the LLM", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "cline-rules-test-"))
try {
const rulesDir = path.join(tmp, ".clinerules")
await fs.mkdir(rulesDir, { recursive: true })
// Intentionally invalid YAML (unquoted '*' is a YAML alias indicator)
await fs.writeFile(
path.join(rulesDir, "invalid.md"),
`---\npaths: *\n---\n\nInvalid YAML, but should still be included`,
)
const files = ["invalid.md"]
const toggles: Record<string, boolean> = {
[path.join(rulesDir, "invalid.md")]: true,
}
const res = await getRuleFilesTotalContentWithMetadata(files, rulesDir, toggles, {
evaluationContext: { paths: ["src/index.ts"] },
})
// Fail-open: included even though frontmatter cannot be parsed.
expect(res.content).to.contain("invalid.md")
// Preserve raw frontmatter fence/content for the LLM.
expect(res.content).to.contain("---")
expect(res.content).to.contain("paths:")
} finally {
await fs.rm(tmp, { recursive: true, force: true })
}
})
it("treats paths: [] as match-nothing (fail-closed)", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "cline-rules-test-"))
try {
const rulesDir = path.join(tmp, ".clinerules")
await fs.mkdir(rulesDir, { recursive: true })
await fs.writeFile(path.join(rulesDir, "scoped-empty.md"), `---\npaths: []\n---\n\nShould never activate`)
const files = ["scoped-empty.md"]
const toggles: Record<string, boolean> = {
[path.join(rulesDir, "scoped-empty.md")]: true,
}
const res = await getRuleFilesTotalContentWithMetadata(files, rulesDir, toggles, {
evaluationContext: { paths: ["src/index.ts"] },
})
expect(res.content).to.not.contain("scoped-empty.md")
} finally {
await fs.rm(tmp, { recursive: true, force: true })
}
})
it("keeps activatedConditionalRules order stable (matches input file order)", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "cline-rules-test-"))
try {
const rulesDir = path.join(tmp, ".clinerules")
await fs.mkdir(rulesDir, { recursive: true })
await fs.writeFile(path.join(rulesDir, "a.md"), `---\npaths:\n - "src/**"\n---\n\nA`)
await fs.writeFile(path.join(rulesDir, "b.md"), `---\npaths:\n - "src/**"\n---\n\nB`)
await fs.writeFile(path.join(rulesDir, "c.md"), `---\npaths:\n - "src/**"\n---\n\nC`)
const files = ["a.md", "b.md", "c.md"]
const toggles: Record<string, boolean> = {
[path.join(rulesDir, "a.md")]: true,
[path.join(rulesDir, "b.md")]: true,
[path.join(rulesDir, "c.md")]: true,
}
const res = await getRuleFilesTotalContentWithMetadata(files, rulesDir, toggles, {
evaluationContext: { paths: ["src/index.ts"] },
})
expect(res.activatedConditionalRules.map((r) => r.name)).to.deep.equal(files.map((f) => `global:${f}`))
} finally {
await fs.rm(tmp, { recursive: true, force: true })
}
})
})
@@ -1,837 +0,0 @@
/**
* Unit tests for skills utility functions
* Tests skill discovery, override resolution, toggle filtering, and content loading
*/
import { afterEach, beforeEach, describe, it, mock } from "bun:test"
import { expect } from "chai"
import * as actualFsPromises from "fs/promises"
import * as path from "path"
import * as sinon from "sinon"
import * as actualSkillDirectories from "@/core/storage/skill-directories"
import { Logger } from "@/shared/services/Logger"
import * as actualFsUtils from "@/utils/fs"
// bun loads real ESM, so sinon cannot stub the `@utils/fs`,
// `@core/storage/skill-directories`, or the `fs/promises` namespace exports
// ("ES Modules cannot be stubbed"). Crucially, under bun `fs.promises` and the
// `fs/promises` module are NOT the same object, so stubbing `fs.promises.X`
// (which worked under mocha/ts-node) does not affect the SUT's
// `import * as fs from "fs/promises"` bindings. Inject module-level sinon stubs
// via bun's mock.module so the full sinon stub API (.withArgs/.resolves/etc.)
// keeps working through the exact specifiers the SUT imports.
const fileExistsAtPathStub = sinon.stub()
const isDirectoryStub_ = sinon.stub()
const getSkillsDirectoriesForScanStub = sinon.stub()
const readdirStub_ = sinon.stub()
const statStub_ = sinon.stub()
const readFileStub_ = sinon.stub()
const writeFileStub_ = sinon.stub()
const fsUtilsMock = () => ({
...actualFsUtils,
fileExistsAtPath: fileExistsAtPathStub,
isDirectory: isDirectoryStub_,
})
const skillDirsMock = () => ({
...actualSkillDirectories,
getSkillsDirectoriesForScan: getSkillsDirectoriesForScanStub,
})
const fsPromisesMockNamespace = {
...actualFsPromises,
readdir: readdirStub_,
stat: statStub_,
readFile: readFileStub_,
writeFile: writeFileStub_,
}
const fsPromisesMock = () => ({ ...fsPromisesMockNamespace, default: fsPromisesMockNamespace })
// The SUT imports via the `@utils/*` and `@core/*` tsconfig path aliases;
// register both the `@/`-prefixed and bare-alias forms to be safe.
mock.module("@utils/fs", fsUtilsMock)
mock.module("@/utils/fs", fsUtilsMock)
mock.module("@core/storage/skill-directories", skillDirsMock)
mock.module("@/core/storage/skill-directories", skillDirsMock)
mock.module("fs/promises", fsPromisesMock)
mock.module("node:fs/promises", fsPromisesMock)
import { parseYamlFrontmatter } from "../frontmatter"
import {
discoverSkills,
getAvailableSkills,
getSkillContent,
parseRemoteSkillEntries,
setSkillDisabledInFrontmatter,
updateSkillMarkdownDisabledState,
} from "../skills"
describe("Skills Utility Functions", () => {
let sandbox: sinon.SinonSandbox
let fileExistsStub: sinon.SinonStub
let isDirectoryStub: sinon.SinonStub
let readdirStub: sinon.SinonStub
let statStub: sinon.SinonStub
let readFileStub: sinon.SinonStub
// Use path.join for OS-independent paths
const TEST_CWD = path.join("/test", "project")
const GLOBAL_SKILLS_DIR = path.join("/home", "user", ".cline", "skills")
beforeEach(() => {
sandbox = sinon.createSandbox()
// Stub Logger.warn to avoid noise in test output
sandbox.stub(Logger, "warn")
// Reset the module-level sinon stubs (injected via mock.module above) and
// re-point the per-test handles at them.
fileExistsAtPathStub.reset()
isDirectoryStub_.reset()
getSkillsDirectoriesForScanStub.reset()
readdirStub_.reset()
statStub_.reset()
readFileStub_.reset()
writeFileStub_.reset()
fileExistsStub = fileExistsAtPathStub
isDirectoryStub = isDirectoryStub_
readdirStub = readdirStub_
statStub = statStub_
readFileStub = readFileStub_
getSkillsDirectoriesForScanStub.returns([
{ path: path.join(TEST_CWD, ".clinerules", "skills"), source: "project" },
{ path: path.join(TEST_CWD, ".cline", "skills"), source: "project" },
{ path: path.join(TEST_CWD, ".claude", "skills"), source: "project" },
{ path: path.join(TEST_CWD, ".agents", "skills"), source: "project" },
{ path: GLOBAL_SKILLS_DIR, source: "global" },
{ path: path.join("/home", "user", ".agents", "skills"), source: "global" },
])
// Default: no directories exist
fileExistsStub.resolves(false)
isDirectoryStub.resolves(false)
})
afterEach(() => {
sandbox.restore()
})
describe("discoverSkills", () => {
it("should discover skills from global directory", async () => {
const skillDir = path.join(GLOBAL_SKILLS_DIR, "my-skill")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
fileExistsStub.withArgs(skillMdPath).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["my-skill"])
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
name: my-skill
description: A test skill
---
Instructions here`)
const skills = await discoverSkills(TEST_CWD)
expect(skills).to.have.lengthOf(1)
expect(skills[0].name).to.equal("my-skill")
expect(skills[0].description).to.equal("A test skill")
expect(skills[0].source).to.equal("global")
})
it("should discover skills from project .clinerules/skills directory", async () => {
const projectSkillsDir = path.join(TEST_CWD, ".clinerules", "skills")
const skillDir = path.join(projectSkillsDir, "explaining-code")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(projectSkillsDir).resolves(true)
fileExistsStub.withArgs(skillMdPath).resolves(true)
isDirectoryStub.withArgs(projectSkillsDir).resolves(true)
readdirStub.withArgs(projectSkillsDir).resolves(["explaining-code"])
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
name: explaining-code
description: Explains code with diagrams and analogies
---
Use analogies and ASCII diagrams when explaining code.`)
const skills = await discoverSkills(TEST_CWD)
expect(skills).to.have.lengthOf(1)
expect(skills[0].name).to.equal("explaining-code")
expect(skills[0].source).to.equal("project")
})
it("should discover skills from project .cline/skills directory", async () => {
const clineSkillsDir = path.join(TEST_CWD, ".cline", "skills")
const skillDir = path.join(clineSkillsDir, "debugging")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(clineSkillsDir).resolves(true)
fileExistsStub.withArgs(skillMdPath).resolves(true)
isDirectoryStub.withArgs(clineSkillsDir).resolves(true)
readdirStub.withArgs(clineSkillsDir).resolves(["debugging"])
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
name: debugging
description: Debug code systematically
---
Use systematic debugging approaches.`)
const skills = await discoverSkills(TEST_CWD)
expect(skills).to.have.lengthOf(1)
expect(skills[0].name).to.equal("debugging")
expect(skills[0].source).to.equal("project")
})
it("should discover skills from project .claude/skills directory", async () => {
const claudeSkillsDir = path.join(TEST_CWD, ".claude", "skills")
const skillDir = path.join(claudeSkillsDir, "coding")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(claudeSkillsDir).resolves(true)
fileExistsStub.withArgs(skillMdPath).resolves(true)
isDirectoryStub.withArgs(claudeSkillsDir).resolves(true)
readdirStub.withArgs(claudeSkillsDir).resolves(["coding"])
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
name: coding
description: Write clean code
---
Follow best practices.`)
const skills = await discoverSkills(TEST_CWD)
expect(skills).to.have.lengthOf(1)
expect(skills[0].name).to.equal("coding")
expect(skills[0].source).to.equal("project")
})
it("should discover skills from project .agents/skills directory", async () => {
const agentsSkillsDir = path.join(TEST_CWD, ".agents", "skills")
const skillDir = path.join(agentsSkillsDir, "testing")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(agentsSkillsDir).resolves(true)
fileExistsStub.withArgs(skillMdPath).resolves(true)
isDirectoryStub.withArgs(agentsSkillsDir).resolves(true)
readdirStub.withArgs(agentsSkillsDir).resolves(["testing"])
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
name: testing
description: Write comprehensive tests
---
Always write tests.`)
const skills = await discoverSkills(TEST_CWD)
expect(skills).to.have.lengthOf(1)
expect(skills[0].name).to.equal("testing")
expect(skills[0].source).to.equal("project")
})
it("should handle empty skills directories gracefully", async () => {
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves([])
const skills = await discoverSkills(TEST_CWD)
expect(skills).to.have.lengthOf(0)
})
it("should skip non-directory entries in skills folder", async () => {
const readmePath = path.join(GLOBAL_SKILLS_DIR, "README.md")
const skillDir = path.join(GLOBAL_SKILLS_DIR, "my-skill")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["README.md", "my-skill"])
statStub.withArgs(readmePath).resolves({ isDirectory: () => false })
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
fileExistsStub.withArgs(skillMdPath).resolves(true)
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
name: my-skill
description: A skill
---
Content`)
const skills = await discoverSkills(TEST_CWD)
expect(skills).to.have.lengthOf(1)
expect(skills[0].name).to.equal("my-skill")
})
it("should skip skill directories without SKILL.md", async () => {
const skillDir = path.join(GLOBAL_SKILLS_DIR, "incomplete-skill")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["incomplete-skill"])
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
fileExistsStub.withArgs(skillMdPath).resolves(false)
const skills = await discoverSkills(TEST_CWD)
expect(skills).to.have.lengthOf(0)
})
})
describe("getAvailableSkills - Override Resolution", () => {
it("should override project skill with global skill of same name", async () => {
const globalSkillDir = path.join(GLOBAL_SKILLS_DIR, "coding")
const globalSkillMdPath = path.join(globalSkillDir, "SKILL.md")
// Setup global skill (higher priority)
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
fileExistsStub.withArgs(globalSkillMdPath).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["coding"])
statStub.withArgs(globalSkillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(globalSkillMdPath, "utf-8").resolves(`---
name: coding
description: Global coding skill
---
Global instructions`)
// Setup project skill with same name (lower priority)
const projectSkillsDir = path.join(TEST_CWD, ".clinerules", "skills")
const projectSkillDir = path.join(projectSkillsDir, "coding")
const projectSkillMdPath = path.join(projectSkillDir, "SKILL.md")
fileExistsStub.withArgs(projectSkillsDir).resolves(true)
fileExistsStub.withArgs(projectSkillMdPath).resolves(true)
isDirectoryStub.withArgs(projectSkillsDir).resolves(true)
readdirStub.withArgs(projectSkillsDir).resolves(["coding"])
statStub.withArgs(projectSkillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(projectSkillMdPath, "utf-8").resolves(`---
name: coding
description: Project coding skill
---
Project instructions`)
const allSkills = await discoverSkills(TEST_CWD)
const skills = getAvailableSkills(allSkills)
expect(skills).to.have.lengthOf(1)
expect(skills[0].description).to.equal("Global coding skill")
expect(skills[0].source).to.equal("global")
})
it("should keep both skills when names are different", async () => {
const globalSkillDir = path.join(GLOBAL_SKILLS_DIR, "global-skill")
const globalSkillMdPath = path.join(globalSkillDir, "SKILL.md")
// Setup global skill
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
fileExistsStub.withArgs(globalSkillMdPath).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["global-skill"])
statStub.withArgs(globalSkillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(globalSkillMdPath, "utf-8").resolves(`---
name: global-skill
description: A global skill
---
Content`)
// Setup project skill with different name
const projectSkillsDir = path.join(TEST_CWD, ".clinerules", "skills")
const projectSkillDir = path.join(projectSkillsDir, "project-skill")
const projectSkillMdPath = path.join(projectSkillDir, "SKILL.md")
fileExistsStub.withArgs(projectSkillsDir).resolves(true)
fileExistsStub.withArgs(projectSkillMdPath).resolves(true)
isDirectoryStub.withArgs(projectSkillsDir).resolves(true)
readdirStub.withArgs(projectSkillsDir).resolves(["project-skill"])
statStub.withArgs(projectSkillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(projectSkillMdPath, "utf-8").resolves(`---
name: project-skill
description: A project skill
---
Content`)
const allSkills = await discoverSkills(TEST_CWD)
const skills = getAvailableSkills(allSkills)
expect(skills).to.have.lengthOf(2)
const names = skills.map((s) => s.name)
expect(names).to.include("global-skill")
expect(names).to.include("project-skill")
})
})
describe("Metadata Validation", () => {
it("should reject skill with missing name field", async () => {
const skillDir = path.join(GLOBAL_SKILLS_DIR, "bad-skill")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
fileExistsStub.withArgs(skillMdPath).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["bad-skill"])
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
description: Missing name
---
Content`)
const skills = await discoverSkills(TEST_CWD)
expect(skills).to.have.lengthOf(0)
sinon.assert.calledWithMatch(Logger.warn as sinon.SinonStub, /missing required 'name' field/)
})
it("should reject skill with missing description field", async () => {
const skillDir = path.join(GLOBAL_SKILLS_DIR, "bad-skill")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
fileExistsStub.withArgs(skillMdPath).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["bad-skill"])
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
name: bad-skill
---
Content`)
const skills = await discoverSkills(TEST_CWD)
expect(skills).to.have.lengthOf(0)
sinon.assert.calledWithMatch(Logger.warn as sinon.SinonStub, /missing required 'description' field/)
})
it("should reject skill when name doesn't match directory name", async () => {
const skillDir = path.join(GLOBAL_SKILLS_DIR, "my-dir")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
fileExistsStub.withArgs(skillMdPath).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["my-dir"])
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
name: different-name
description: Mismatched name
---
Content`)
const skills = await discoverSkills(TEST_CWD)
expect(skills).to.have.lengthOf(0)
sinon.assert.calledWithMatch(Logger.warn as sinon.SinonStub, /doesn't match directory/)
})
it("should handle malformed YAML frontmatter gracefully", async () => {
const skillDir = path.join(GLOBAL_SKILLS_DIR, "bad-yaml")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
fileExistsStub.withArgs(skillMdPath).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["bad-yaml"])
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
name: [invalid yaml
description: broken
---
Content`)
const skills = await discoverSkills(TEST_CWD)
expect(skills).to.have.lengthOf(0)
})
it("should handle file without frontmatter", async () => {
const skillDir = path.join(GLOBAL_SKILLS_DIR, "no-front")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
fileExistsStub.withArgs(skillMdPath).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["no-front"])
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`Just plain markdown content without frontmatter`)
const skills = await discoverSkills(TEST_CWD)
expect(skills).to.have.lengthOf(0)
})
})
describe("getSkillContent", () => {
it("should load full skill content with instructions", async () => {
const skillDir = path.join(GLOBAL_SKILLS_DIR, "my-skill")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
fileExistsStub.withArgs(skillMdPath).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["my-skill"])
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
name: my-skill
description: Test skill
---
These are the detailed instructions.
## Step 1
Do this first.
## Step 2
Then do this.`)
const allSkills = await discoverSkills(TEST_CWD)
const availableSkills = getAvailableSkills(allSkills)
const content = await getSkillContent("my-skill", availableSkills)
expect(content).to.not.be.null
expect(content!.name).to.equal("my-skill")
expect(content!.instructions).to.include("These are the detailed instructions")
expect(content!.instructions).to.include("Step 1")
expect(content!.instructions).to.include("Step 2")
})
it("should return null for non-existent skill", async () => {
const content = await getSkillContent("non-existent", [])
expect(content).to.be.null
})
it("should trim whitespace from instructions", async () => {
const skillDir = path.join(GLOBAL_SKILLS_DIR, "my-skill")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
fileExistsStub.withArgs(skillMdPath).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["my-skill"])
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
name: my-skill
description: Test
---
Instructions with whitespace
`)
const allSkills = await discoverSkills(TEST_CWD)
const availableSkills = getAvailableSkills(allSkills)
const content = await getSkillContent("my-skill", availableSkills)
expect(content!.instructions).to.equal("Instructions with whitespace")
})
})
describe("Remote Skills", () => {
// entry.name must match frontmatter name (enforced by parseRemoteSkillEntries)
const makeEntry = (name: string, desc: string, body = "Instructions", alwaysEnabled = false) => ({
name,
alwaysEnabled,
contents: `---\nname: ${name}\ndescription: ${desc}\n---\n${body}`,
})
describe("parseRemoteSkillEntries", () => {
it("should return validated entries when entry.name matches frontmatter.name", () => {
const entries = [
{ name: "Deploy", alwaysEnabled: true, contents: `---\nname: Deploy\ndescription: CI/CD\n---\nBody` },
]
const result = parseRemoteSkillEntries(entries)
expect(result).to.have.lengthOf(1)
expect(result[0].name).to.equal("Deploy")
expect(result[0].description).to.equal("CI/CD")
expect(result[0].alwaysEnabled).to.equal(true)
})
it("should warn but still include entries where entry.name drifts from frontmatter.name", () => {
const entries = [
{
name: "entry-key",
alwaysEnabled: false,
contents: `---\nname: Different Name\ndescription: Desc\n---\nBody`,
},
]
const result = parseRemoteSkillEntries(entries)
expect(result).to.have.lengthOf(1)
expect(result[0].name).to.equal("Different Name")
sinon.assert.calledWithMatch(Logger.warn as sinon.SinonStub, /does not match frontmatter\.name/)
})
it("should skip entries with missing frontmatter name", () => {
const entries = [{ name: "bad", alwaysEnabled: false, contents: `---\ndescription: No name\n---\nContent` }]
const result = parseRemoteSkillEntries(entries)
expect(result).to.have.lengthOf(0)
})
it("should skip entries with missing frontmatter description", () => {
const entries = [{ name: "No Desc", alwaysEnabled: false, contents: `---\nname: No Desc\n---\nContent` }]
const result = parseRemoteSkillEntries(entries)
expect(result).to.have.lengthOf(0)
})
it("should handle empty array", () => {
expect(parseRemoteSkillEntries([])).to.have.lengthOf(0)
})
it("should include all entries with valid frontmatter even with drift", () => {
const entries = [
{ name: "Good", alwaysEnabled: false, contents: `---\nname: Good\ndescription: Valid\n---\nBody` },
{ name: "drift", alwaysEnabled: false, contents: `---\nname: Different\ndescription: Drifted\n---\nBody` },
{
name: "Also Good",
alwaysEnabled: true,
contents: `---\nname: Also Good\ndescription: Valid too\n---\nBody`,
},
]
const result = parseRemoteSkillEntries(entries)
expect(result).to.have.lengthOf(3)
expect(result[0].name).to.equal("Good")
expect(result[1].name).to.equal("Different")
expect(result[2].name).to.equal("Also Good")
})
})
describe("discoverSkills - remote skill discovery", () => {
it("should include remote skills from remote config", async () => {
const entries = [makeEntry("Deploy Pipeline", "Handles CI/CD deployment", "Deploy instructions")]
const skills = await discoverSkills(TEST_CWD, entries)
const remoteSkill = skills.find((s) => s.name === "Deploy Pipeline")
expect(remoteSkill).to.not.be.undefined
expect(remoteSkill!.path).to.equal("remote:Deploy Pipeline")
expect(remoteSkill!.source).to.equal("global")
expect(remoteSkill!.description).to.equal("Handles CI/CD deployment")
})
it("should use frontmatter.name as identity even when entry.name drifts", async () => {
const entries = [
{ name: "entry-key", alwaysEnabled: false, contents: `---\nname: Actual Name\ndescription: Desc\n---\nBody` },
]
const skills = await discoverSkills(TEST_CWD, entries)
const remoteSkill = skills.find((s) => s.path?.startsWith("remote:"))
expect(remoteSkill).to.not.be.undefined
expect(remoteSkill!.name).to.equal("Actual Name")
expect(remoteSkill!.path).to.equal("remote:Actual Name")
})
it("should skip remote skills with missing frontmatter name", async () => {
const entries = [{ name: "bad", alwaysEnabled: false, contents: `---\ndescription: No name\n---\nContent` }]
const skills = await discoverSkills(TEST_CWD, entries)
expect(skills.find((s) => s.path?.startsWith("remote:"))).to.be.undefined
})
it("should skip remote skills with missing frontmatter description", async () => {
const entries = [{ name: "No Desc", alwaysEnabled: false, contents: `---\nname: No Desc\n---\nContent` }]
const skills = await discoverSkills(TEST_CWD, entries)
expect(skills.find((s) => s.path?.startsWith("remote:"))).to.be.undefined
})
it("should handle empty and undefined entries gracefully", async () => {
for (const val of [[], undefined]) {
const skills = await discoverSkills(TEST_CWD, val)
expect(skills.filter((s) => s.path?.startsWith("remote:"))).to.have.lengthOf(0)
}
})
})
describe("Override resolution (remote > disk-global > project)", () => {
it("remote overrides disk-global skill of same name", async () => {
const entries = [makeEntry("coding", "Remote coding")]
const diskGlobalDir = path.join(GLOBAL_SKILLS_DIR, "coding")
const diskGlobalMd = path.join(diskGlobalDir, "SKILL.md")
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
fileExistsStub.withArgs(diskGlobalMd).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["coding"])
statStub.withArgs(diskGlobalDir).resolves({ isDirectory: () => true })
readFileStub
.withArgs(diskGlobalMd, "utf-8")
.resolves(`---\nname: coding\ndescription: Disk global coding\n---\nDisk`)
const available = getAvailableSkills(await discoverSkills(TEST_CWD, entries))
expect(available).to.have.lengthOf(1)
expect(available[0].description).to.equal("Remote coding")
expect(available[0].path).to.equal("remote:coding")
})
it("remote overrides project skill of same name", async () => {
const entries = [makeEntry("coding", "Remote coding")]
const projDir = path.join(TEST_CWD, ".clinerules", "skills")
const projSkillDir = path.join(projDir, "coding")
const projMd = path.join(projSkillDir, "SKILL.md")
fileExistsStub.withArgs(projDir).resolves(true)
fileExistsStub.withArgs(projMd).resolves(true)
isDirectoryStub.withArgs(projDir).resolves(true)
readdirStub.withArgs(projDir).resolves(["coding"])
statStub.withArgs(projSkillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(projMd, "utf-8").resolves(`---\nname: coding\ndescription: Project coding\n---\nProject`)
const available = getAvailableSkills(await discoverSkills(TEST_CWD, entries))
expect(available).to.have.lengthOf(1)
expect(available[0].description).to.equal("Remote coding")
expect(available[0].path).to.equal("remote:coding")
})
})
describe("getSkillContent - remote skill content loading", () => {
it("should load content from provided entries for remote skills", async () => {
const entries = [makeEntry("Deploy Pipeline", "Deployment skill", "These are the deployment instructions.")]
const skill = {
name: "Deploy Pipeline",
description: "Deployment skill",
path: "remote:Deploy Pipeline",
source: "global" as const,
}
const content = await getSkillContent("Deploy Pipeline", [skill], entries)
expect(content).to.not.be.null
expect(content!.name).to.equal("Deploy Pipeline")
expect(content!.instructions).to.equal("These are the deployment instructions.")
})
it("should trim whitespace from remote skill instructions", async () => {
const entries = [makeEntry("Trim Skill", "Test", "\n Instructions with whitespace \n\n")]
const skill = { name: "Trim Skill", description: "Test", path: "remote:Trim Skill", source: "global" as const }
const content = await getSkillContent("Trim Skill", [skill], entries)
expect(content!.instructions).to.equal("Instructions with whitespace")
})
it("should return null if remote skill entry not found in entries", async () => {
const skill = { name: "Gone", description: "Removed", path: "remote:Gone", source: "global" as const }
const content = await getSkillContent("Gone", [skill], [])
expect(content).to.be.null
})
it("should not attempt disk read for remote skills", async () => {
const entries = [makeEntry("Remote Only", "Test", "Remote content")]
const skill = { name: "Remote Only", description: "Test", path: "remote:Remote Only", source: "global" as const }
await getSkillContent("Remote Only", [skill], entries)
sinon.assert.notCalled(readFileStub)
})
})
})
})
describe("updateSkillMarkdownDisabledState", () => {
it("adds disabled: true when disabling a skill with existing frontmatter", () => {
const input = ["---", "name: my-skill", "description: A skill", "---", "Body here"].join("\n")
const output = updateSkillMarkdownDisabledState(input, false)
expect(output).to.contain("disabled: true")
expect(output).to.contain("name: my-skill")
expect(output).to.contain("Body here")
})
it("removes disabled flag when enabling a previously-disabled skill", () => {
const input = ["---", "name: my-skill", "description: A skill", "disabled: true", "---", "Body"].join("\n")
const output = updateSkillMarkdownDisabledState(input, true)
expect(output).to.not.contain("disabled")
expect(output).to.contain("name: my-skill")
expect(output).to.contain("Body")
})
it("also clears a stale enabled: false when enabling", () => {
const input = ["---", "name: my-skill", "enabled: false", "---", "Body"].join("\n")
const output = updateSkillMarkdownDisabledState(input, true)
expect(output).to.not.contain("enabled: false")
})
it("drops the frontmatter block entirely when enabling leaves it empty", () => {
const input = ["---", "disabled: true", "---", "Body only"].join("\n")
const output = updateSkillMarkdownDisabledState(input, true)
expect(output).to.equal("Body only")
})
it("returns content unchanged when enabling a doc with no frontmatter", () => {
const input = "Just body, no frontmatter"
expect(updateSkillMarkdownDisabledState(input, true)).to.equal(input)
})
it("is idempotent: disabling an already-disabled skill keeps disabled: true once", () => {
const input = ["---", "name: s", "disabled: true", "---", "B"].join("\n")
const output = updateSkillMarkdownDisabledState(input, false)
expect(output.match(/disabled: true/g)).to.have.lengthOf(1)
})
// Frontmatter block whose YAML is genuinely invalid (asserted below). The
// `---` markers are well-formed so parseYamlFrontmatter detects frontmatter
// and then fails to parse it, exercising the parseError branch.
const MALFORMED_FRONTMATTER = ["---", "name: s", "description: : : bad", " - nope", "---", "Body"].join("\n")
it("uses a fixture whose frontmatter YAML is actually invalid", () => {
// Guards the two tests below from rotting into false positives: if this
// fixture ever became valid YAML, updateSkillMarkdownDisabledState would
// take a different (rewriting) path and the "untouched" assertions could
// pass for the wrong reason.
const parsed = parseYamlFrontmatter(MALFORMED_FRONTMATTER)
expect(parsed.hadFrontmatter).to.be.true
expect(parsed.parseError, "fixture frontmatter should be invalid YAML").to.be.a("string")
})
it("leaves malformed-frontmatter files untouched when disabling (no double header)", () => {
// parseYamlFrontmatter fails open and returns the full original document
// as the body. Disabling must not prepend a second `---` block and corrupt
// the file.
const output = updateSkillMarkdownDisabledState(MALFORMED_FRONTMATTER, false)
expect(output).to.equal(MALFORMED_FRONTMATTER)
// Exactly one frontmatter opener/closer pair, not two.
expect(output.match(/^---$/gm)).to.have.lengthOf(2)
})
it("leaves malformed-frontmatter files untouched when enabling", () => {
expect(updateSkillMarkdownDisabledState(MALFORMED_FRONTMATTER, true)).to.equal(MALFORMED_FRONTMATTER)
})
})
describe("setSkillDisabledInFrontmatter", () => {
let sandbox: sinon.SinonSandbox
let readFileStub: sinon.SinonStub
let writeFileStub: sinon.SinonStub
beforeEach(() => {
sandbox = sinon.createSandbox()
sandbox.stub(Logger, "warn")
// Use the module-level fs/promises stubs (mock.module above) since under
// bun fs.promises !== the `fs/promises` module the SUT imports.
readFileStub_.reset()
writeFileStub_.reset()
readFileStub = readFileStub_
writeFileStub = writeFileStub_
writeFileStub.resolves()
})
afterEach(() => sandbox.restore())
it("writes disabled: true to the SKILL.md when disabling a disk skill", async () => {
const skillPath = path.join("/home", "user", ".cline", "skills", "s", "SKILL.md")
readFileStub.withArgs(skillPath, "utf-8").resolves(["---", "name: s", "description: d", "---", "Body"].join("\n"))
const ok = await setSkillDisabledInFrontmatter(skillPath, false)
expect(ok).to.be.true
sinon.assert.calledOnce(writeFileStub)
const written = writeFileStub.getCall(0).args[1] as string
expect(written).to.contain("disabled: true")
})
it("does not write for remote skills (no backing file)", async () => {
const ok = await setSkillDisabledInFrontmatter("remote:Some Skill", false)
expect(ok).to.be.false
sinon.assert.notCalled(readFileStub)
sinon.assert.notCalled(writeFileStub)
})
it("skips the write when content is unchanged", async () => {
const skillPath = path.join("/home", "user", ".cline", "skills", "s", "SKILL.md")
// Already disabled; disabling again yields identical content.
readFileStub.withArgs(skillPath, "utf-8").resolves(["---", "name: s", "disabled: true", "---", "B"].join("\n"))
const ok = await setSkillDisabledInFrontmatter(skillPath, false)
expect(ok).to.be.true
sinon.assert.notCalled(writeFileStub)
})
})
@@ -1,34 +0,0 @@
import { synchronizeRuleToggles } from "@core/context/instructions/user-instructions/rule-helpers"
import { ensureRulesDirectoryExists, GlobalFileNames } from "@core/storage/disk"
import { ClineRulesToggles } from "@shared/cline-rules"
import path from "path"
import { Controller } from "@/core/controller"
export async function refreshClineRulesToggles(
controller: Controller,
workingDirectory: string,
): Promise<{
globalToggles: ClineRulesToggles
localToggles: ClineRulesToggles
}> {
// Global toggles
const globalClineRulesToggles = controller.stateManager.getGlobalSettingsKey("globalClineRulesToggles")
const globalClineRulesFilePath = await ensureRulesDirectoryExists()
const updatedGlobalToggles = await synchronizeRuleToggles(globalClineRulesFilePath, globalClineRulesToggles)
controller.stateManager.setGlobalState("globalClineRulesToggles", updatedGlobalToggles)
// Local toggles
const localClineRulesToggles = controller.stateManager.getWorkspaceStateKey("localClineRulesToggles")
const localClineRulesFilePath = path.resolve(workingDirectory, GlobalFileNames.clineRules)
const updatedLocalToggles = await synchronizeRuleToggles(localClineRulesFilePath, localClineRulesToggles, "", [
[".clinerules", "workflows"],
[".clinerules", "hooks"],
[".clinerules", "skills"],
])
controller.stateManager.setWorkspaceState("localClineRulesToggles", updatedLocalToggles)
return {
globalToggles: updatedGlobalToggles,
localToggles: updatedLocalToggles,
}
}
@@ -1,49 +0,0 @@
import { combineRuleToggles, synchronizeRuleToggles } from "@core/context/instructions/user-instructions/rule-helpers"
import { GlobalFileNames } from "@core/storage/disk"
import { ClineRulesToggles } from "@shared/cline-rules"
import path from "path"
import { Controller } from "@/core/controller"
/**
* Refreshes the toggles for windsurf, cursor, and agents rules
*/
export async function refreshExternalRulesToggles(
controller: Controller,
workingDirectory: string,
): Promise<{
windsurfLocalToggles: ClineRulesToggles
cursorLocalToggles: ClineRulesToggles
agentsLocalToggles: ClineRulesToggles
}> {
// local windsurf toggles
const localWindsurfRulesToggles = controller.stateManager.getWorkspaceStateKey("localWindsurfRulesToggles")
const localWindsurfRulesFilePath = path.resolve(workingDirectory, GlobalFileNames.windsurfRules)
const updatedLocalWindsurfToggles = await synchronizeRuleToggles(localWindsurfRulesFilePath, localWindsurfRulesToggles)
controller.stateManager.setWorkspaceState("localWindsurfRulesToggles", updatedLocalWindsurfToggles)
// local cursor toggles
const localCursorRulesToggles = controller.stateManager.getWorkspaceStateKey("localCursorRulesToggles")
// cursor has two valid locations for rules files, so we need to check both and combine
// synchronizeRuleToggles will drop whichever rules files are not in each given path, but combining the results will result in no data loss
let localCursorRulesFilePath = path.resolve(workingDirectory, GlobalFileNames.cursorRulesDir)
const updatedLocalCursorToggles1 = await synchronizeRuleToggles(localCursorRulesFilePath, localCursorRulesToggles, ".mdc")
localCursorRulesFilePath = path.resolve(workingDirectory, GlobalFileNames.cursorRulesFile)
const updatedLocalCursorToggles2 = await synchronizeRuleToggles(localCursorRulesFilePath, localCursorRulesToggles)
const updatedLocalCursorToggles = combineRuleToggles(updatedLocalCursorToggles1, updatedLocalCursorToggles2)
controller.stateManager.setWorkspaceState("localCursorRulesToggles", updatedLocalCursorToggles)
// local agents toggles
const localAgentsRulesToggles = controller.stateManager.getWorkspaceStateKey("localAgentsRulesToggles")
const localAgentsRulesFilePath = path.resolve(workingDirectory, GlobalFileNames.agentsRulesFile)
const updatedLocalAgentsToggles = await synchronizeRuleToggles(localAgentsRulesFilePath, localAgentsRulesToggles)
controller.stateManager.setWorkspaceState("localAgentsRulesToggles", updatedLocalAgentsToggles)
return {
windsurfLocalToggles: updatedLocalWindsurfToggles,
cursorLocalToggles: updatedLocalCursorToggles,
agentsLocalToggles: updatedLocalAgentsToggles,
}
}
@@ -1,53 +0,0 @@
import * as yaml from "js-yaml"
export type FrontmatterParseResult = {
data: Record<string, unknown>
/**
* The markdown content after stripping the `--- frontmatter ---` block.
*
* Named `body` (rather than `content`) to make it clear this is the remaining
* document body and to keep this helper generic for multiple consumers.
*/
body: string
/**
* True when the input contained a frontmatter block, even if parsing failed.
*
* This allows callers to distinguish:
* - "no frontmatter provided" (baseline behavior), vs
* - "frontmatter was provided" (may have semantic meaning in future consumers).
*/
hadFrontmatter: boolean
/**
* Present only when YAML frontmatter was detected but failed to parse.
*
* This helper is intentionally fail-open and does not log. Returning `parseError`
* lets each caller decide whether to log, surface diagnostics, etc.
*/
parseError?: string
}
/**
* Parse YAML frontmatter from markdown content.
*
* Behavior is intentionally fail-open:
* - If YAML fails to parse, returns data={} and body=original markdown.
* - If no frontmatter exists, returns data={} and body=original markdown.
*/
export function parseYamlFrontmatter(markdown: string): FrontmatterParseResult {
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/
const match = markdown.match(frontmatterRegex)
if (!match) {
return { data: {}, body: markdown, hadFrontmatter: false }
}
const [, yamlContent, body] = match
try {
const data = (yaml.load(yamlContent, { schema: yaml.JSON_SCHEMA }) as Record<string, unknown>) || {}
return { data, body, hadFrontmatter: true }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { data: {}, body: markdown, hadFrontmatter: true, parseError: message }
}
}
@@ -1,153 +0,0 @@
/**
* Rule frontmatter conditional evaluation.
*
* This module implements a small conditional "DSL" for Cline Rules YAML frontmatter.
* It is used to decide whether a rule should be activated for a given request context.
*
* Notes:
* - Unknown conditional keys are ignored for forward compatibility.
* - The `paths` conditional matches if any candidate path matches any glob pattern.
* - Candidate paths are expected to be workspace-root-relative POSIX paths.
*/
import picomatch from "picomatch"
export type RuleEvaluationContext = {
/**
* Candidate workspace-relative paths that represent the current request context.
* These should be POSIX-style paths, relative to their workspace root.
*/
paths?: string[]
}
type MatchedConditions = Record<string, string[]>
type ConditionalEvaluatorResult = {
passed: boolean
matched?: string[]
}
type ConditionalEvaluatorWithMatch = (frontmatterValue: unknown, context: RuleEvaluationContext) => ConditionalEvaluatorResult
function toPosix(p: string): string {
return p.replace(/\\/g, "/")
}
function isNonEmptyStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((v) => typeof v === "string" && v.length > 0)
}
const evaluatePathsConditional: ConditionalEvaluatorWithMatch = (frontmatterValue: unknown, context: RuleEvaluationContext) => {
// Invalid type -> ignore conditional (fail-open)
if (!isNonEmptyStringArray(frontmatterValue)) {
return { passed: true }
}
const patterns = frontmatterValue.map((p) => p.trim()).filter(Boolean)
// Policy:
// - `paths` omitted => universal (because this evaluator is never invoked)
// - `paths: []` (or `paths` that trims to no usable patterns) => match nothing (fail-closed)
// This gives users an explicit way to disable a rule via frontmatter, while omission
// remains the mechanism for "always on" rules.
if (patterns.length === 0) {
return { passed: false }
}
const candidatePaths = (context.paths || []).map((p) => toPosix(p)).filter(Boolean)
// Conservative: no evidence => do not activate path-scoped rules
if (candidatePaths.length === 0) {
return { passed: false }
}
const matchedPatterns: string[] = []
for (const pattern of patterns) {
const matcher = picomatch(pattern, { dot: true })
if (candidatePaths.some((candidate) => matcher(candidate))) {
matchedPatterns.push(pattern)
}
}
return { passed: matchedPatterns.length > 0, matched: matchedPatterns.length > 0 ? matchedPatterns : undefined }
}
const conditionalEvaluators: Record<string, ConditionalEvaluatorWithMatch> = {
paths: evaluatePathsConditional,
}
export function evaluateRuleConditionals(
frontmatter: Record<string, unknown>,
context: RuleEvaluationContext,
): {
passed: boolean
matchedConditions: MatchedConditions
} {
const matchedConditions: MatchedConditions = {}
for (const [key, value] of Object.entries(frontmatter)) {
const evaluator = conditionalEvaluators[key]
if (!evaluator) {
continue // unknown conditional: ignore
}
const result = evaluator(value, context)
if (!result.passed) {
return { passed: false, matchedConditions: {} }
}
if (result.matched && result.matched.length > 0) {
matchedConditions[key] = result.matched
}
}
return { passed: true, matchedConditions }
}
/**
* Extracts path-like strings from user text to help enable first-turn activation.
* This is intentionally heuristic and conservative.
*/
export function extractPathLikeStrings(text: string): string[] {
if (!text) return []
// 0) Strip fenced code blocks to avoid extracting paths from pasted code.
// This dramatically reduces false positives from snippets containing `foo/bar` or `a.b.c`.
// Note: We intentionally keep this simple and fail-open (if fences are unbalanced, we do nothing special).
const withoutCodeFences = text.replace(/```[\s\S]*?```/g, " ")
// 1) Remove URLs to avoid false positives.
const withoutUrls = withoutCodeFences.replace(/\b\w+:\/\/[^\s]+/g, " ")
// 2) Match tokens that look like paths.
// - Either contain at least one slash (e.g. src/index.ts)
// - Or look like a simple filename with an extension (e.g. foo.md)
// (no slashes; conservative to reduce false positives).
const tokenRegex =
/(?:^|[\s([{"'`])((?:[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+\/?|[A-Za-z0-9_.-]+\.[A-Za-z0-9]{1,10}))(?=$|[\s)\]}"'`,.;:!?])/g
const matches: string[] = []
let match: RegExpExecArray | null
while ((match = tokenRegex.exec(withoutUrls))) {
const candidate = match[1]
if (!candidate) continue
// Normalize away leading ./
const normalized = candidate.startsWith("./") ? candidate.slice(2) : candidate
// Avoid absurdly long tokens
if (normalized.length > 300) continue
matches.push(normalized)
}
// De-dupe while preserving order
const seen = new Set<string>()
const result: string[] = []
for (const m of matches) {
const posix = m.replace(/\\/g, "/")
if (posix === "/" || posix.startsWith("/") || posix.includes("..")) {
// We only want repo/workspace-relative hints here.
continue
}
if (!seen.has(posix)) {
seen.add(posix)
result.push(posix)
}
}
return result
}
@@ -1,467 +0,0 @@
import { ClineRulesToggles } from "@shared/cline-rules"
import { GlobalInstructionsFile } from "@shared/remote-config/schema"
import { fileExistsAtPath, isDirectory, readDirectory } from "@utils/fs"
import fs from "fs/promises"
import * as path from "path"
import { Logger } from "@/shared/services/Logger"
import { parseYamlFrontmatter } from "./frontmatter"
import { evaluateRuleConditionals, RuleEvaluationContext } from "./rule-conditionals"
/**
* Recursively traverses directory and finds all files, including checking for optional whitelisted file extension
*/
async function readDirectoryRecursive(
directoryPath: string,
allowedFileExtension: string,
excludedPaths: string[][] = [],
): Promise<string[]> {
try {
const entries = await readDirectory(directoryPath, excludedPaths)
const results: string[] = []
for (const entry of entries) {
if (allowedFileExtension !== "") {
const fileExtension = path.extname(entry)
if (fileExtension !== allowedFileExtension) {
continue
}
}
results.push(entry)
}
return results
} catch (error) {
Logger.error(`Error reading directory ${directoryPath}: ${error}`)
return []
}
}
/**
* Gets the up to date toggles
*/
export async function synchronizeRuleToggles(
rulesDirectoryPath: string,
currentToggles: ClineRulesToggles,
allowedFileExtension = "",
excludedPaths: string[][] = [],
): Promise<ClineRulesToggles> {
// Create a copy of toggles to modify
const updatedToggles = { ...currentToggles }
try {
const pathExists = await fileExistsAtPath(rulesDirectoryPath)
if (pathExists) {
const isDir = await isDirectory(rulesDirectoryPath)
if (isDir) {
// DIRECTORY CASE
const filePaths = await readDirectoryRecursive(rulesDirectoryPath, allowedFileExtension, excludedPaths)
const existingRulePaths = new Set<string>()
for (const filePath of filePaths) {
const ruleFilePath = path.resolve(rulesDirectoryPath, filePath)
existingRulePaths.add(ruleFilePath)
const pathHasToggle = ruleFilePath in updatedToggles
if (!pathHasToggle) {
updatedToggles[ruleFilePath] = true
}
}
// Clean up toggles for non-existent files
for (const togglePath in updatedToggles) {
const pathExists = existingRulePaths.has(togglePath)
if (!pathExists) {
delete updatedToggles[togglePath]
}
}
} else {
// FILE CASE
// Add toggle for this file
const pathHasToggle = rulesDirectoryPath in updatedToggles
if (!pathHasToggle) {
updatedToggles[rulesDirectoryPath] = true
}
// Remove toggles for any other paths
for (const togglePath in updatedToggles) {
if (togglePath !== rulesDirectoryPath) {
delete updatedToggles[togglePath]
}
}
}
} else {
// PATH DOESN'T EXIST CASE
// Clear all toggles since the path doesn't exist
for (const togglePath in updatedToggles) {
delete updatedToggles[togglePath]
}
}
} catch (error) {
Logger.error(`Failed to synchronize rule toggles for path: ${rulesDirectoryPath}`, error)
}
return updatedToggles
}
/**
* Synchronizes remote rule toggles with current remote config
* Removes toggles for rules that no longer exist, adds defaults for new rules
*/
export function synchronizeRemoteRuleToggles(
remoteRules: GlobalInstructionsFile[],
currentToggles: ClineRulesToggles,
): ClineRulesToggles {
const updatedToggles: ClineRulesToggles = {}
// Create set of current remote rule names
const existingRuleNames = new Set(remoteRules.map((rule) => rule.name))
// Keep toggles only for rules that still exist
for (const [ruleName, enabled] of Object.entries(currentToggles)) {
if (existingRuleNames.has(ruleName)) {
updatedToggles[ruleName] = enabled
}
}
// Add default toggles for new rules (default to enabled)
for (const rule of remoteRules) {
if (!(rule.name in updatedToggles)) {
updatedToggles[rule.name] = true
}
}
return updatedToggles
}
/**
* Certain project rules have more than a single location where rules are allowed to be stored
*/
export function combineRuleToggles(toggles1: ClineRulesToggles, toggles2: ClineRulesToggles): ClineRulesToggles {
return { ...toggles1, ...toggles2 }
}
/**
* Read the content of rules files
*/
const getRuleFilesTotalContent = async (rulesFilePaths: string[], basePath: string, toggles: ClineRulesToggles) => {
return (await getRuleFilesTotalContentWithMetadata(rulesFilePaths, basePath, toggles)).content
}
const LOCAL_RULE_PATHS = {
clineRules: ".clinerules",
workflows: ".clinerules/workflows",
} as const
type ActivatedConditionalRule = {
name: string
matchedConditions: Record<string, string[]>
}
type RuleFileController = {
stateManager: {
getGlobalSettingsKey(key: "globalWorkflowToggles" | "globalClineRulesToggles"): ClineRulesToggles
setGlobalState(key: "globalWorkflowToggles" | "globalClineRulesToggles", value: ClineRulesToggles): void
getWorkspaceStateKey(
key:
| "workflowToggles"
| "localCursorRulesToggles"
| "localWindsurfRulesToggles"
| "localAgentsRulesToggles"
| "localClineRulesToggles",
): ClineRulesToggles
setWorkspaceState(
key:
| "workflowToggles"
| "localCursorRulesToggles"
| "localWindsurfRulesToggles"
| "localAgentsRulesToggles"
| "localClineRulesToggles",
value: ClineRulesToggles,
): void
}
}
// Prefixes used to make activated conditional rule identifiers self-explanatory in the UI.
// NOTE: These are display identifiers (not toggle keys).
export const RULE_SOURCE_PREFIX = {
workspace: "workspace",
global: "global",
remote: "remote",
} as const
export type RuleLoadResult = {
content: string
activatedConditionalRules: ActivatedConditionalRule[]
}
/**
* Result type for rule loading functions that return formatted instructions.
* Used by getGlobalClineRules and getLocalClineRules.
*/
type RuleLoadResultWithInstructions = {
instructions?: string
activatedConditionalRules: ActivatedConditionalRule[]
}
export const getRuleFilesTotalContentWithMetadata = async (
rulesFilePaths: string[],
basePath: string,
toggles: ClineRulesToggles,
opts?: { evaluationContext?: RuleEvaluationContext; ruleNamePrefix?: keyof typeof RULE_SOURCE_PREFIX },
): Promise<RuleLoadResult> => {
const evaluationContext = opts?.evaluationContext ?? {}
const prefix = RULE_SOURCE_PREFIX[opts?.ruleNamePrefix ?? "global"]
const parts = await Promise.all(
rulesFilePaths.map(async (filePath) => {
const ruleFilePath = path.resolve(basePath, filePath)
const ruleFilePathRelative = path.relative(basePath, ruleFilePath)
if (ruleFilePath in toggles && toggles[ruleFilePath] === false) {
return { contentPart: null, activatedRule: null }
}
const raw = (await fs.readFile(ruleFilePath, "utf8")).trim()
if (!raw) {
return { contentPart: null, activatedRule: null }
}
const { data, body, hadFrontmatter, parseError } = parseYamlFrontmatter(raw)
// YAML parse errors are treated as fail-open.
// NOTE: We intentionally preserve the raw frontmatter fence/content here so the LLM can still
// see the author's intended scoping (e.g., `paths:`) and reason about it, even if it cannot be
// evaluated reliably due to invalid YAML.
if (hadFrontmatter && parseError) {
return { contentPart: `${ruleFilePathRelative}\n${raw}`, activatedRule: null }
}
const { passed, matchedConditions } = evaluateRuleConditionals(data, evaluationContext)
if (!passed) {
return { contentPart: null, activatedRule: null }
}
const activatedRule =
hadFrontmatter && Object.keys(matchedConditions).length > 0
? { name: `${prefix}:${ruleFilePathRelative}`, matchedConditions }
: null
return { contentPart: `${ruleFilePathRelative}\n${body.trim()}`, activatedRule }
}),
)
return {
content: parts
.map((p) => p.contentPart)
.filter(Boolean)
.join("\n\n"),
activatedConditionalRules: parts
.map((p) => p.activatedRule)
.filter((rule): rule is ActivatedConditionalRule => rule !== null),
}
}
function getRemoteRulesTotalContentWithMetadata(
remoteRules: GlobalInstructionsFile[],
remoteToggles: ClineRulesToggles,
opts?: { evaluationContext?: RuleEvaluationContext },
): RuleLoadResult {
const activatedConditionalRules: ActivatedConditionalRule[] = []
const evaluationContext = opts?.evaluationContext ?? {}
let combinedContent = ""
for (const rule of remoteRules) {
const isEnabled = rule.alwaysEnabled || remoteToggles[rule.name] !== false
if (!isEnabled) continue
const raw = (rule.contents || "").trim()
if (!raw) continue
const { data, body, hadFrontmatter, parseError } = parseYamlFrontmatter(raw)
if (hadFrontmatter && parseError) {
// Fail open: include entire raw contents
if (combinedContent) combinedContent += "\n\n"
combinedContent += `${rule.name}\n${raw}`
continue
}
const { passed, matchedConditions } = evaluateRuleConditionals(data, evaluationContext)
if (!passed) continue
if (hadFrontmatter && Object.keys(matchedConditions).length > 0) {
activatedConditionalRules.push({ name: `${RULE_SOURCE_PREFIX.remote}:${rule.name}`, matchedConditions })
}
if (combinedContent) combinedContent += "\n\n"
combinedContent += `${rule.name}\n${body.trim()}`
}
return { content: combinedContent, activatedConditionalRules }
}
/**
* Handles converting any directory into a file (specifically used for .clinerules and .clinerules/workflows)
* The old .clinerules file or .clinerules/workflows file will be renamed to a default filename
* Doesn't do anything if the dir already exists or doesn't exist
* Returns whether there are any uncaught errors
*/
async function ensureLocalClineDirExists(clinerulePath: string, defaultRuleFilename: string): Promise<boolean> {
try {
const exists = await fileExistsAtPath(clinerulePath)
if (exists && !(await isDirectory(clinerulePath))) {
// logic to convert .clinerules file into directory, and rename the rules file to {defaultRuleFilename}
const content = await fs.readFile(clinerulePath, "utf8")
const tempPath = clinerulePath + ".bak"
await fs.rename(clinerulePath, tempPath) // create backup
try {
await fs.mkdir(clinerulePath, { recursive: true })
await fs.writeFile(path.join(clinerulePath, defaultRuleFilename), content, "utf8")
await fs.unlink(tempPath).catch(() => {}) // delete backup
return false // conversion successful with no errors
} catch (_conversionError) {
// attempt to restore backup on conversion failure
try {
await fs.rm(clinerulePath, { recursive: true, force: true }).catch(() => {})
await fs.rename(tempPath, clinerulePath) // restore backup
} catch (_restoreError) {}
return true // in either case here we consider this an error
}
}
// exists and is a dir or doesn't exist, either of these cases we dont need to handle here
return false
} catch (_error) {
return true
}
}
/**
* Create a rule file or workflow file
*/
export const createRuleFile = async (isGlobal: boolean, filename: string, cwd: string, type: string) => {
try {
let filePath: string
if (isGlobal) {
const disk = require("@core/storage/disk") as {
ensureWorkflowsDirectoryExists: () => Promise<string>
ensureRulesDirectoryExists: () => Promise<string>
}
if (type === "workflow") {
const globalClineWorkflowFilePath = await disk.ensureWorkflowsDirectoryExists()
filePath = path.join(globalClineWorkflowFilePath, filename)
} else {
const globalClineRulesFilePath = await disk.ensureRulesDirectoryExists()
filePath = path.join(globalClineRulesFilePath, filename)
}
} else {
const localClineRulesFilePath = path.resolve(cwd, LOCAL_RULE_PATHS.clineRules)
const hasError = await ensureLocalClineDirExists(localClineRulesFilePath, "default-rules.md")
if (hasError === true) {
return { filePath: null, fileExists: false }
}
await fs.mkdir(localClineRulesFilePath, { recursive: true })
if (type === "workflow") {
const localWorkflowsFilePath = path.resolve(cwd, LOCAL_RULE_PATHS.workflows)
const hasError = await ensureLocalClineDirExists(localWorkflowsFilePath, "default-workflows.md")
if (hasError === true) {
return { filePath: null, fileExists: false }
}
await fs.mkdir(localWorkflowsFilePath, { recursive: true })
filePath = path.join(localWorkflowsFilePath, filename)
} else {
// clinerules file creation
filePath = path.join(localClineRulesFilePath, filename)
}
}
const fileExists = await fileExistsAtPath(filePath)
if (fileExists) {
return { filePath, fileExists }
}
await fs.writeFile(filePath, "", "utf8")
return { filePath, fileExists: false }
} catch (_error) {
return { filePath: null, fileExists: false }
}
}
/**
* Delete a rule file or workflow file
*/
export async function deleteRuleFile(
controller: RuleFileController,
rulePath: string,
isGlobal: boolean,
type: string,
): Promise<{ success: boolean; message: string }> {
try {
// Check if file exists
const fileExists = await fileExistsAtPath(rulePath)
if (!fileExists) {
return {
success: false,
message: `File does not exist: ${rulePath}`,
}
}
// Delete the file from disk
await fs.rm(rulePath, { force: true })
// Get the filename for messages
const fileName = path.basename(rulePath)
// Update the appropriate toggles
if (isGlobal) {
if (type === "workflow") {
const toggles = controller.stateManager.getGlobalSettingsKey("globalWorkflowToggles")
delete toggles[rulePath]
controller.stateManager.setGlobalState("globalWorkflowToggles", toggles)
} else {
const toggles = controller.stateManager.getGlobalSettingsKey("globalClineRulesToggles")
delete toggles[rulePath]
controller.stateManager.setGlobalState("globalClineRulesToggles", toggles)
}
} else {
if (type === "workflow") {
const toggles = controller.stateManager.getWorkspaceStateKey("workflowToggles")
delete toggles[rulePath]
controller.stateManager.setWorkspaceState("workflowToggles", toggles)
} else if (type === "cursor") {
const toggles = controller.stateManager.getWorkspaceStateKey("localCursorRulesToggles")
delete toggles[rulePath]
controller.stateManager.setWorkspaceState("localCursorRulesToggles", toggles)
} else if (type === "windsurf") {
const toggles = controller.stateManager.getWorkspaceStateKey("localWindsurfRulesToggles")
delete toggles[rulePath]
controller.stateManager.setWorkspaceState("localWindsurfRulesToggles", toggles)
} else if (type === "agents") {
const toggles = controller.stateManager.getWorkspaceStateKey("localAgentsRulesToggles")
delete toggles[rulePath]
controller.stateManager.setWorkspaceState("localAgentsRulesToggles", toggles)
} else {
const toggles = controller.stateManager.getWorkspaceStateKey("localClineRulesToggles")
delete toggles[rulePath]
controller.stateManager.setWorkspaceState("localClineRulesToggles", toggles)
}
}
return {
success: true,
message: `File "${fileName}" deleted successfully`,
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
Logger.error(`Error deleting file: ${errorMessage}`, error)
return {
success: false,
message: `Failed to delete file.`,
}
}
}
@@ -1,313 +0,0 @@
import { getSkillsDirectoriesForScan } from "@core/storage/skill-directories"
import type { GlobalInstructionsFile } from "@shared/remote-config/schema"
import type { SkillContent, SkillMetadata } from "@shared/skills"
import { fileExistsAtPath, isDirectory } from "@utils/fs"
import * as fs from "fs/promises"
import * as yaml from "js-yaml"
import * as path from "path"
import { Logger } from "@/shared/services/Logger"
import { parseYamlFrontmatter } from "./frontmatter"
/**
* Update the `disabled` frontmatter flag of a SKILL.md document.
*
* The SDK (which builds the model's skill list and the `skills` tool) reads a
* skill's enabled state from the SKILL.md frontmatter `disabled` field, not from
* the extension's UI toggle state. Toggling a skill in the VS Code sidebar must
* therefore also write this flag so the change is reflected for the model
* (ENG-1995). This mirrors the SDK's updateSkillMarkdownEnabledState but lives in
* the extension and uses js-yaml (the extension's frontmatter parser).
*
* - enabled=false → sets `disabled: true`.
* - enabled=true → removes `disabled` (and a stale `enabled: false`), dropping
* the frontmatter block entirely if it becomes empty.
*
* Returns the original content unchanged when enabling a document that has no
* frontmatter (nothing to clear).
*/
export function updateSkillMarkdownDisabledState(content: string, enabled: boolean): string {
const { data, body, hadFrontmatter, parseError } = parseYamlFrontmatter(content)
if (!hadFrontmatter && enabled) {
return content
}
// parseYamlFrontmatter fails open on malformed YAML: it returns data={} and
// body=<full original content> (frontmatter block included). Serializing here
// would prepend a second `---` block and corrupt the file, so leave it
// untouched and let the user fix the frontmatter.
if (parseError) {
return content
}
if (enabled) {
delete data.disabled
if (data.enabled === false) {
delete data.enabled
}
if (Object.keys(data).length === 0) {
return body
}
return serializeSkillFrontmatter(data, body)
}
data.disabled = true
return serializeSkillFrontmatter(data, body)
}
function serializeSkillFrontmatter(data: Record<string, unknown>, body: string): string {
const yamlText = yaml.dump(data, { schema: yaml.JSON_SCHEMA }).trimEnd()
return `---\n${yamlText}\n---\n${body}`
}
/**
* Persist a skill's enabled/disabled state to its SKILL.md frontmatter on disk.
* No-op (returns false) for remote skills, which have no backing file.
*/
export async function setSkillDisabledInFrontmatter(skillMdPath: string, enabled: boolean): Promise<boolean> {
if (!skillMdPath || skillMdPath.startsWith("remote:")) {
return false
}
try {
const content = await fs.readFile(skillMdPath, "utf-8")
const updated = updateSkillMarkdownDisabledState(content, enabled)
if (updated !== content) {
await fs.writeFile(skillMdPath, updated)
}
return true
} catch (error) {
Logger.warn(`Failed to update skill frontmatter at ${skillMdPath}:`, error)
return false
}
}
/**
* A remote skill entry after frontmatter validation.
* name is always frontmatter.name (canonical). A warning is logged if entry.name drifts.
*/
export interface ValidatedRemoteSkill {
name: string
description: string
alwaysEnabled: boolean
contents: string
}
/**
* Parse and validate remote skill entries from GlobalInstructionsFile[].
*
* Validates:
* - frontmatter.name and frontmatter.description are present strings
* - Warns if entry.name does not match frontmatter.name (drift)
*
* Returns only valid entries. Callers share this single validation point
* instead of duplicating frontmatter parsing.
*/
export function parseRemoteSkillEntries(entries: GlobalInstructionsFile[]): ValidatedRemoteSkill[] {
return entries
.map((entry) => {
const { data: frontmatter } = parseYamlFrontmatter(entry.contents)
if (!frontmatter.name || typeof frontmatter.name !== "string") return null
if (!frontmatter.description || typeof frontmatter.description !== "string") return null
// Warn on drift but use frontmatter.name as the canonical identity.
// The dashboard should keep entry.name in sync, but we don't reject on mismatch
// since that would silently hide org-configured skills from users.
if (entry.name !== frontmatter.name) {
Logger.warn(`Remote skill entry.name "${entry.name}" does not match frontmatter.name "${frontmatter.name}"`)
}
return {
name: frontmatter.name,
description: frontmatter.description as string,
alwaysEnabled: entry.alwaysEnabled,
contents: entry.contents,
}
})
.filter((e): e is NonNullable<typeof e> => e !== null)
}
/** Parse YAML frontmatter from markdown content (shared helper). */
function parseFrontmatter(fileContent: string): { data: Record<string, unknown>; content: string } {
const result = parseYamlFrontmatter(fileContent)
if (result.parseError) {
Logger.warn("Failed to parse YAML frontmatter:", result.parseError)
}
return { data: result.data, content: result.body }
}
/**
* Scan a directory for skill subdirectories containing SKILL.md files.
*/
async function scanSkillsDirectory(dirPath: string, source: "global" | "project"): Promise<SkillMetadata[]> {
const skills: SkillMetadata[] = []
if (!(await fileExistsAtPath(dirPath)) || !(await isDirectory(dirPath))) {
return skills
}
try {
const entries = await fs.readdir(dirPath)
for (const entryName of entries) {
const entryPath = path.join(dirPath, entryName)
const stats = await fs.stat(entryPath).catch(() => null)
if (!stats?.isDirectory()) continue
const skill = await loadSkillMetadata(entryPath, source, entryName)
if (skill) {
skills.push(skill)
}
}
} catch (error: unknown) {
if (error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "EACCES") {
Logger.warn(`Permission denied reading skills directory: ${dirPath}`)
}
}
return skills
}
/**
* Load skill metadata from a skill directory.
*/
async function loadSkillMetadata(
skillDir: string,
source: "global" | "project",
skillName: string,
): Promise<SkillMetadata | null> {
const skillMdPath = path.join(skillDir, "SKILL.md")
if (!(await fileExistsAtPath(skillMdPath))) return null
try {
const fileContent = await fs.readFile(skillMdPath, "utf-8")
const { data: frontmatter } = parseFrontmatter(fileContent)
// Validate required fields
if (!frontmatter.name || typeof frontmatter.name !== "string") {
Logger.warn(`Skill at ${skillDir} missing required 'name' field`)
return null
}
if (!frontmatter.description || typeof frontmatter.description !== "string") {
Logger.warn(`Skill at ${skillDir} missing required 'description' field`)
return null
}
// Name must match directory name per spec
if (frontmatter.name !== skillName) {
Logger.warn(`Skill name "${frontmatter.name}" doesn't match directory "${skillName}"`)
return null
}
return {
name: skillName,
description: frontmatter.description,
path: skillMdPath,
source,
}
} catch (error) {
Logger.warn(`Failed to load skill at ${skillDir}:`, error)
return null
}
}
/**
* Discover all skills from global (~/.cline/skills), remote config, and project directories.
*
* Precedence (highest wins on name collision via getAvailableSkills):
* remote (enterprise) > disk-global (user personal) > project (workspace)
*
* This is achieved by the array order + getAvailableSkills iterating in reverse (last wins):
* [project..., disk-global..., remote...]
*/
export async function discoverSkills(cwd: string, remoteSkillEntries?: GlobalInstructionsFile[]): Promise<SkillMetadata[]> {
const skills: SkillMetadata[] = []
const scanDirs = getSkillsDirectoriesForScan(cwd)
// Collect project and disk-global skills separately so we can insert remote between them
const projectSkills: SkillMetadata[] = []
const diskGlobalSkills: SkillMetadata[] = []
for (const dir of scanDirs) {
const dirSkills = await scanSkillsDirectory(dir.path, dir.source)
if (dir.source === "project") {
projectSkills.push(...dirSkills)
} else {
diskGlobalSkills.push(...dirSkills)
}
}
// Remote skills: validated via parseRemoteSkillEntries and keyed by frontmatter.name.
const remoteSkills: SkillMetadata[] = parseRemoteSkillEntries(remoteSkillEntries || []).map((entry) => ({
name: entry.name,
description: entry.description,
path: `remote:${entry.name}`,
source: "global" as const,
}))
// Insert in order: project → disk-global → remote
// getAvailableSkills iterates backwards so remote (last) wins, then disk-global, then project
skills.push(...projectSkills, ...diskGlobalSkills, ...remoteSkills)
return skills
}
/**
* Get available skills with override resolution (global > project).
*/
export function getAvailableSkills(skills: SkillMetadata[]): SkillMetadata[] {
const seen = new Set<string>()
const result: SkillMetadata[] = []
// Iterate backwards: global skills (added last) are seen first and take precedence
for (let i = skills.length - 1; i >= 0; i--) {
const skill = skills[i]
if (!seen.has(skill.name)) {
seen.add(skill.name)
result.unshift(skill)
}
}
return result
}
/**
* Get full skill content including instructions.
* For remote skills, pass remoteSkillEntries so content can be loaded without disk I/O.
*/
export async function getSkillContent(
skillName: string,
availableSkills: SkillMetadata[],
remoteSkillEntries?: GlobalInstructionsFile[],
): Promise<SkillContent | null> {
const skill = availableSkills.find((s) => s.name === skillName)
if (!skill) return null
// Remote skills have no file on disk — retrieve content from the provided entries.
// Try entry.name first (fast path when dashboard is in sync), fall back to frontmatter match.
if (skill.path.startsWith("remote:")) {
let entry = (remoteSkillEntries || []).find((e) => e.name === skillName)
if (!entry) {
entry = (remoteSkillEntries || []).find((e) => {
const { data } = parseYamlFrontmatter(e.contents)
return typeof data.name === "string" && data.name === skillName
})
}
if (!entry) return null
const { body } = parseYamlFrontmatter(entry.contents)
return {
...skill,
instructions: body.trim(),
}
}
try {
const fileContent = await fs.readFile(skill.path, "utf-8")
const { content: body } = parseFrontmatter(fileContent)
return {
...skill,
instructions: body.trim(),
}
} catch {
return null
}
}
@@ -1,32 +0,0 @@
import { synchronizeRuleToggles } from "@core/context/instructions/user-instructions/rule-helpers"
import { ensureWorkflowsDirectoryExists, GlobalFileNames } from "@core/storage/disk"
import { ClineRulesToggles } from "@shared/cline-rules"
import path from "path"
import { Controller } from "@/core/controller"
/**
* Refresh the workflow toggles
*/
export async function refreshWorkflowToggles(
controller: Controller,
workingDirectory: string,
): Promise<{
globalWorkflowToggles: ClineRulesToggles
localWorkflowToggles: ClineRulesToggles
}> {
// Global workflows
const globalWorkflowToggles = controller.stateManager.getGlobalSettingsKey("globalWorkflowToggles")
const globalClineWorkflowsFilePath = await ensureWorkflowsDirectoryExists()
const updatedGlobalWorkflowToggles = await synchronizeRuleToggles(globalClineWorkflowsFilePath, globalWorkflowToggles)
controller.stateManager.setGlobalState("globalWorkflowToggles", updatedGlobalWorkflowToggles)
const workflowRulesToggles = controller.stateManager.getWorkspaceStateKey("workflowToggles")
const workflowsDirPath = path.resolve(workingDirectory, GlobalFileNames.workflows)
const updatedWorkflowToggles = await synchronizeRuleToggles(workflowsDirPath, workflowRulesToggles)
controller.stateManager.setWorkspaceState("workflowToggles", updatedWorkflowToggles)
return {
globalWorkflowToggles: updatedGlobalWorkflowToggles,
localWorkflowToggles: updatedWorkflowToggles,
}
}
@@ -1,15 +1,6 @@
import { EmptyRequest, String } from "@shared/proto/cline/common"
import { AuthService } from "@/sdk/auth-service"
import { Controller } from "../index"
/**
* Handles the user clicking the login link in the UI.
* Uses the SDK-backed AuthService to initiate the Cline OAuth flow.
* The SDK spawns a local callback server and opens the browser.
*
* @param controller The controller instance.
* @returns The login URL as a string.
*/
export async function accountLoginClicked(_controller: Controller, _: EmptyRequest): Promise<String> {
return await AuthService.getInstance().createAuthRequest()
return String.create({})
}
@@ -2,14 +2,6 @@ import type { EmptyRequest } from "@shared/proto/cline/common"
import { Empty } from "@shared/proto/cline/common"
import type { Controller } from "../index"
/**
* Handles the account logout action.
* Delegates to the SdkController which uses the SDK-backed AuthService.
* @param controller The controller instance
* @param _request The empty request object
* @returns Empty response
*/
export async function accountLogoutClicked(controller: Controller, _request: EmptyRequest): Promise<Empty> {
await controller.handleSignOut()
export async function accountLogoutClicked(_controller: Controller, _request: EmptyRequest): Promise<Empty> {
return Empty.create({})
}
@@ -1,23 +1,6 @@
import { AuthState, AuthStateChangedRequest } from "@shared/proto/cline/account"
import { Logger } from "@/shared/services/Logger"
import type { Controller } from "../index"
/**
* Handles authentication state changes from the Firebase context.
* Updates the user info in global state and returns the updated value.
* @param controller The controller instance
* @param request The auth state change request
* @returns The updated user info
*/
export async function authStateChanged(controller: Controller, request: AuthStateChangedRequest): Promise<AuthState> {
try {
// Store the user info directly in global state
controller.stateManager.setGlobalState("userInfo", request.user)
// Return the same user info
return AuthState.create({ user: request.user })
} catch (error) {
Logger.error(`Failed to update auth state: ${error}`)
throw error
}
export async function authStateChanged(_controller: Controller, _request: AuthStateChangedRequest): Promise<AuthState> {
return AuthState.create({})
}
@@ -1,57 +1,9 @@
import { GetOrganizationCreditsRequest, OrganizationCreditsData, OrganizationUsageTransaction } from "@shared/proto/cline/account"
import { Logger } from "@/shared/services/Logger"
import { GetOrganizationCreditsRequest, OrganizationCreditsData } from "@shared/proto/cline/account"
import type { Controller } from "../index"
/**
* Handles fetching all organization credits data (balance, usage, payments)
* @param controller The controller instance
* @param request Organization credits request
* @returns Organization credits data response
*/
export async function getOrganizationCredits(
controller: Controller,
request: GetOrganizationCreditsRequest,
_controller: Controller,
_request: GetOrganizationCreditsRequest,
): Promise<OrganizationCreditsData> {
try {
if (!controller.accountService) {
throw new Error("Account service not available")
}
// Call the individual RPC variants in parallel
const [balanceData, usageTransactions] = await Promise.all([
controller.accountService.fetchOrganizationCreditsRPC(request.organizationId),
controller.accountService.fetchOrganizationUsageTransactionsRPC(request.organizationId),
])
// If balance call fails (returns undefined), throw an error
if (!balanceData) {
throw new Error("Failed to fetch organization credits data")
}
return OrganizationCreditsData.create({
balance: balanceData ? { currentBalance: balanceData.balance / 100 } : { currentBalance: 0 },
organizationId: balanceData?.organizationId || "",
usageTransactions:
usageTransactions?.map((tx: any) =>
OrganizationUsageTransaction.create({
aiInferenceProviderName: tx.aiInferenceProviderName,
aiModelName: tx.aiModelName,
aiModelTypeName: tx.aiModelTypeName,
completionTokens: tx.completionTokens,
costUsd: tx.costUsd,
createdAt: tx.createdAt,
creditsUsed: tx.creditsUsed,
generationId: tx.generationId,
organizationId: tx.organizationId,
promptTokens: tx.promptTokens,
totalTokens: tx.totalTokens,
userId: tx.userId,
operation: tx.operation,
}),
) || [],
})
} catch (error) {
Logger.error(`Failed to fetch organization credits data: ${error}`)
throw error
}
return OrganizationCreditsData.create({})
}
@@ -1,11 +1,6 @@
import { EmptyRequest, String } from "@shared/proto/cline/common"
import { HostProvider } from "@/hosts/host-provider"
import { Controller } from "../index"
/**
* Constructs and returns a URL that will redirect to the user's IDE.
*/
export async function getRedirectUrl(_controller: Controller, _: EmptyRequest): Promise<String> {
const url = (await HostProvider.env.getIdeRedirectUri({})).value
return { value: url }
return String.create({})
}
@@ -1,39 +1,7 @@
import { UserCreditsData } from "@shared/proto/cline/account"
import type { EmptyRequest } from "@shared/proto/cline/common"
import { Logger } from "@/shared/services/Logger"
import type { Controller } from "../index"
/**
* Handles fetching all user credits data (balance, usage, payments)
* @param controller The controller instance
* @param request Empty request
* @returns User credits data response
*/
export async function getUserCredits(controller: Controller, _request: EmptyRequest): Promise<UserCreditsData> {
try {
if (!controller.accountService) {
throw new Error("Account service not available")
}
// Call the individual RPC variants in parallel
const [balance, usageTransactions, paymentTransactions] = await Promise.all([
controller.accountService.fetchBalanceRPC(),
controller.accountService.fetchUsageTransactionsRPC(),
controller.accountService.fetchPaymentTransactionsRPC(),
])
// If either call fails (returns undefined), throw an error
if (balance === undefined) {
throw new Error("Failed to fetch user credits data")
}
return UserCreditsData.create({
balance: balance ? { currentBalance: balance.balance / 100 } : { currentBalance: 0 },
usageTransactions: usageTransactions,
paymentTransactions: paymentTransactions,
})
} catch (error) {
Logger.error(`Failed to fetch user credits data: ${error}`)
throw error
}
export async function getUserCredits(_controller: Controller, _request: EmptyRequest): Promise<UserCreditsData> {
return UserCreditsData.create({})
}
@@ -1,35 +1,7 @@
import { UserOrganization, UserOrganizationsResponse } from "@shared/proto/cline/account"
import { UserOrganizationsResponse } from "@shared/proto/cline/account"
import type { EmptyRequest } from "@shared/proto/cline/common"
import type { Controller } from "../index"
/**
* Handles fetching all user credits data (balance, usage, payments)
* @param controller The controller instance
* @param request Empty request
* @returns User credits data response
*/
export async function getUserOrganizations(controller: Controller, _request: EmptyRequest): Promise<UserOrganizationsResponse> {
try {
if (!controller.accountService) {
throw new Error("Account service not available")
}
// Fetch user organizations from the account service
const organizations = await controller.accountService.fetchUserOrganizationsRPC()
return UserOrganizationsResponse.create({
organizations:
organizations?.map((org: any) =>
UserOrganization.create({
active: org.active,
memberId: org.memberId,
name: org.name,
organizationId: org.organizationId,
roles: org.roles ? [...org.roles] : [],
}),
) || [],
})
} catch (error) {
throw error
}
export async function getUserOrganizations(_controller: Controller, _request: EmptyRequest): Promise<UserOrganizationsResponse> {
return UserOrganizationsResponse.create({})
}
@@ -1,18 +1,6 @@
import { Empty, EmptyRequest } from "@shared/proto/cline/common"
import { HostProvider } from "@/hosts/host-provider"
import { openExternal } from "@/utils/env"
import { Controller } from ".."
/**
* Initiates Hicap auth
*/
export async function hicapAuthClicked(_: Controller, __: EmptyRequest): Promise<Empty> {
const callbackUrl = await HostProvider.get().getCallbackUrl("/hicap")
const authUrl = new URL("https://dashboard.hicap.ai/setup")
authUrl.searchParams.set("application", "cline")
authUrl.searchParams.set("callback_url", callbackUrl)
await openExternal(authUrl.toString())
return {}
return Empty.create({})
}
@@ -1,43 +1,6 @@
import { Empty, EmptyRequest } from "@shared/proto/cline/common"
import { ShowMessageType } from "@shared/proto/host/window"
import { HostProvider } from "@/hosts/host-provider"
import { AuthService } from "@/sdk/auth-service"
import { Logger } from "@/shared/services/Logger"
import { Controller } from ".."
/**
* Initiates OpenAI Codex OAuth authentication flow.
* Uses the SDK-backed AuthService which delegates to @cline/core's
* loginOpenAICodex() function.
*/
export async function openAiCodexSignIn(controller: Controller, _: EmptyRequest): Promise<Empty> {
try {
const authService = AuthService.getInstance()
// Start the OAuth flow in the background
authService
.openAiCodexLogin()
.then(async () => {
HostProvider.window.showMessage({
type: ShowMessageType.INFORMATION,
message: "Successfully signed in to OpenAI Codex",
})
await controller.postStateToWebview()
})
.catch((error) => {
Logger.error("[openAiCodexSignIn] OAuth flow failed:", error)
const errorMessage = error instanceof Error ? error.message : String(error)
if (!errorMessage.includes("timed out")) {
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message: `OpenAI Codex sign in failed: ${errorMessage}`,
})
}
})
} catch (error) {
Logger.error("[openAiCodexSignIn] Failed to start OAuth flow:", error)
throw error
}
return {}
export async function openAiCodexSignIn(_controller: Controller, _: EmptyRequest): Promise<Empty> {
return Empty.create({})
}
@@ -1,23 +1,6 @@
import { Empty, EmptyRequest } from "@shared/proto/cline/common"
import { AuthService } from "@/sdk/auth-service"
import { Logger } from "@/shared/services/Logger"
import { Controller } from ".."
/**
* Signs out of OpenAI Codex by clearing stored credentials.
* Uses the SDK-backed AuthService to clear provider settings.
*/
export async function openAiCodexSignOut(controller: Controller, _: EmptyRequest): Promise<Empty> {
try {
// Clear stored credentials via SDK-backed AuthService
await AuthService.getInstance().clearCodexCredentials()
// Update the state to reflect sign out
await controller.postStateToWebview()
} catch (error) {
Logger.error("[openAiCodexSignOut] Failed to sign out:", error)
throw error
}
return {}
export async function openAiCodexSignOut(_controller: Controller, _: EmptyRequest): Promise<Empty> {
return Empty.create({})
}
@@ -1,17 +1,6 @@
import { Empty, EmptyRequest } from "@shared/proto/cline/common"
import { HostProvider } from "@/hosts/host-provider"
import { openExternal } from "@/utils/env"
import { Controller } from ".."
/**
* Initiates OpenRouter auth
*/
export async function openrouterAuthClicked(_: Controller, __: EmptyRequest): Promise<Empty> {
const callbackUrl = await HostProvider.get().getCallbackUrl("/openrouter")
const authUrl = new URL("https://openrouter.ai/auth")
authUrl.searchParams.set("callback_url", callbackUrl)
await openExternal(authUrl.toString())
return {}
return Empty.create({})
}
@@ -1,25 +1,6 @@
import { Empty, StringRequest } from "@shared/proto/cline/common"
import { HostProvider } from "@/hosts/host-provider"
import { toRequestyServiceUrl } from "@/shared/clients/requesty"
import { openExternal } from "@/utils/env"
import { Controller } from ".."
/**
* Initiates Requesty auth with optional custom base URL
*/
export async function requestyAuthClicked(_: Controller, req: StringRequest): Promise<Empty> {
const customBaseUrl = req.value || undefined
const callbackUrl = await HostProvider.get().getCallbackUrl("/requesty")
const baseUrl = toRequestyServiceUrl(customBaseUrl, "app")
if (!baseUrl) {
throw new Error("Invalid Requesty base URL")
}
const authUrl = new URL("oauth/authorize", baseUrl)
authUrl.searchParams.set("callback_url", callbackUrl)
await openExternal(authUrl.toString())
return {}
export async function requestyAuthClicked(_: Controller, _req: StringRequest): Promise<Empty> {
return Empty.create({})
}
@@ -1,24 +1,7 @@
import { UserOrganizationUpdateRequest } from "@shared/proto/cline/account"
import { Empty } from "@shared/proto/cline/common"
import { fetchRemoteConfig } from "@/core/storage/remote-config/fetch"
import type { Controller } from "../index"
/**
* Handles setting the user's active organization
* @param controller The controller instance
* @param request UserOrganization to set as active
* @returns Empty response
*/
export async function setUserOrganization(controller: Controller, request: UserOrganizationUpdateRequest): Promise<Empty> {
try {
if (!controller.accountService) {
throw new Error("Account service not available")
}
// Switch to the specified organization using the account service
await controller.accountService.switchAccount(request.organizationId)
await fetchRemoteConfig(controller)
return {}
} catch (error) {
throw error
}
export async function setUserOrganization(_controller: Controller, _request: UserOrganizationUpdateRequest): Promise<Empty> {
return Empty.create({})
}
@@ -1,28 +1,10 @@
import { SubmitLimitIncreaseResponse } from "@shared/proto/cline/account"
import type { EmptyRequest } from "@shared/proto/cline/common"
import { Logger } from "@/shared/services/Logger"
import type { Controller } from "../index"
/**
* Submits a spend limit increase request to the user's org admin.
* Called when the user clicks "Request Increase" on the SpendLimitError component.
* @param controller The controller instance
* @param _request Empty request
* @returns SubmitLimitIncreaseResponse indicating success or failure
*/
export async function submitLimitIncreaseRequest(
controller: Controller,
_controller: Controller,
_request: EmptyRequest,
): Promise<SubmitLimitIncreaseResponse> {
try {
if (!controller.accountService) {
throw new Error("Account service not available")
}
await controller.accountService.submitLimitIncreaseRequestRPC()
return SubmitLimitIncreaseResponse.create({ success: true })
} catch (error) {
Logger.error(`Failed to submit limit increase request: ${error}`)
throw error
}
return SubmitLimitIncreaseResponse.create({})
}
@@ -1,13 +1,12 @@
import { AuthService } from "@/sdk/auth-service"
import { AuthState, EmptyRequest } from "@/shared/proto/index.cline"
import { Controller } from ".."
import { StreamingResponseHandler } from "../grpc-handler"
export async function subscribeToAuthStatusUpdate(
controller: Controller,
request: EmptyRequest,
responseStream: StreamingResponseHandler<AuthState>,
requestId?: string,
_controller: Controller,
_request: EmptyRequest,
_responseStream: StreamingResponseHandler<AuthState>,
_requestId?: string,
): Promise<void> {
return AuthService.getInstance().subscribeToAuthStatusUpdate(controller, request, responseStream, requestId)
return
}
@@ -1,5 +1,3 @@
import { discoverChromeInstances } from "@services/browser/BrowserDiscovery"
import { BrowserSession } from "@services/browser/BrowserSession"
import { BrowserConnection } from "@shared/proto/cline/browser"
import { EmptyRequest } from "@shared/proto/cline/common"
import { Controller } from "../index"
@@ -10,36 +8,6 @@ import { Controller } from "../index"
* @param request The request message
* @returns The browser connection result
*/
export async function discoverBrowser(controller: Controller, _request: EmptyRequest): Promise<BrowserConnection> {
try {
const discoveredHost = await discoverChromeInstances()
if (discoveredHost) {
// Don't update the remoteBrowserHost state when auto-discovering
// This way we don't override the user's preference
// Test the connection to get the endpoint
const browserSession = new BrowserSession(controller.stateManager)
const result = await browserSession.testConnection(discoveredHost)
return BrowserConnection.create({
success: true,
message: `Successfully discovered and connected to Chrome at ${discoveredHost}`,
endpoint: result.endpoint || "",
})
} else {
return BrowserConnection.create({
success: false,
message:
"No Chrome instances found. Make sure Chrome is running with remote debugging enabled (--remote-debugging-port=9222).",
endpoint: "",
})
}
} catch (error) {
return BrowserConnection.create({
success: false,
message: `Error discovering browser: ${error instanceof Error ? error.message : String(error)}`,
endpoint: "",
})
}
export async function discoverBrowser(_controller: Controller, _request: EmptyRequest): Promise<BrowserConnection> {
return BrowserConnection.create({})
}
@@ -1,6 +1,5 @@
import { BrowserConnectionInfo } from "@shared/proto/cline/browser"
import { EmptyRequest } from "@shared/proto/cline/common"
import { Logger } from "@/shared/services/Logger"
import { Controller } from "../index"
/**
@@ -9,39 +8,6 @@ import { Controller } from "../index"
* @param request The request message
* @returns The browser connection info
*/
export async function getBrowserConnectionInfo(controller: Controller, _: EmptyRequest): Promise<BrowserConnectionInfo> {
try {
// Get browser settings from extension state
const browserSettings = controller.stateManager.getGlobalSettingsKey("browserSettings")
// Check if there's an active browser session by using the controller's handleWebviewMessage approach
// This is similar to what's done in controller/index.ts for the "getBrowserConnectionInfo" message
if (controller.task?.browserSession) {
// Access the browser session through the controller's task property
// Using indexer notation to access private property
const browserSession = controller.task.browserSession
const connectionInfo = browserSession.getConnectionInfo()
// Convert from BrowserSession.BrowserConnectionInfo to proto.BrowserConnectionInfo
return BrowserConnectionInfo.create({
isConnected: connectionInfo.isConnected,
isRemote: connectionInfo.isRemote,
host: connectionInfo.host || "", // Ensure host is never undefined
})
}
// Fallback to browser settings if no active browser session
return BrowserConnectionInfo.create({
isConnected: false,
isRemote: !!browserSettings.remoteBrowserEnabled,
host: browserSettings.remoteBrowserHost || "",
})
} catch (error: unknown) {
Logger.error("Error getting browser connection info:", error)
return BrowserConnectionInfo.create({
isConnected: false,
isRemote: false,
host: "",
})
}
export async function getBrowserConnectionInfo(_controller: Controller, _: EmptyRequest): Promise<BrowserConnectionInfo> {
return BrowserConnectionInfo.create({})
}
@@ -1,7 +1,5 @@
import { ChromePath } from "@shared/proto/cline/browser"
import { EmptyRequest } from "@shared/proto/cline/common"
import { Logger } from "@/shared/services/Logger"
import { BrowserSession } from "../../../services/browser/BrowserSession"
import { Controller } from "../index"
/**
@@ -10,20 +8,6 @@ import { Controller } from "../index"
* @param request The empty request message
* @returns The detected Chrome path and whether it's bundled
*/
export async function getDetectedChromePath(controller: Controller, _: EmptyRequest): Promise<ChromePath> {
try {
const browserSession = new BrowserSession(controller.stateManager)
const result = await browserSession.getDetectedChromePath()
return ChromePath.create({
path: result.path,
isBundled: result.isBundled,
})
} catch (error) {
Logger.error("Error getting detected Chrome path:", error)
return ChromePath.create({
path: "",
isBundled: false,
})
}
export async function getDetectedChromePath(_controller: Controller, _: EmptyRequest): Promise<ChromePath> {
return ChromePath.create({})
}
@@ -1,5 +1,4 @@
import { EmptyRequest, String as StringMessage } from "@shared/proto/cline/common"
import { BrowserSession } from "../../../services/browser/BrowserSession"
import { Controller } from "../index"
/**
@@ -8,17 +7,6 @@ import { Controller } from "../index"
* @param request The empty request message
* @returns The browser relaunch result as a string message
*/
export async function relaunchChromeDebugMode(controller: Controller, _: EmptyRequest): Promise<StringMessage> {
try {
const browserSession = new BrowserSession(controller.stateManager)
// Relaunch Chrome in debug mode
await browserSession.relaunchChromeDebugMode(controller)
// The actual result will be sent via the ProtoBus in the BrowserSession.relaunchChromeDebugMode method
// Here we just return a message as a placeholder
return { value: "Chrome relaunch initiated" }
} catch (error) {
throw new Error(`Error relaunching Chrome: ${error instanceof Error ? error.message : globalThis.String(error)}`)
}
export async function relaunchChromeDebugMode(_controller: Controller, _: EmptyRequest): Promise<StringMessage> {
return StringMessage.create({})
}
@@ -1,5 +1,3 @@
import { discoverChromeInstances } from "@services/browser/BrowserDiscovery"
import { BrowserSession } from "@services/browser/BrowserSession"
import { BrowserConnection } from "@shared/proto/cline/browser"
import { StringRequest } from "@shared/proto/cline/common"
import { Controller } from "../index"
@@ -10,52 +8,6 @@ import { Controller } from "../index"
* @param request The request message
* @returns The browser connection result
*/
export async function testBrowserConnection(controller: Controller, request: StringRequest): Promise<BrowserConnection> {
try {
const browserSession = new BrowserSession(controller.stateManager)
const text = request.value || ""
// If no text is provided, try auto-discovery
if (!text) {
try {
const discoveredHost = await discoverChromeInstances()
if (discoveredHost) {
// Test the connection to the discovered host
const result = await browserSession.testConnection(discoveredHost)
return BrowserConnection.create({
success: result.success,
message: `Auto-discovered and tested connection to Chrome at ${discoveredHost}: ${result.message}`,
endpoint: result.endpoint || "",
})
} else {
return BrowserConnection.create({
success: false,
message:
"No Chrome instances found on the network. Make sure Chrome is running with remote debugging enabled (--remote-debugging-port=9222).",
endpoint: "",
})
}
} catch (error) {
return BrowserConnection.create({
success: false,
message: `Error during auto-discovery: ${error instanceof Error ? error.message : String(error)}`,
endpoint: "",
})
}
} else {
// Test the provided URL
const result = await browserSession.testConnection(text)
return BrowserConnection.create({
success: result.success,
message: result.message,
endpoint: result.endpoint || "",
})
}
} catch (error) {
return BrowserConnection.create({
success: false,
message: `Error testing connection: ${error instanceof Error ? error.message : String(error)}`,
endpoint: "",
})
}
export async function testBrowserConnection(_controller: Controller, _request: StringRequest): Promise<BrowserConnection> {
return BrowserConnection.create({})
}
@@ -1,9 +1,6 @@
import { Empty, Int64Request } from "@shared/proto/cline/common"
import { Controller } from ".."
export async function checkpointDiff(controller: Controller, request: Int64Request): Promise<Empty> {
if (request.value) {
await controller.task?.checkpointManager?.presentMultifileDiff?.(request.value, false)
}
export async function checkpointDiff(_controller: Controller, _request: Int64Request): Promise<Empty> {
return Empty.create()
}
@@ -1,34 +1,7 @@
import { CheckpointRestoreRequest } from "@shared/proto/cline/checkpoints"
import { Empty } from "@shared/proto/cline/common"
import pWaitFor from "p-wait-for"
import { HostProvider } from "@/hosts/host-provider"
import { ShowMessageType } from "@/shared/proto/index.host"
import { Logger } from "@/shared/services/Logger"
import { ClineCheckpointRestore } from "../../../shared/WebviewMessage"
import { Controller } from ".."
export async function checkpointRestore(controller: Controller, request: CheckpointRestoreRequest): Promise<Empty> {
await controller.cancelTask() // we cannot alter message history say if the task is active, as it could be in the middle of editing a file or running a command, which expect the ask to be responded to rather than being superseded by a new message eg add deleted_api_reqs
if (request.number) {
// wait for messages to be loaded
await pWaitFor(() => controller.task?.taskState.isInitialized === true, {
timeout: 3_000,
}).catch((error) => {
Logger.log("Failed to init new Cline instance to restore checkpoint", error)
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message: "Failed to restore checkpoint",
})
throw error
})
// NOTE: cancelTask awaits abortTask, which awaits diffViewProvider.revertChanges, which reverts any edited files, allowing us to reset to a checkpoint rather than running into a state where the revertChanges function is called alongside or after the checkpoint reset
await controller.task?.checkpointManager?.restoreCheckpoint(
request.number,
request.restoreType as ClineCheckpointRestore,
request.offset,
)
}
export async function checkpointRestore(_controller: Controller, _request: CheckpointRestoreRequest): Promise<Empty> {
return Empty.create({})
}
@@ -1,18 +1,7 @@
import { PathHashMap } from "@shared/proto/cline/checkpoints"
import { StringArrayRequest } from "@shared/proto/cline/common"
import { hashWorkingDir } from "@/integrations/checkpoints/CheckpointUtils"
import { Controller } from ".."
export async function getCwdHash(_controller: Controller, request: StringArrayRequest): Promise<PathHashMap> {
const pathHash: Record<string, string> = {}
for (const path of request.value) {
try {
pathHash[path] = hashWorkingDir(path)
} catch {
pathHash[path] = ""
}
}
return PathHashMap.create({ pathHash })
export async function getCwdHash(_controller: Controller, _request: StringArrayRequest): Promise<PathHashMap> {
return PathHashMap.create({})
}
@@ -1,61 +1,12 @@
import { CheckpointEvent, CheckpointSubscriptionRequest } from "@shared/proto/cline/checkpoints"
import { getRequestRegistry, StreamingResponseHandler } from "../grpc-handler"
import { StreamingResponseHandler } from "../grpc-handler"
import { Controller } from "../index"
/**
* Track active checkpoint subscriptions per workspace.
* Map structure: cwdHash -> Set of response streams
*/
const activeCheckpointSubscriptions = new Map<string, Set<StreamingResponseHandler<CheckpointEvent>>>()
/**
* Subscribe to checkpoint events for a specific workspace.
*
* Clients receive real-time notifications about checkpoint operations:
* - Shadow git initialization
* - Commit creation
* - Checkpoint restoration
*
* Each operation generates two events (start and completion).
*
* @param controller The controller instance
* @param request The subscription request containing cwdHash
* @param responseStream The streaming response handler
* @param requestId The ID of the request
*/
export async function subscribeToCheckpoints(
_controller: Controller,
request: CheckpointSubscriptionRequest,
responseStream: StreamingResponseHandler<CheckpointEvent>,
requestId?: string,
_request: CheckpointSubscriptionRequest,
_responseStream: StreamingResponseHandler<CheckpointEvent>,
_requestId?: string,
): Promise<void> {
const { cwdHash } = request
if (!activeCheckpointSubscriptions.has(cwdHash)) {
activeCheckpointSubscriptions.set(cwdHash, new Set())
}
const subscriptions = activeCheckpointSubscriptions.get(cwdHash)
if (!subscriptions) {
throw new Error(`Failed to retrieve subscriptions for cwdHash: ${cwdHash}`)
}
subscriptions.add(responseStream)
// Register cleanup when the connection is closed
const cleanup = () => {
subscriptions.delete(responseStream)
if (subscriptions.size === 0) {
activeCheckpointSubscriptions.delete(cwdHash)
}
}
if (requestId) {
getRequestRegistry().registerRequest(
requestId,
cleanup,
{ type: "checkpoint_subscription" as const, cwdHash },
responseStream,
)
}
return
}
@@ -1,46 +1,8 @@
import { getFileMentionFromPath } from "@/core/mentions"
import { singleFileDiagnosticsToProblemsString } from "@/integrations/diagnostics"
import { telemetryService } from "@/services/telemetry"
import { CommandContext, Empty } from "@/shared/proto/index.cline"
import { Logger } from "@/shared/services/Logger"
import { Controller } from "../index"
import { sendAddToInputEvent } from "../ui/subscribeToAddToInput"
// 'Add to Cline' context menu in editor and code action
// Inserts the selected code into the chat.
export async function addToCline(controller: Controller, request: CommandContext, notebookContext?: string): Promise<Empty> {
if (!request.selectedText?.trim() && !notebookContext) {
Logger.log("❌ No text selected and no notebook context - returning early")
return {}
}
const filePath = request.filePath || ""
const fileMention = await getFileMentionFromPath(filePath)
let input = `${fileMention}\n\`\`\`\n${request.selectedText}\n\`\`\``
// Add notebook context if provided (includes cell JSON)
if (notebookContext) {
Logger.log("Adding notebook context for enhanced editing")
input += `\n${notebookContext}`
}
if (request.diagnostics.length) {
const problemsString = await singleFileDiagnosticsToProblemsString(filePath, request.diagnostics)
input += `\nProblems:\n${problemsString}`
}
// Notebooks send immediately, regular adds just fill input
if (notebookContext && controller.task) {
await controller.task.handleWebviewAskResponse("messageResponse", input)
} else if (notebookContext) {
await controller.initTask(input)
} else {
await sendAddToInputEvent(input)
}
Logger.log("addToCline", request.selectedText, filePath, request.language)
telemetryService.captureButtonClick("codeAction_addToChat", controller.task?.ulid)
return {}
export async function addToCline(_controller: Controller, _request: CommandContext, _notebookContext?: string): Promise<Empty> {
return Empty.create({})
}
@@ -1,37 +1,10 @@
import { getFileMentionFromPath } from "@/core/mentions"
import { HostProvider } from "@/hosts/host-provider"
import { telemetryService } from "@/services/telemetry"
import { CommandContext, Empty } from "@/shared/proto/index.cline"
import { ShowMessageType } from "@/shared/proto/index.host"
import { Logger } from "@/shared/services/Logger"
import { Controller } from "../index"
export async function explainWithCline(
controller: Controller,
request: CommandContext,
notebookContext?: string,
_controller: Controller,
_request: CommandContext,
_notebookContext?: string,
): Promise<Empty> {
if (!request.selectedText?.trim() && !notebookContext) {
HostProvider.window.showMessage({
type: ShowMessageType.INFORMATION,
message: "Please select some code to explain.",
})
return {}
}
const filePath = request.filePath || ""
const fileMention = await getFileMentionFromPath(filePath)
let prompt = `Explain the following code from ${fileMention}:
\`\`\`${request.language}\n${request.selectedText}\n\`\`\``
// Add notebook context if provided (includes cell JSON)
if (notebookContext) {
Logger.log("Adding notebook context to explainWithCline task")
prompt += notebookContext
}
await controller.initTask(prompt)
telemetryService.captureButtonClick("codeAction_explainCode", controller.task?.ulid)
return {}
return Empty.create({})
}
@@ -1,21 +1,6 @@
import { getFileMentionFromPath } from "@/core/mentions"
import { singleFileDiagnosticsToProblemsString } from "@/integrations/diagnostics"
import { telemetryService } from "@/services/telemetry"
import { CommandContext, Empty } from "@/shared/proto/index.cline"
import { Logger } from "@/shared/services/Logger"
import { Controller } from "../index"
export async function fixWithCline(controller: Controller, request: CommandContext): Promise<Empty> {
const filePath = request.filePath || ""
const fileMention = await getFileMentionFromPath(filePath)
const problemsString = await singleFileDiagnosticsToProblemsString(filePath, request.diagnostics)
await controller.initTask(
`Fix the following code in ${fileMention}
\`\`\`\n${request.selectedText}\n\`\`\`\n\nProblems:\n${problemsString}`,
)
Logger.log("fixWithCline", request.selectedText, request.filePath, request.language, problemsString)
telemetryService.captureButtonClick("codeAction_fixWithCline", controller.task?.ulid)
return {}
export async function fixWithCline(_controller: Controller, _request: CommandContext): Promise<Empty> {
return Empty.create({})
}
@@ -1,46 +1,10 @@
import { getFileMentionFromPath } from "@/core/mentions"
import { HostProvider } from "@/hosts/host-provider"
import { telemetryService } from "@/services/telemetry"
import { CommandContext, Empty } from "@/shared/proto/index.cline"
import { ShowMessageType } from "@/shared/proto/index.host"
import { Logger } from "@/shared/services/Logger"
import { Controller } from "../index"
export async function improveWithCline(
controller: Controller,
request: CommandContext,
notebookContext?: string,
_controller: Controller,
_request: CommandContext,
_notebookContext?: string,
): Promise<Empty> {
if (!request.selectedText?.trim() && !notebookContext) {
Logger.log("❌ No text selected and no notebook context")
HostProvider.window.showMessage({
type: ShowMessageType.INFORMATION,
message: "Please select some code to improve.",
})
return {}
}
const filePath = request.filePath || ""
const fileMention = await getFileMentionFromPath(filePath)
const hasSelectedText = request.selectedText?.trim()
// Build prompt
let prompt = hasSelectedText
? `Improve the following code from ${fileMention} (e.g., suggest refactorings, optimizations, or better practices):\n\`\`\`${request.language}\n${request.selectedText}\n\`\`\``
: `Improve the current code in the current notebook cell from ${fileMention}. Suggest refactorings, optimizations, or better practices based on the cell context.`
if (notebookContext) {
Logger.log("Adding notebook context to improveWithCline task")
prompt += `\n${notebookContext}`
}
// Send: notebooks go to existing task if available, non-notebooks always create new task
if (notebookContext && controller.task) {
await controller.task.handleWebviewAskResponse("messageResponse", prompt)
} else {
await controller.initTask(prompt)
}
telemetryService.captureButtonClick("codeAction_improveCode", controller.task?.ulid)
return {}
return Empty.create({})
}
@@ -1,100 +0,0 @@
import { afterEach, beforeEach, describe, it, mock } from "bun:test"
import { Controller } from "@core/controller"
import { BooleanResponse, StringRequest } from "@shared/proto/cline/common"
import * as actualPathUtils from "@utils/path"
import { expect } from "chai"
import * as sinon from "sinon"
// bun loads real ESM, so sinon cannot stub the `@utils/path` namespace export
// ("ES Modules cannot be stubbed"). Inject a module-level sinon stub via
// mock.module so the full sinon stub API keeps working.
const getWorkspacePathStub: sinon.SinonStub = sinon.stub()
const pathUtilsMock = () => ({ ...actualPathUtils, getWorkspacePath: getWorkspacePathStub })
mock.module("@utils/path", pathUtilsMock)
mock.module("@/utils/path", pathUtilsMock)
import { ifFileExistsRelativePath } from "../ifFileExistsRelativePath"
describe("ifFileExistsRelativePath", () => {
let mockController: Controller
beforeEach(() => {
// Create a mock controller
mockController = {} as any
// Reset the module-level getWorkspacePath stub
getWorkspacePathStub.reset()
})
afterEach(() => {
getWorkspacePathStub.reset()
})
it("should return BooleanResponse with boolean value", async () => {
getWorkspacePathStub.resolves("/workspace")
const request = StringRequest.create({
value: "src/test.ts",
})
const result = await ifFileExistsRelativePath(mockController, request)
// The result should be a BooleanResponse object
expect(result).to.have.property("value")
expect(typeof result.value).to.equal("boolean")
})
it("should return false when no workspace path is available", async () => {
const noWorkspaceScenarios = [null, undefined]
for (const workspaceValue of noWorkspaceScenarios) {
getWorkspacePathStub.resolves(workspaceValue)
const request = StringRequest.create({
value: "src/test.ts",
})
const result = await ifFileExistsRelativePath(mockController, request)
expect(result).to.deep.equal(BooleanResponse.create({ value: false }))
}
})
it("should return false when path is invalid", async () => {
getWorkspacePathStub.resolves("/workspace")
const invalidPaths = ["", undefined]
for (const invalidPath of invalidPaths) {
const request = StringRequest.create({
value: invalidPath,
})
const result = await ifFileExistsRelativePath(mockController, request)
expect(result).to.deep.equal(BooleanResponse.create({ value: false }))
}
})
it("should handle valid relative paths correctly", async () => {
getWorkspacePathStub.resolves("/workspace")
// Test with valid workspace-relative paths only
const validPaths = ["src/file.ts", "./src/file.ts", "package.json", ".gitignore", "src/components/ui/Button/Button.tsx"]
for (const testPath of validPaths) {
const request = StringRequest.create({
value: testPath,
})
const result = await ifFileExistsRelativePath(mockController, request)
// Each should return a BooleanResponse
expect(result).to.have.property("value")
expect(typeof result.value).to.equal("boolean")
}
// Verify that getWorkspacePath was called for each path
expect(getWorkspacePathStub.callCount).to.equal(validPaths.length)
})
})
@@ -1,130 +0,0 @@
import { afterEach, beforeEach, describe, it, mock } from "bun:test"
import { Controller } from "@core/controller"
import * as actualOpenFileIntegration from "@integrations/misc/open-file"
import { Empty, StringRequest } from "@shared/proto/cline/common"
import * as actualPathUtils from "@utils/path"
import { expect } from "chai"
import * as path from "path"
import * as sinon from "sinon"
import { Logger } from "@/shared/services/Logger"
// bun loads real ESM, so sinon cannot stub the `@integrations/misc/open-file`
// and `@utils/path` namespace exports ("ES Modules cannot be stubbed"). Inject
// module-level sinon stubs via mock.module so the full sinon stub API keeps
// working. (`Logger` is a class with static methods and is still sinon-stubbed
// directly below.)
const openFileIntegrationStub: sinon.SinonStub = sinon.stub()
const getWorkspacePathStub: sinon.SinonStub = sinon.stub()
const openFileMock = () => ({ ...actualOpenFileIntegration, openFile: openFileIntegrationStub })
const pathUtilsMock = () => ({ ...actualPathUtils, getWorkspacePath: getWorkspacePathStub })
mock.module("@integrations/misc/open-file", openFileMock)
mock.module("@utils/path", pathUtilsMock)
mock.module("@/utils/path", pathUtilsMock)
import { openFileRelativePath } from "../openFileRelativePath"
describe("openFileRelativePath", () => {
let sandbox: sinon.SinonSandbox
let mockController: Controller
let consoleErrorStub: sinon.SinonStub
beforeEach(() => {
sandbox = sinon.createSandbox()
// Create a mock controller
mockController = {} as any
// Reset the module-level sinon stubs (injected via mock.module above)
openFileIntegrationStub.reset()
getWorkspacePathStub.reset()
// Stub console.error to prevent test output pollution
consoleErrorStub = sandbox.stub(Logger, "error")
})
afterEach(() => {
sandbox.restore()
openFileIntegrationStub.reset()
getWorkspacePathStub.reset()
})
it("should return Empty response on successful execution", async () => {
getWorkspacePathStub.resolves("/workspace")
const request = StringRequest.create({
value: "src/test.ts",
})
const result = await openFileRelativePath(mockController, request)
expect(result).to.deep.equal(Empty.create())
})
it("should call openFileIntegration with absolute path when relative path is provided", async () => {
const workspacePath = "/workspace"
const relativePath = "src/components/Test.tsx"
const expectedAbsolutePath = path.resolve(workspacePath, relativePath)
getWorkspacePathStub.resolves(workspacePath)
const request = StringRequest.create({
value: relativePath,
})
await openFileRelativePath(mockController, request)
expect(openFileIntegrationStub.calledOnceWith(expectedAbsolutePath)).to.be.true
})
it("should not call openFileIntegration when path is invalid", async () => {
getWorkspacePathStub.resolves("/workspace")
const invalidPaths = ["", undefined]
for (const invalidPath of invalidPaths) {
const request = StringRequest.create({
value: invalidPath,
})
await openFileRelativePath(mockController, request)
expect(openFileIntegrationStub.called).to.be.false
openFileIntegrationStub.resetHistory()
}
})
it("should return Empty and log error when no workspace path is available", async () => {
const noWorkspaceScenarios = [null, undefined]
for (const workspaceValue of noWorkspaceScenarios) {
getWorkspacePathStub.resolves(workspaceValue)
consoleErrorStub.resetHistory()
const request = StringRequest.create({
value: "src/test.ts",
})
const result = await openFileRelativePath(mockController, request)
expect(result).to.deep.equal(Empty.create())
expect(consoleErrorStub.called).to.be.true
expect(openFileIntegrationStub.called).to.be.false
}
})
it("should handle nested directory paths", async () => {
const workspacePath = "/workspace"
const relativePath = "src/components/ui/Button/Button.tsx"
const expectedAbsolutePath = path.resolve(workspacePath, relativePath)
getWorkspacePathStub.resolves(workspacePath)
const request = StringRequest.create({
value: relativePath,
})
await openFileRelativePath(mockController, request)
expect(openFileIntegrationStub.calledOnceWith(expectedAbsolutePath)).to.be.true
})
})
@@ -1,21 +1,6 @@
import { Empty, StringRequest } from "@shared/proto/cline/common"
import { Logger } from "@/shared/services/Logger"
import { writeTextToClipboard } from "@/utils/env"
import { Controller } from ".."
/**
* Copies text to the system clipboard
* @param controller The controller instance
* @param request The request containing the text to copy
* @returns Empty response
*/
export async function copyToClipboard(_controller: Controller, request: StringRequest): Promise<Empty> {
try {
if (request.value) {
await writeTextToClipboard(request.value)
}
} catch (error) {
Logger.error("Error copying to clipboard:", error)
}
return Empty.create()
export async function copyToClipboard(_controller: Controller, _request: StringRequest): Promise<Empty> {
return Empty.create({})
}
@@ -1,56 +1,10 @@
import { CreateHookRequest, CreateHookResponse } from "@shared/proto/cline/file"
import fs from "fs/promises"
import path from "path"
import { HookDiscoveryCache } from "../../hooks/HookDiscoveryCache"
import { getHookTemplate } from "../../hooks/templates"
import { isValidHookType, resolveHooksDirectory, VALID_HOOK_TYPES } from "../../hooks/utils"
import { Controller } from ".."
import { refreshHooks } from "./refreshHooks"
export async function createHook(
controller: Controller,
request: CreateHookRequest,
globalHooksDirOverride?: string,
_controller: Controller,
_request: CreateHookRequest,
_globalHooksDirOverride?: string,
): Promise<CreateHookResponse> {
const { hookName, isGlobal, workspaceName } = request
// Validate hook name is one of the valid hook types
if (!isValidHookType(hookName)) {
throw new Error(`Invalid hook type: "${hookName}". Valid hook types are: ${VALID_HOOK_TYPES.join(", ")}`)
}
// Determine target directory
const hooksDir = await resolveHooksDirectory(isGlobal, workspaceName, globalHooksDirOverride)
// Ensure directory exists
await fs.mkdir(hooksDir, { recursive: true })
const hookFileName = process.platform === "win32" ? `${hookName}.ps1` : hookName
const hookPath = path.join(hooksDir, hookFileName)
// Check if already exists
try {
await fs.stat(hookPath)
throw new Error(`Hook ${hookName} already exists at ${hookPath}`)
} catch (error) {
// Good - file doesn't exist yet
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error
}
}
// Get template content
const templateContent = getHookTemplate(hookName)
// Write file WITHOUT executable permissions (644) so hook is toggled off by default
// User can enable it later when they're ready
const mode = 0o644
await fs.writeFile(hookPath, templateContent, { mode })
// Invalidate hook discovery cache
await HookDiscoveryCache.getInstance().invalidateAll()
// Return updated hooks state
const hooksToggles = await refreshHooks(controller, undefined, globalHooksDirOverride)
return CreateHookResponse.create({ hooksToggles })
return CreateHookResponse.create({})
}
@@ -1,75 +1,6 @@
import { refreshClineRulesToggles } from "@core/context/instructions/user-instructions/cline-rules"
import { createRuleFile as createRuleFileImpl } from "@core/context/instructions/user-instructions/rule-helpers"
import { getWorkspaceBasename } from "@core/workspace"
import { RuleFile, RuleFileRequest } from "@shared/proto/cline/file"
import { refreshWorkflowToggles } from "@/core/context/instructions/user-instructions/workflows"
import { HostProvider } from "@/hosts/host-provider"
import { ShowMessageType } from "@/shared/proto/host/window"
import { Logger } from "@/shared/services/Logger"
import { getCwd, getDesktopDir } from "@/utils/path"
import { Controller } from ".."
import { openFile } from "./openFile"
/**
* Creates a rule file in either global or workspace rules directory
* @param controller The controller instance
* @param request The request containing filename and isGlobal flag
* @returns Result with file path and display name
* @throws Error if operation fails
*/
export async function createRuleFile(controller: Controller, request: RuleFileRequest): Promise<RuleFile> {
if (
typeof request.isGlobal !== "boolean" ||
!request.filename ||
typeof request.filename !== "string" ||
!request.type ||
typeof request.type !== "string"
) {
Logger.error("createRuleFile: Missing or invalid parameters", {
isGlobal: typeof request.isGlobal === "boolean" ? request.isGlobal : `Invalid: ${typeof request.isGlobal}`,
filename: typeof request.filename === "string" ? request.filename : `Invalid: ${typeof request.filename}`,
type: typeof request.type === "string" ? request.type : `Invalid: ${typeof request.type}`,
})
throw new Error("Missing or invalid parameters")
}
const cwd = await getCwd(getDesktopDir())
const { filePath, fileExists } = await createRuleFileImpl(request.isGlobal, request.filename, cwd, request.type)
if (!filePath) {
throw new Error("Failed to create file.")
}
const fileTypeName = request.type === "workflow" ? "workflow" : "rule"
if (fileExists) {
const message = `${fileTypeName} file "${request.filename}" already exists.`
HostProvider.window.showMessage({
type: ShowMessageType.WARNING,
message,
})
// Still open it for editing
await openFile(controller, { value: filePath })
} else {
if (request.type === "workflow") {
await refreshWorkflowToggles(controller, cwd)
} else {
await refreshClineRulesToggles(controller, cwd)
}
await controller.postStateToWebview()
await openFile(controller, { value: filePath })
const message = `Created new ${request.isGlobal ? "global" : "workspace"} ${fileTypeName} file: ${request.filename}`
HostProvider.window.showMessage({
type: ShowMessageType.INFORMATION,
message,
})
}
return RuleFile.create({
filePath: filePath,
displayName: getWorkspaceBasename(filePath, "Controller.createRuleFile"),
alreadyExists: fileExists,
})
export async function createRuleFile(_controller: Controller, _request: RuleFileRequest): Promise<RuleFile> {
return RuleFile.create({})
}
@@ -1,109 +1,6 @@
import { ensureAgentSkillsDirectoryExists } from "@core/storage/disk"
import { CreateSkillRequest, SkillsToggles } from "@shared/proto/cline/file"
import fs from "fs/promises"
import path from "path"
import { HostProvider } from "@/hosts/host-provider"
import { ShowMessageType } from "@/shared/proto/host/window"
import { Logger } from "@/shared/services/Logger"
import { fileExistsAtPath } from "@/utils/fs"
import { Controller } from ".."
import { openFile } from "./openFile"
const SKILL_TEMPLATE = `---
name: {{SKILL_NAME}}
description: Brief description of what this skill does
---
# {{SKILL_NAME}}
Instructions for the AI agent...
## Usage
Describe when and how to use this skill.
## Steps
1. First step
2. Second step
3. Third step
`
/**
* Creates a new skill from template
* @param controller The controller instance
* @param request The request containing skill name and isGlobal flag
* @returns The updated skills toggles
*/
export async function createSkillFile(controller: Controller, request: CreateSkillRequest): Promise<SkillsToggles> {
const { skillName, isGlobal } = request
if (!skillName || typeof skillName !== "string" || typeof isGlobal !== "boolean") {
Logger.error("createSkillFile: Missing or invalid parameters", {
skillName: typeof skillName === "string" ? skillName : `Invalid: ${typeof skillName}`,
isGlobal: typeof isGlobal === "boolean" ? isGlobal : `Invalid: ${typeof isGlobal}`,
})
throw new Error("Missing or invalid parameters for createSkillFile")
}
// Validate skill name (must be valid directory name)
const sanitizedName = skillName.replace(/[^a-zA-Z0-9_-]/g, "-").toLowerCase()
if (!sanitizedName) {
throw new Error("Invalid skill name")
}
let skillDir: string
if (isGlobal) {
// Create in ~/.agents/skills using the unified helper
const globalSkillsDir = await ensureAgentSkillsDirectoryExists({ isGlobal: true })
skillDir = path.join(globalSkillsDir, sanitizedName)
} else {
const workspacePaths = await HostProvider.workspace.getWorkspacePaths({})
const primaryWorkspace = workspacePaths.paths[0]
if (!primaryWorkspace) {
throw new Error("No workspace folder open")
}
// Create in .agents/skills using the unified helper
const localSkillsDir = await ensureAgentSkillsDirectoryExists({
isGlobal: false,
workspacePath: primaryWorkspace,
})
skillDir = path.join(localSkillsDir, sanitizedName)
}
// Check if skill already exists
if (await fileExistsAtPath(skillDir)) {
await HostProvider.window.showMessage({
type: ShowMessageType.WARNING,
message: `Skill "${sanitizedName}" already exists`,
})
// Return current toggles
const globalToggles = controller.stateManager.getGlobalSettingsKey("globalSkillsToggles") || {}
const localToggles = controller.stateManager.getWorkspaceStateKey("localSkillsToggles") || {}
return SkillsToggles.create({
globalSkillsToggles: globalToggles,
localSkillsToggles: localToggles,
})
}
// Create skill directory
await fs.mkdir(skillDir, { recursive: true })
// Create SKILL.md from template
const skillMdPath = path.join(skillDir, "SKILL.md")
const content = SKILL_TEMPLATE.replace(/\{\{SKILL_NAME\}\}/g, sanitizedName)
await fs.writeFile(skillMdPath, content, "utf-8")
// Open the file for editing
await openFile(controller, { value: skillMdPath })
// Return current toggles (new skill defaults to enabled)
const globalToggles = controller.stateManager.getGlobalSettingsKey("globalSkillsToggles") || {}
const localToggles = controller.stateManager.getWorkspaceStateKey("localSkillsToggles") || {}
return SkillsToggles.create({
globalSkillsToggles: globalToggles,
localSkillsToggles: localToggles,
})
export async function createSkillFile(_controller: Controller, _request: CreateSkillRequest): Promise<SkillsToggles> {
return SkillsToggles.create({})
}
@@ -1,33 +1,10 @@
import { DeleteHookRequest, DeleteHookResponse } from "@shared/proto/cline/file"
import fs from "fs/promises"
import { HookDiscoveryCache } from "../../hooks/HookDiscoveryCache"
import { resolveExistingHookPath, resolveHooksDirectory } from "../../hooks/utils"
import { Controller } from ".."
import { refreshHooks } from "./refreshHooks"
export async function deleteHook(
controller: Controller,
request: DeleteHookRequest,
globalHooksDirOverride?: string,
_controller: Controller,
_request: DeleteHookRequest,
_globalHooksDirOverride?: string,
): Promise<DeleteHookResponse> {
const { hookName, isGlobal, workspaceName } = request
// Determine hook path
const hooksDir = await resolveHooksDirectory(isGlobal, workspaceName, globalHooksDirOverride)
const hookPath = await resolveExistingHookPath(hooksDir, hookName)
// Verify hook exists before attempting deletion
if (!hookPath) {
throw new Error(`Hook ${hookName} does not exist in ${hooksDir}`)
}
// Delete the hook file
await fs.unlink(hookPath)
// Invalidate hook discovery cache
await HookDiscoveryCache.getInstance().invalidateAll()
// Return updated hooks state
const hooksToggles = await refreshHooks(controller, undefined, globalHooksDirOverride)
return DeleteHookResponse.create({ hooksToggles })
return DeleteHookResponse.create({})
}
@@ -1,59 +1,6 @@
import { deleteRuleFile as deleteRuleFileImpl } from "@core/context/instructions/user-instructions/rule-helpers"
import { getWorkspaceBasename } from "@core/workspace"
import { RuleFile, RuleFileRequest } from "@shared/proto/cline/file"
import { HostProvider } from "@/hosts/host-provider"
import { ShowMessageType } from "@/shared/proto/host/window"
import { Logger } from "@/shared/services/Logger"
import { Controller } from ".."
/**
* Deletes a rule file from either global or workspace rules directory
* @param controller The controller instance
* @param request The request containing rule path and isGlobal flag
* @returns Result with file path and display name
* @throws Error if operation fails
*/
export async function deleteRuleFile(controller: Controller, request: RuleFileRequest): Promise<RuleFile> {
if (
typeof request.isGlobal !== "boolean" ||
typeof request.rulePath !== "string" ||
!request.rulePath ||
!request.type ||
typeof request.type !== "string"
) {
Logger.error("deleteRuleFile: Missing or invalid parameters", {
isGlobal: typeof request.isGlobal === "boolean" ? request.isGlobal : `Invalid: ${typeof request.isGlobal}`,
rulePath: typeof request.rulePath === "string" ? request.rulePath : `Invalid: ${typeof request.rulePath}`,
type: typeof request.type === "string" ? request.type : `Invalid: ${typeof request.type}`,
})
throw new Error("Missing or invalid parameters")
}
const result = await deleteRuleFileImpl(controller, request.rulePath, request.isGlobal, request.type)
if (!result.success) {
throw new Error(result.message || "Failed to delete rule file")
}
// we refresh inside of the deleteRuleFileImpl(..) call
//await refreshClineRulesToggles(controller.context, cwd)
//await refreshExternalRulesToggles(controller.context, cwd)
//await refreshWorkflowToggles(controller.context, cwd)
await controller.postStateToWebview()
const fileName = getWorkspaceBasename(request.rulePath, "Controller.deleteRuleFile")
const fileTypeName = request.type === "workflow" ? "workflow" : "rule"
const message = `${fileTypeName} file "${fileName}" deleted successfully`
HostProvider.window.showMessage({
type: ShowMessageType.INFORMATION,
message,
})
return RuleFile.create({
filePath: request.rulePath,
displayName: fileName,
alreadyExists: false,
})
export async function deleteRuleFile(_controller: Controller, _request: RuleFileRequest): Promise<RuleFile> {
return RuleFile.create({})
}
@@ -1,63 +1,6 @@
import { DeleteSkillRequest, SkillsToggles } from "@shared/proto/cline/file"
import fs from "fs/promises"
import path from "path"
import { Logger } from "@/shared/services/Logger"
import { fileExistsAtPath } from "@/utils/fs"
import { Controller } from ".."
/**
* Deletes an existing skill directory
* @param controller The controller instance
* @param request The request containing skill path and isGlobal flag
* @returns The updated skills toggles
*/
export async function deleteSkillFile(controller: Controller, request: DeleteSkillRequest): Promise<SkillsToggles> {
const { skillPath, isGlobal } = request
if (!skillPath || typeof skillPath !== "string" || typeof isGlobal !== "boolean") {
Logger.error("deleteSkillFile: Missing or invalid parameters", {
skillPath: typeof skillPath === "string" ? skillPath : `Invalid: ${typeof skillPath}`,
isGlobal: typeof isGlobal === "boolean" ? isGlobal : `Invalid: ${typeof isGlobal}`,
})
throw new Error("Missing or invalid parameters for deleteSkillFile")
}
// Get the skill directory (skillPath points to SKILL.md, so get parent)
const skillDir = path.dirname(skillPath)
// Verify the path exists
if (!(await fileExistsAtPath(skillDir))) {
Logger.warn(`deleteSkillFile: Skill directory not found: ${skillDir}`)
// Return current toggles anyway
const globalToggles = controller.stateManager.getGlobalSettingsKey("globalSkillsToggles") || {}
const localToggles = controller.stateManager.getWorkspaceStateKey("localSkillsToggles") || {}
return SkillsToggles.create({
globalSkillsToggles: globalToggles,
localSkillsToggles: localToggles,
})
}
// Delete the skill directory
await fs.rm(skillDir, { recursive: true, force: true })
// Remove from toggles
let globalToggles = controller.stateManager.getGlobalSettingsKey("globalSkillsToggles") || {}
let localToggles = controller.stateManager.getWorkspaceStateKey("localSkillsToggles") || {}
if (isGlobal) {
const { [skillPath]: _, ...remaining } = globalToggles
globalToggles = remaining
controller.stateManager.setGlobalState("globalSkillsToggles", globalToggles)
} else {
const { [skillPath]: _, ...remaining } = localToggles
localToggles = remaining
controller.stateManager.setWorkspaceState("localSkillsToggles", localToggles)
}
await controller.postStateToWebview()
return SkillsToggles.create({
globalSkillsToggles: globalToggles,
localSkillsToggles: localToggles,
})
export async function deleteSkillFile(_controller: Controller, _request: DeleteSkillRequest): Promise<SkillsToggles> {
return SkillsToggles.create({})
}
@@ -1,41 +1,6 @@
import { RelativePaths, RelativePathsRequest } from "@shared/proto/cline/file"
import * as path from "path"
import { URI } from "vscode-uri"
import { Logger } from "@/shared/services/Logger"
import { isDirectory } from "@/utils/fs"
import { asRelativePath } from "@/utils/path"
import { Controller } from ".."
/**
* Converts a list of URIs to workspace-relative paths
* @param controller The controller instance
* @param request The request containing URIs to convert
* @returns Response with resolved relative paths
*/
export async function getRelativePaths(_controller: Controller, request: RelativePathsRequest): Promise<RelativePaths> {
const result = []
for (const uriString of request.uris) {
try {
result.push(await getRelativePath(uriString))
} catch (error) {
Logger.error(`Error calculating relative path for ${uriString}:`, error)
}
}
return RelativePaths.create({ paths: result })
}
async function getRelativePath(uriString: string): Promise<string> {
const filePath = URI.parse(uriString, true).fsPath
const relativePath = await asRelativePath(filePath)
// If the path is still absolute, it's outside the workspace
if (path.isAbsolute(relativePath)) {
throw new Error(`Dropped file ${relativePath} is outside the workspace.`)
}
let result = "/" + relativePath.replace(/\\/g, "/")
if (await isDirectory(filePath)) {
result += "/"
}
return result
export async function getRelativePaths(_controller: Controller, _request: RelativePathsRequest): Promise<RelativePaths> {
return RelativePaths.create({})
}
@@ -1,39 +1,6 @@
import { workspaceResolver } from "@core/workspace"
import { BooleanResponse, StringRequest } from "@shared/proto/cline/common"
import { getWorkspacePath } from "@utils/path"
import * as fs from "fs"
import { Controller } from ".."
/**
* Check if a file exists in the project using a relative path
* @param controller The controller instance
* @param request The request containing the relative file path to check
* @returns BooleanResponse indicating whether the file exists
*/
export async function ifFileExistsRelativePath(_controller: Controller, request: StringRequest): Promise<BooleanResponse> {
const workspacePath = await getWorkspacePath()
if (!workspacePath) {
// If no workspace is open, return false
return BooleanResponse.create({ value: false })
}
if (!request.value) {
// If no path provided, return false
return BooleanResponse.create({ value: false })
}
// Resolve the relative path to absolute path
const resolvedPath = workspaceResolver.resolveWorkspacePath(
workspacePath,
request.value,
"Controller.ifFileExistsRelativePath",
)
const absolutePath = typeof resolvedPath === "string" ? resolvedPath : resolvedPath.absolutePath
// Check if the file exists
try {
return BooleanResponse.create({ value: fs.statSync(absolutePath).isFile() })
} catch {
return BooleanResponse.create({ value: false })
}
export async function ifFileExistsRelativePath(_controller: Controller, _request: StringRequest): Promise<BooleanResponse> {
return BooleanResponse.create({})
}
@@ -1,19 +1,6 @@
import { openFile as openFileIntegration } from "@integrations/misc/open-file"
import { Empty, StringRequest } from "@shared/proto/cline/common"
import path from "path"
import { HostProvider } from "@/hosts/host-provider"
import { Controller } from ".."
/**
* Opens the api_conversation_history.json file for a task in the editor
* @param controller The controller instance
* @param request The request message containing the task ID in the 'value' field
* @returns Empty response
*/
export async function openDiskConversationHistory(_controller: Controller, request: StringRequest): Promise<Empty> {
if (request.value) {
const globalStoragePath = HostProvider.get().globalStorageFsPath
const taskConversationHistoryPath = path.join(globalStoragePath, "tasks", request.value, "api_conversation_history.json")
await openFileIntegration(taskConversationHistoryPath)
}
return Empty.create()
export async function openDiskConversationHistory(_controller: Controller, _request: StringRequest): Promise<Empty> {
return Empty.create({})
}
@@ -1,81 +1,6 @@
import { parseYamlFrontmatter } from "@core/context/instructions/user-instructions/frontmatter"
import { StateManager } from "@core/storage/StateManager"
import { openFile as openFileIntegration } from "@integrations/misc/open-file"
import { Empty, StringRequest } from "@shared/proto/cline/common"
import { REMOTE_URI_SCHEME } from "@shared/remote-config/constants"
import type { GlobalInstructionsFile } from "@shared/remote-config/schema"
import { writeFile } from "@utils/fs"
import * as os from "os"
import * as path from "path"
import { Controller } from ".."
/**
* Opens a file in the editor
* @param controller The controller instance
* @param request The request message containing the file path in the 'value' field.
* Supports special URI format for remote rules/workflows/skills:
* - remote://rule/{ruleName}
* - remote://workflow/{workflowName}
* - remote://skill/{skillName}
* @returns Empty response
*/
export async function openFile(_controller: Controller, request: StringRequest): Promise<Empty> {
if (request.value) {
// Check for remote:// prefix for remote rules/workflows
if (request.value.startsWith(REMOTE_URI_SCHEME)) {
await openRemoteFile(request.value)
} else {
await openFileIntegration(request.value)
}
}
return Empty.create()
}
/**
* Opens a remote rule, workflow, or skill file by creating a temp file with its contents
* @param uri The remote URI in format: remote://rule/{name}, remote://workflow/{name}, or remote://skill/{name}
*/
async function openRemoteFile(uri: string): Promise<void> {
// Parse: remote://rule/{name}, remote://workflow/{name}, or remote://skill/{name}
const match = uri.match(/^remote:\/\/(rule|workflow|skill)\/(.+)$/)
if (!match) {
throw new Error(`Invalid remote file URI: ${uri}`)
}
const [, type, name] = match
const remoteConfig = StateManager.get().getRemoteConfigSettings()
// Look up content based on type
let items: GlobalInstructionsFile[] | undefined
if (type === "rule") {
items = remoteConfig.remoteGlobalRules
} else if (type === "workflow") {
items = remoteConfig.remoteGlobalWorkflows
} else {
items = remoteConfig.remoteGlobalSkills
}
// Try entry.name first (fast path), fall back to frontmatter.name for skills
// in case entry.name drifts from the frontmatter
let item = items?.find((r) => r.name === name)
if (!item && type === "skill") {
item = items?.find((r) => {
const { data } = parseYamlFrontmatter(r.contents)
return typeof data.name === "string" && data.name === name
})
}
if (!item?.contents) {
throw new Error(`Remote ${type} not found: ${name}`)
}
// Create temp file with read-only header comment
const header = `# ⚠️ READ-ONLY: This ${type} is managed by your organization.\n# Changes made here will not be saved.\n\n`
const content = header + item.contents
// Sanitize the name for use in filename (replace invalid characters)
const sanitizedName = name.replace(/[<>:"/\\|?*]/g, "_")
const tempPath = path.join(os.tmpdir(), `cline-remote-${type}-${sanitizedName}.md`)
await writeFile(tempPath, content)
await openFileIntegration(tempPath)
export async function openFile(_controller: Controller, _request: StringRequest): Promise<Empty> {
return Empty.create({})
}
@@ -1,36 +1,6 @@
import { workspaceResolver } from "@core/workspace"
import { openFile as openFileIntegration } from "@integrations/misc/open-file"
import { Empty, StringRequest } from "@shared/proto/cline/common"
import { getWorkspacePath } from "@utils/path"
import { Logger } from "@/shared/services/Logger"
import { Controller } from ".."
/**
* Opens a file in the editor by a relative path
* @param controller The controller instance
* @param request The request message containing the relative file path in the 'value' field
* @returns Empty response
*/
export async function openFileRelativePath(_controller: Controller, request: StringRequest): Promise<Empty> {
const workspacePath = await getWorkspacePath()
if (!workspacePath) {
Logger.error("Error in openFileRelativePath: No workspace path available")
return Empty.create()
}
if (request.value) {
// Resolve the relative path to absolute path
const resolvedPath = workspaceResolver.resolveWorkspacePath(
workspacePath,
request.value,
"Controller.openFileRelativePath",
)
const absolutePath = typeof resolvedPath === "string" ? resolvedPath : resolvedPath.absolutePath
// Open the file using the existing integration
openFileIntegration(absolutePath)
}
return Empty.create()
export async function openFileRelativePath(_controller: Controller, _request: StringRequest): Promise<Empty> {
return Empty.create({})
}
@@ -1,40 +1,6 @@
import { openFile as openFileIntegration } from "@integrations/misc/open-file"
import { telemetryService } from "../../../services/telemetry"
import { Empty, StringRequest } from "../../../shared/proto/cline/common"
import { ensureFocusChainFile, extractFocusChainListFromText } from "../../task/focus-chain/file-utils"
import { Empty, StringRequest } from "@shared/proto/cline/common"
import { Controller } from ".."
/**
* Opens or creates a focus chain checklist markdown file for editing
* The file is stored at <globalStorage>/tasks/<taskId>/focus_chain_taskid_<taskId>.md
*/
export async function openFocusChainFile(controller: Controller, request: StringRequest): Promise<Empty> {
if (!request.value) {
throw new Error("Task ID is required")
}
const taskId = request.value
// Get the current focus chain list from the task's most recent task_progress message
let initialFocusChainContent: string | undefined
const currentTask = controller.task
if (currentTask) {
// Get the task's message history and find the most recent task_progress message
// TODO - can we decouple this from ClineMessages?
const clineMessages = currentTask.messageStateHandler.getClineMessages()
const lastProgressMessage = clineMessages
.slice()
.reverse()
.find((m: any) => m.say === "task_progress")
if (lastProgressMessage && lastProgressMessage.text) {
initialFocusChainContent = extractFocusChainListFromText(lastProgressMessage.text) || undefined
}
}
const focusChainFilePath = await ensureFocusChainFile(taskId, initialFocusChainContent)
telemetryService.captureFocusChainListOpened(taskId)
await openFileIntegration(focusChainFilePath)
return Empty.create()
export async function openFocusChainFile(_controller: Controller, _request: StringRequest): Promise<Empty> {
return Empty.create({})
}
@@ -1,16 +1,6 @@
import { openImage as openImageIntegration } from "@integrations/misc/open-file"
import { Empty, StringRequest } from "@shared/proto/cline/common"
import { Controller } from ".."
/**
* Opens an image in the system viewer
* @param controller The controller instance
* @param request The request message containing the image path or data URI in the 'value' field
* @returns Empty response
*/
export async function openImage(_controller: Controller, request: StringRequest): Promise<Empty> {
if (request.value) {
await openImageIntegration(request.value)
}
return Empty.create()
export async function openImage(_controller: Controller, _request: StringRequest): Promise<Empty> {
return Empty.create({})
}
@@ -1,14 +1,6 @@
import { Empty, StringRequest } from "@shared/proto/cline/common"
import { openMention as coreOpenMention } from "../../mentions"
import { Controller } from ".."
/**
* Opens a mention (file path, problem, terminal, or URL)
* @param controller The controller instance
* @param request The string request containing the mention text
* @returns Empty response
*/
export async function openMention(_controller: Controller, request: StringRequest): Promise<Empty> {
coreOpenMention(request.value)
return Empty.create()
export async function openMention(_controller: Controller, _request: StringRequest): Promise<Empty> {
return Empty.create({})
}
@@ -1,85 +1,10 @@
import { HookInfo, HooksToggles, WorkspaceHooks } from "@shared/proto/cline/file"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { HostProvider } from "@/hosts/host-provider"
import { resolveExistingHookPath, VALID_HOOK_TYPES } from "../../hooks/utils"
import { HooksToggles } from "@shared/proto/cline/file"
import { Controller } from ".."
export async function refreshHooks(
_controller: Controller,
_request?: any,
globalHooksDirOverride?: string,
_globalHooksDirOverride?: string,
): Promise<HooksToggles> {
const globalHooksDir = globalHooksDirOverride || path.join(os.homedir(), "Documents", "Cline", "Hooks")
const isWindows = process.platform === "win32"
// Collect global hooks
const globalHooks: HookInfo[] = []
for (const hookName of VALID_HOOK_TYPES) {
const hookPath = await resolveExistingHookPath(globalHooksDir, hookName)
if (hookPath) {
globalHooks.push(
HookInfo.create({
name: hookName,
enabled: await isExecutable(hookPath),
absolutePath: hookPath,
}),
)
}
}
// Collect workspace hooks from all workspace folders
const workspacePaths = await HostProvider.workspace.getWorkspacePaths({})
const workspaceHooksList: WorkspaceHooks[] = []
for (const workspacePath of workspacePaths.paths) {
const workspaceHooksDir = path.join(workspacePath, ".clinerules", "hooks")
const hooks: HookInfo[] = []
for (const hookName of VALID_HOOK_TYPES) {
const hookPath = await resolveExistingHookPath(workspaceHooksDir, hookName)
if (hookPath) {
hooks.push(
HookInfo.create({
name: hookName,
enabled: await isExecutable(hookPath),
absolutePath: hookPath,
}),
)
}
}
// Add all workspaces, even if they have no hooks yet
// This allows users to create their first hook via the dropdown
const workspaceName = path.basename(workspacePath)
workspaceHooksList.push(
WorkspaceHooks.create({
workspaceName,
hooks,
}),
)
}
return HooksToggles.create({
globalHooks,
workspaceHooks: workspaceHooksList,
isWindows,
})
}
async function isExecutable(filePath: string): Promise<boolean> {
if (process.platform === "win32") {
// On Windows, files are "enabled" if they exist
// TODO(PR-9552 follow-up): Replace this temporary file-exists behavior
// with JSON-backed cross-platform hook enablement state.
return true
}
try {
await fs.access(filePath, fs.constants.X_OK)
return true
} catch {
return false
}
return HooksToggles.create({})
}
@@ -1,39 +1,7 @@
import { refreshClineRulesToggles } from "@core/context/instructions/user-instructions/cline-rules"
import { refreshExternalRulesToggles } from "@core/context/instructions/user-instructions/external-rules"
import { refreshWorkflowToggles } from "@core/context/instructions/user-instructions/workflows"
import { EmptyRequest } from "@shared/proto/cline/common"
import { RefreshedRules } from "@shared/proto/cline/file"
import { Logger } from "@/shared/services/Logger"
import { getCwd, getDesktopDir } from "@/utils/path"
import type { Controller } from "../index"
/**
* Refreshes all rule toggles (Cline, External, and Workflows)
* @param controller The controller instance
* @param _request The empty request
* @returns RefreshedRules containing updated toggles for all rule types
*/
export async function refreshRules(controller: Controller, _request: EmptyRequest): Promise<RefreshedRules> {
try {
const cwd = await getCwd(getDesktopDir())
const { globalToggles, localToggles } = await refreshClineRulesToggles(controller, cwd)
const { cursorLocalToggles, windsurfLocalToggles, agentsLocalToggles } = await refreshExternalRulesToggles(
controller,
cwd,
)
const { localWorkflowToggles, globalWorkflowToggles } = await refreshWorkflowToggles(controller, cwd)
return RefreshedRules.create({
globalClineRulesToggles: { toggles: globalToggles },
localClineRulesToggles: { toggles: localToggles },
localCursorRulesToggles: { toggles: cursorLocalToggles },
localWindsurfRulesToggles: { toggles: windsurfLocalToggles },
localAgentsRulesToggles: { toggles: agentsLocalToggles },
localWorkflowToggles: { toggles: localWorkflowToggles },
globalWorkflowToggles: { toggles: globalWorkflowToggles },
})
} catch (error) {
Logger.error("Failed to refresh rules:", error)
throw error
}
export async function refreshRules(_controller: Controller, _request: EmptyRequest): Promise<RefreshedRules> {
return RefreshedRules.create({})
}
@@ -1,133 +1,6 @@
import { parseRemoteSkillEntries } from "@core/context/instructions/user-instructions/skills"
import { RefreshedSkills, SkillInfo } from "@shared/proto/cline/file"
import fs from "fs/promises"
import path from "path"
import { parseYamlFrontmatter } from "@/core/context/instructions/user-instructions/frontmatter"
import { getSkillsDirectoriesForScan } from "@/core/storage/disk"
import { HostProvider } from "@/hosts/host-provider"
import { Logger } from "@/shared/services/Logger"
import { fileExistsAtPath, isDirectory } from "@/utils/fs"
import { RefreshedSkills } from "@shared/proto/cline/file"
import { Controller } from ".."
/**
* Scan a directory for skill subdirectories containing SKILL.md files.
*/
async function scanSkillsDirectory(dirPath: string): Promise<SkillInfo[]> {
const skills: SkillInfo[] = []
if (!(await fileExistsAtPath(dirPath)) || !(await isDirectory(dirPath))) {
return skills
}
try {
const entries = await fs.readdir(dirPath)
for (const entryName of entries) {
const entryPath = path.join(dirPath, entryName)
const stats = await fs.stat(entryPath).catch(() => null)
if (!stats?.isDirectory()) continue
const skillMdPath = path.join(entryPath, "SKILL.md")
if (!(await fileExistsAtPath(skillMdPath))) continue
try {
const fileContent = await fs.readFile(skillMdPath, "utf-8")
const result = parseYamlFrontmatter(fileContent)
if (result.parseError) {
Logger.warn("Failed to parse YAML frontmatter:", result.parseError)
}
const frontmatter = result.data
// Validate required fields
if (!frontmatter.name || typeof frontmatter.name !== "string") continue
if (!frontmatter.description || typeof frontmatter.description !== "string") continue
if (frontmatter.name !== entryName) continue
skills.push(
SkillInfo.create({
name: entryName,
description: frontmatter.description,
path: skillMdPath,
enabled: true, // Will be updated with toggle state
}),
)
} catch {
// Skip invalid skills
}
}
} catch {
// Directory read error, skip
}
return skills
}
/**
* Refreshes all skill toggles (discovers skills and their enabled state)
*/
export async function refreshSkills(controller: Controller): Promise<RefreshedSkills> {
// Get workspace paths for local skills
const workspacePaths = await HostProvider.workspace.getWorkspacePaths({})
const primaryWorkspace = workspacePaths.paths[0]
const globalSkills: SkillInfo[] = []
const localSkills: SkillInfo[] = []
if (primaryWorkspace) {
const scanDirs = getSkillsDirectoriesForScan(primaryWorkspace)
for (const dir of scanDirs) {
const skills = await scanSkillsDirectory(dir.path)
if (dir.source === "global") {
globalSkills.push(...skills)
} else {
localSkills.push(...skills)
}
}
} else {
const scanDirs = getSkillsDirectoriesForScan("")
for (const dir of scanDirs) {
if (dir.source !== "global") continue
const skills = await scanSkillsDirectory(dir.path)
globalSkills.push(...skills)
}
}
// Get global toggles and apply them
const globalToggles = controller.stateManager.getGlobalSettingsKey("globalSkillsToggles") || {}
for (const skill of globalSkills) {
skill.enabled = globalToggles[skill.path] !== false
}
// Add remote skills from remote config.
// Precedence: remote (enterprise) > disk-global (user) > project (workspace).
// Remote entries are appended to globalSkills[] and split into the dedicated "Enterprise Skills"
// section by the UI. The toggle store distinguishes them by the "remote:" path prefix.
const remoteConfigSettings = controller.stateManager.getRemoteConfigSettings()
const remoteSkillsToggles = controller.stateManager.getGlobalStateKey("remoteSkillsToggles") || {}
const validatedRemoteSkills = parseRemoteSkillEntries(remoteConfigSettings.remoteGlobalSkills || [])
for (const entry of validatedRemoteSkills) {
const enabled = entry.alwaysEnabled || remoteSkillsToggles[entry.name] !== false
globalSkills.push(
SkillInfo.create({
name: entry.name,
description: entry.description,
path: `remote:${entry.name}`,
enabled,
alwaysEnabled: entry.alwaysEnabled,
}),
)
}
// Get local toggles and apply them
const localToggles = controller.stateManager.getWorkspaceStateKey("localSkillsToggles") || {}
for (const skill of localSkills) {
skill.enabled = localToggles[skill.path] !== false
}
return RefreshedSkills.create({
globalSkills,
localSkills,
})
export async function refreshSkills(_controller: Controller): Promise<RefreshedSkills> {
return RefreshedSkills.create({})
}
@@ -1,28 +1,7 @@
import { StringRequest } from "@shared/proto/cline/common"
import { GitCommits } from "@shared/proto/cline/file"
import { searchCommits as searchCommitsUtil } from "@utils/git"
import { getWorkspacePath } from "@utils/path"
import { Logger } from "@/shared/services/Logger"
import { Controller } from ".."
/**
* Searches for git commits in the workspace repository
* @param controller The controller instance
* @param request The request message containing the search query in the 'value' field
* @returns GitCommits containing the matching commits
*/
export async function searchCommits(_controller: Controller, request: StringRequest): Promise<GitCommits> {
const cwd = await getWorkspacePath()
if (!cwd) {
return GitCommits.create({ commits: [] })
}
try {
const commits = await searchCommitsUtil(request.value || "", cwd)
return GitCommits.create({ commits })
} catch (error) {
Logger.error(`Error searching commits: ${JSON.stringify(error)}`)
return GitCommits.create({ commits: [] })
}
export async function searchCommits(_controller: Controller, _request: StringRequest): Promise<GitCommits> {
return GitCommits.create({})
}
@@ -1,174 +1,6 @@
import {
type FileSearchSource,
RipgrepError,
type SearchWorkspaceFilesResult,
searchWorkspaceFiles,
searchWorkspaceFilesMultiroot,
} from "@services/search/file-search"
import { telemetryService } from "@services/telemetry"
import { FileSearchRequest, FileSearchResults, FileSearchType } from "@shared/proto/cline/file"
import { convertSearchResultsToProtoFileInfos } from "@shared/proto-conversions/file/search-result-conversion"
import { type FsInfo, getFsInfo } from "@utils/fs-info"
import { getWorkspacePath } from "@utils/path"
import { Logger } from "@/shared/services/Logger"
import { FileSearchRequest, FileSearchResults } from "@shared/proto/cline/file"
import { Controller } from ".."
// error_reason values surfaced on FileSearchResults; see proto/cline/file.proto.
const ERROR_REASON_WORKSPACE_UNAVAILABLE = "workspace_unavailable"
const ERROR_REASON_RIPGREP_SPAWN_FAILED = "ripgrep_spawn_failed"
const ERROR_REASON_UNKNOWN = "unknown"
function classifyError(error: unknown): { errorReason: string; errorMessage: string } {
const errorMessage = error instanceof Error ? error.message : String(error)
if (error instanceof RipgrepError) {
const firstStderrLine = error.stderr ? error.stderr.trim().split("\n", 1)[0] : ""
return {
errorReason: ERROR_REASON_RIPGREP_SPAWN_FAILED,
errorMessage: firstStderrLine || errorMessage,
}
}
return { errorReason: ERROR_REASON_UNKNOWN, errorMessage }
}
// Fire-and-forget the FS-class lookup + telemetry capture. The picker awaits
// the searchFiles response, so we must not block it on a slow/hung mount —
// `getFsInfo` does a `realpath` and a `mount`/`stat -f` that, even with the
// outer timeout in fs-info, can still cost seconds on a stale network FS.
function captureWithFsContext(fsContextPath: string | undefined, capture: (fsContext: FsInfo) => void | Promise<void>): void {
getFsInfo(fsContextPath)
.then(capture)
.catch((err) => Logger.warn(`searchFiles: telemetry capture failed: ${err}`))
}
/**
* Searches for files in the workspace with fuzzy matching
* @param controller The controller instance
* @param request The request containing search query, and optionally a mentionsRequestId and workspace_hint
* @returns Results containing matching files/folders
*/
export async function searchFiles(controller: Controller, request: FileSearchRequest): Promise<FileSearchResults> {
// Best-effort path used for FS-class telemetry. Declared in the function
// scope so the catch block can also reference it. When the request carries
// a workspaceHint we tag against the matched root; for cross-root searches
// (no hint) we fall back to the primary root, since attributing one event
// to "the root that mattered" is impossible without per-root events.
let fsContextPath: string | undefined
try {
// Map enum to string for the search service
let selectedTypeString: "file" | "folder" | undefined
if (request.selectedType === FileSearchType.FILE) {
selectedTypeString = "file"
} else if (request.selectedType === FileSearchType.FOLDER) {
selectedTypeString = "folder"
}
// Extract hint, ensure workspaceManager is ready, check for multiroot
const workspaceHint = request.workspaceHint
const workspaceManager = await controller.ensureWorkspaceManager()
const hasMultirootSupport = workspaceManager && workspaceManager.getRoots()?.length > 0
let searchResult: SearchWorkspaceFilesResult
if (hasMultirootSupport) {
// Tag the actually-searched root, not always the primary —
// otherwise an SSHFS secondary root looks like a fast primary
// in dashboards. searchWorkspaceFilesMultiroot resolves the hint
// the same way (by name).
const hintedRoot = workspaceHint
? (workspaceManager.getRootByName(workspaceHint) ??
workspaceManager.getRoots().find((r) => r.path === workspaceHint))
: undefined
fsContextPath = hintedRoot?.path ?? workspaceManager.getRoots()[0]?.path
searchResult = await searchWorkspaceFilesMultiroot(
request.query || "",
workspaceManager,
request.limit || 20,
selectedTypeString,
workspaceHint,
)
} else {
// Legacy single workspace search
const workspacePath = await getWorkspacePath()
if (!workspacePath) {
Logger.error("Error in searchFiles: No workspace path available")
telemetryService.captureMentionFailed("folder", "workspace_unavailable", "No workspace path available")
return {
results: [],
mentionsRequestId: request.mentionsRequestId,
errorReason: ERROR_REASON_WORKSPACE_UNAVAILABLE,
errorMessage: "No workspace path available",
}
}
fsContextPath = workspacePath
// Call file search service with query from request
searchResult = await searchWorkspaceFiles(
request.query || "",
workspacePath,
request.limit || 20, // Use default limit of 20 if not specified
selectedTypeString,
)
}
const searchSource: FileSearchSource = searchResult.source
// Convert search results to proto FileInfo objects using the conversion function
const protoResults = convertSearchResultsToProtoFileInfos(searchResult.items)
// Track search results telemetry
// Determine search type for telemetry
let searchType: "file" | "folder" | "all" = "all"
if (request.selectedType === FileSearchType.FILE) {
searchType = "file"
} else if (request.selectedType === FileSearchType.FOLDER) {
searchType = "folder"
}
captureWithFsContext(fsContextPath, (fsContext) =>
telemetryService.captureMentionSearchResults(
request.query || "",
protoResults.length,
searchType,
protoResults.length === 0,
fsContext,
searchSource,
),
)
// Return successful results
return { results: protoResults, mentionsRequestId: request.mentionsRequestId }
} catch (error) {
const { errorReason, errorMessage } = classifyError(error)
Logger.error(`Error in searchFiles (errorReason=${errorReason}):`, error)
const mentionType =
request.selectedType === FileSearchType.FILE
? "file"
: request.selectedType === FileSearchType.FOLDER
? "folder"
: "folder" // Default to folder for "all" searches
const errorType: "ripgrep_spawn_failed" | "permission_denied" | "unknown" =
errorReason === ERROR_REASON_RIPGREP_SPAWN_FAILED
? "ripgrep_spawn_failed"
: error instanceof Error && error.message.includes("permission")
? "permission_denied"
: "unknown"
// fsContextPath may be unset if we threw before resolving the workspace;
// getFsInfo handles undefined and returns the unknown sentinel.
captureWithFsContext(fsContextPath, (fsContext) =>
telemetryService.captureMentionFailed(mentionType, errorType, errorMessage, fsContext),
)
return {
results: [],
mentionsRequestId: request.mentionsRequestId,
errorReason,
errorMessage,
}
}
export async function searchFiles(_controller: Controller, _request: FileSearchRequest): Promise<FileSearchResults> {
return FileSearchResults.create({})
}
@@ -1,21 +1,6 @@
import { selectFiles as selectFilesIntegration } from "@integrations/misc/process-files"
import { BooleanRequest, StringArrays } from "@shared/proto/cline/common"
import { Logger } from "@/shared/services/Logger"
import { Controller } from ".."
/**
* Prompts the user to select images from the file system and returns them as data URLs
* @param controller The controller instance
* @param request Boolean request, with the value defining whether this model supports images
* @returns Two arrays of image data URLs and other file paths
*/
export async function selectFiles(_controller: Controller, request: BooleanRequest): Promise<StringArrays> {
try {
const { images, files } = await selectFilesIntegration(request.value)
return StringArrays.create({ values1: images, values2: files })
} catch (error) {
Logger.error("Error selecting images & files:", error)
// Return empty array on error
return StringArrays.create({ values1: [], values2: [] })
}
export async function selectFiles(_controller: Controller, _request: BooleanRequest): Promise<StringArrays> {
return StringArrays.create({})
}
@@ -1,34 +1,7 @@
import type { ToggleAgentsRuleRequest } from "@shared/proto/cline/file"
import { ClineRulesToggles } from "@shared/proto/cline/file"
import { Logger } from "@/shared/services/Logger"
import type { Controller } from "../index"
/**
* Toggles an Agents rule (enable or disable)
* @param controller The controller instance
* @param request The toggle request
* @returns The updated Agents rule toggles
*/
export async function toggleAgentsRule(controller: Controller, request: ToggleAgentsRuleRequest): Promise<ClineRulesToggles> {
const { rulePath, enabled } = request
if (!rulePath || typeof enabled !== "boolean") {
Logger.error("toggleAgentsRule: Missing or invalid parameters", {
rulePath,
enabled: typeof enabled === "boolean" ? enabled : `Invalid: ${typeof enabled}`,
})
throw new Error("Missing or invalid parameters for toggleAgentsRule")
}
// Update the toggle in workspace state
const toggles = controller.stateManager.getWorkspaceStateKey("localAgentsRulesToggles")
toggles[rulePath] = enabled
controller.stateManager.setWorkspaceState("localAgentsRulesToggles", toggles)
// Get the current state to return in the response
const agentsToggles = controller.stateManager.getWorkspaceStateKey("localAgentsRulesToggles")
return ClineRulesToggles.create({
toggles: agentsToggles,
})
export async function toggleAgentsRule(_controller: Controller, _request: ToggleAgentsRuleRequest): Promise<ClineRulesToggles> {
return ClineRulesToggles.create({})
}
@@ -1,68 +1,7 @@
import { getWorkspaceBasename } from "@core/workspace"
import type { ToggleClineRuleRequest } from "@shared/proto/cline/file"
import { RuleScope, ToggleClineRules } from "@shared/proto/cline/file"
import { telemetryService } from "@/services/telemetry"
import { Logger } from "@/shared/services/Logger"
import { ToggleClineRules } from "@shared/proto/cline/file"
import type { Controller } from "../index"
/**
* Toggles a Cline rule (enable or disable)
* @param controller The controller instance
* @param request The toggle request
* @returns The updated Cline rule toggles
*/
export async function toggleClineRule(controller: Controller, request: ToggleClineRuleRequest): Promise<ToggleClineRules> {
const { scope, rulePath, enabled } = request
if (!rulePath || typeof enabled !== "boolean" || scope === undefined) {
Logger.error("toggleClineRule: Missing or invalid parameters", {
rulePath,
scope,
enabled: typeof enabled === "boolean" ? enabled : `Invalid: ${typeof enabled}`,
})
throw new Error("Missing or invalid parameters for toggleClineRule")
}
// Handle the three different scopes
switch (scope) {
case RuleScope.GLOBAL: {
const toggles = controller.stateManager.getGlobalSettingsKey("globalClineRulesToggles")
toggles[rulePath] = enabled
controller.stateManager.setGlobalState("globalClineRulesToggles", toggles)
break
}
case RuleScope.LOCAL: {
const toggles = controller.stateManager.getWorkspaceStateKey("localClineRulesToggles")
toggles[rulePath] = enabled
controller.stateManager.setWorkspaceState("localClineRulesToggles", toggles)
break
}
case RuleScope.REMOTE: {
const toggles = controller.stateManager.getGlobalStateKey("remoteRulesToggles")
toggles[rulePath] = enabled
controller.stateManager.setGlobalState("remoteRulesToggles", toggles)
break
}
default:
throw new Error(`Invalid scope: ${scope}`)
}
// Track rule toggle telemetry with current task context
if (controller.task?.ulid) {
// Extract just the filename for privacy (no full paths)
const ruleFileName = getWorkspaceBasename(rulePath, "Controller.toggleClineRule")
const isGlobal = scope === RuleScope.GLOBAL
telemetryService.captureClineRuleToggled(controller.task.ulid, ruleFileName, enabled, isGlobal)
}
// Get the current state to return in the response
const globalToggles = controller.stateManager.getGlobalSettingsKey("globalClineRulesToggles")
const localToggles = controller.stateManager.getWorkspaceStateKey("localClineRulesToggles")
const remoteToggles = controller.stateManager.getGlobalStateKey("remoteRulesToggles")
return ToggleClineRules.create({
globalClineRulesToggles: { toggles: globalToggles },
localClineRulesToggles: { toggles: localToggles },
remoteRulesToggles: { toggles: remoteToggles },
})
export async function toggleClineRule(_controller: Controller, _request: ToggleClineRuleRequest): Promise<ToggleClineRules> {
return ToggleClineRules.create({})
}
@@ -1,34 +1,7 @@
import type { ToggleCursorRuleRequest } from "@shared/proto/cline/file"
import { ClineRulesToggles } from "@shared/proto/cline/file"
import { Logger } from "@/shared/services/Logger"
import type { Controller } from "../index"
/**
* Toggles a Cursor rule (enable or disable)
* @param controller The controller instance
* @param request The toggle request
* @returns The updated Cursor rule toggles
*/
export async function toggleCursorRule(controller: Controller, request: ToggleCursorRuleRequest): Promise<ClineRulesToggles> {
const { rulePath, enabled } = request
if (!rulePath || typeof enabled !== "boolean") {
Logger.error("toggleCursorRule: Missing or invalid parameters", {
rulePath,
enabled: typeof enabled === "boolean" ? enabled : `Invalid: ${typeof enabled}`,
})
throw new Error("Missing or invalid parameters for toggleCursorRule")
}
// Update the toggles in workspace state
const toggles = controller.stateManager.getWorkspaceStateKey("localCursorRulesToggles")
toggles[rulePath] = enabled
controller.stateManager.setWorkspaceState("localCursorRulesToggles", toggles)
// Get the current state to return in the response
const cursorToggles = controller.stateManager.getWorkspaceStateKey("localCursorRulesToggles")
return ClineRulesToggles.create({
toggles: cursorToggles,
})
export async function toggleCursorRule(_controller: Controller, _request: ToggleCursorRuleRequest): Promise<ClineRulesToggles> {
return ClineRulesToggles.create({})
}
@@ -1,41 +1,10 @@
import { ToggleHookRequest, ToggleHookResponse } from "@shared/proto/cline/file"
import fs from "fs/promises"
import { HookDiscoveryCache } from "../../hooks/HookDiscoveryCache"
import { resolveExistingHookPath, resolveHooksDirectory } from "../../hooks/utils"
import { Controller } from ".."
import { refreshHooks } from "./refreshHooks"
export async function toggleHook(
controller: Controller,
request: ToggleHookRequest,
globalHooksDirOverride?: string,
_controller: Controller,
_request: ToggleHookRequest,
_globalHooksDirOverride?: string,
): Promise<ToggleHookResponse> {
const { hookName, isGlobal, enabled, workspaceName } = request
// Determine hook path
const hooksDir = await resolveHooksDirectory(isGlobal, workspaceName, globalHooksDirOverride)
const hookPath = await resolveExistingHookPath(hooksDir, hookName)
// Verify hook exists
if (!hookPath) {
throw new Error(`Hook ${hookName} does not exist in ${hooksDir}`)
}
// On Windows, we can't use chmod, so we just return the current state
// without modifying the file. The frontend will disable the toggle.
// TODO(PR-9552 follow-up): Replace this temporary behavior with a
// JSON-backed cross-platform enabled/disabled hook state.
if (process.platform !== "win32") {
// Toggle executable bit (Unix-like systems only)
// TODO(PR-9552 follow-up): Revisit chmod-driven enablement semantics
// once cross-platform JSON-backed state is implemented.
await fs.chmod(hookPath, enabled ? 0o755 : 0o644)
}
// Invalidate cache
await HookDiscoveryCache.getInstance().invalidateAll()
// Return updated state
const hooksToggles = await refreshHooks(controller, undefined, globalHooksDirOverride)
return ToggleHookResponse.create({ hooksToggles })
return ToggleHookResponse.create({})
}
@@ -1,57 +1,6 @@
import { setSkillDisabledInFrontmatter } from "@core/context/instructions/user-instructions/skills"
import { SkillsToggles, ToggleSkillRequest } from "@shared/proto/cline/file"
import { Logger } from "@/shared/services/Logger"
import { Controller } from ".."
/**
* Toggles a skill on or off
* @param controller The controller instance
* @param request The request containing the skill path and enabled state
* @returns The updated skills toggles
*/
export async function toggleSkill(controller: Controller, request: ToggleSkillRequest): Promise<SkillsToggles> {
const { skillPath, isGlobal, enabled } = request
if (!skillPath || typeof enabled !== "boolean" || typeof isGlobal !== "boolean") {
Logger.error("toggleSkill: Missing or invalid parameters", {
skillPath,
isGlobal,
enabled: typeof enabled === "boolean" ? enabled : `Invalid: ${typeof enabled}`,
})
throw new Error("Missing or invalid parameters for toggleSkill")
}
let globalToggles = controller.stateManager.getGlobalSettingsKey("globalSkillsToggles") || {}
let localToggles = controller.stateManager.getWorkspaceStateKey("localSkillsToggles") || {}
let remoteToggles = controller.stateManager.getGlobalStateKey("remoteSkillsToggles") || {}
// Remote skills are identified by a "remote:" path prefix. They use a separate toggle store
// keyed by skill name (the part after "remote:") rather than the file path.
if (skillPath.startsWith("remote:")) {
const name = skillPath.replace("remote:", "")
remoteToggles = { ...remoteToggles, [name]: enabled }
controller.stateManager.setGlobalState("remoteSkillsToggles", remoteToggles)
} else if (isGlobal) {
globalToggles = { ...globalToggles, [skillPath]: enabled }
controller.stateManager.setGlobalState("globalSkillsToggles", globalToggles)
} else {
localToggles = { ...localToggles, [skillPath]: enabled }
controller.stateManager.setWorkspaceState("localSkillsToggles", localToggles)
}
// Persist the enabled state to the SKILL.md frontmatter as well. The SDK
// builds the model's skill list / `skills` tool from the frontmatter
// `disabled` flag, not from the extension's UI toggle state, so without this
// write a skill toggled off in the sidebar would still be offered to the
// model (ENG-1995). The helper is a no-op for remote skills (no backing file).
await setSkillDisabledInFrontmatter(skillPath, enabled)
await controller.postStateToWebview()
return SkillsToggles.create({
globalSkillsToggles: globalToggles,
localSkillsToggles: localToggles,
remoteSkillsToggles: remoteToggles,
})
export async function toggleSkill(_controller: Controller, _request: ToggleSkillRequest): Promise<SkillsToggles> {
return SkillsToggles.create({})
}
@@ -1,30 +1,10 @@
import type { ToggleWindsurfRuleRequest } from "@shared/proto/cline/file"
import { ClineRulesToggles } from "@shared/proto/cline/file"
import { Logger } from "@/shared/services/Logger"
import type { Controller } from "../index"
/**
* Toggles a Windsurf rule (enable or disable)
* @param controller The controller instance
* @param request The toggle request
* @returns The updated Windsurf rule toggles
*/
export async function toggleWindsurfRule(controller: Controller, request: ToggleWindsurfRuleRequest): Promise<ClineRulesToggles> {
const { rulePath, enabled } = request
if (!rulePath || typeof enabled !== "boolean") {
Logger.error("toggleWindsurfRule: Missing or invalid parameters", {
rulePath,
enabled: typeof enabled === "boolean" ? enabled : `Invalid: ${typeof enabled}`,
})
throw new Error("Missing or invalid parameters for toggleWindsurfRule")
}
// Update the toggles
const toggles = controller.stateManager.getWorkspaceStateKey("localWindsurfRulesToggles")
toggles[rulePath] = enabled
controller.stateManager.setWorkspaceState("localWindsurfRulesToggles", toggles)
// Return the toggles directly
return ClineRulesToggles.create({ toggles: toggles })
export async function toggleWindsurfRule(
_controller: Controller,
_request: ToggleWindsurfRuleRequest,
): Promise<ClineRulesToggles> {
return ClineRulesToggles.create({})
}
@@ -1,53 +1,6 @@
import { ClineRulesToggles, RuleScope, ToggleWorkflowRequest } from "@shared/proto/cline/file"
import { Logger } from "@/shared/services/Logger"
import { ClineRulesToggles, ToggleWorkflowRequest } from "@shared/proto/cline/file"
import { Controller } from ".."
/**
* Toggles a workflow on or off
* @param controller The controller instance
* @param request The request containing the workflow path and enabled state
* @returns The updated workflow toggles
*/
export async function toggleWorkflow(controller: Controller, request: ToggleWorkflowRequest): Promise<ClineRulesToggles> {
const { workflowPath, enabled, scope } = request
if (!workflowPath || typeof enabled !== "boolean" || scope === undefined) {
Logger.error("toggleWorkflow: Missing or invalid parameters", {
workflowPath,
scope,
enabled: typeof enabled === "boolean" ? enabled : `Invalid: ${typeof enabled}`,
})
throw new Error("Missing or invalid parameters for toggleWorkflow")
}
// Handle the three different scopes
let toggles: Record<string, boolean>
switch (scope) {
case RuleScope.GLOBAL: {
toggles = controller.stateManager.getGlobalSettingsKey("globalWorkflowToggles")
toggles[workflowPath] = enabled
controller.stateManager.setGlobalState("globalWorkflowToggles", toggles)
break
}
case RuleScope.LOCAL: {
toggles = controller.stateManager.getWorkspaceStateKey("workflowToggles")
toggles[workflowPath] = enabled
controller.stateManager.setWorkspaceState("workflowToggles", toggles)
break
}
case RuleScope.REMOTE: {
toggles = controller.stateManager.getGlobalStateKey("remoteWorkflowToggles")
toggles[workflowPath] = enabled
controller.stateManager.setGlobalState("remoteWorkflowToggles", toggles)
break
}
default:
throw new Error(`Invalid scope: ${scope}`)
}
await controller.postStateToWebview()
// Return the updated toggles
return ClineRulesToggles.create({ toggles: toggles })
export async function toggleWorkflow(_controller: Controller, _request: ToggleWorkflowRequest): Promise<ClineRulesToggles> {
return ClineRulesToggles.create({})
}
@@ -1,416 +0,0 @@
import { afterEach, beforeEach, describe, it } from "mocha"
import { Controller } from "@core/controller"
import { serviceHandlers } from "@generated/hosts/vscode/protobus-services"
import { GrpcCancel, GrpcRequest } from "@shared/WebviewMessage"
import { expect } from "chai"
import * as sinon from "sinon"
import { getRequestRegistry, handleGrpcRequest, handleGrpcRequestCancel } from "./grpc-handler"
describe("grpc-handler", () => {
let sandbox: sinon.SinonSandbox
let mockController: Controller
let mockPostMessageToWebview: sinon.SinonStub
let mockUnaryHandler: sinon.SinonStub
let mockUnaryFailingHandler: sinon.SinonStub
let mockStreamingHandler: sinon.SinonStub
let mockStreamingFailingHandler: sinon.SinonStub
const serviceName = "cline.TestService"
const mockResponse = { result: "result-1234" }
beforeEach(() => {
sandbox = sinon.createSandbox()
// Create a mock controller
mockController = {} as any
mockPostMessageToWebview = sandbox.stub().resolves()
// Create mock service handlers
mockUnaryHandler = sandbox.stub().resolves(mockResponse)
mockStreamingHandler = sandbox.stub().resolves()
mockUnaryFailingHandler = sandbox.stub().rejects(new Error("Test error unary"))
mockStreamingFailingHandler = sandbox.stub().rejects(new Error("Stream error"))
serviceHandlers[serviceName] = {
testUnary: mockUnaryHandler,
testUnaryFailing: mockUnaryFailingHandler,
testStreaming: mockStreamingHandler,
testStreamingFailing: mockStreamingFailingHandler,
}
})
afterEach(() => {
sandbox.restore()
})
describe("handleGrpcRequest", () => {
describe("Unary requests", () => {
it("should handle successful unary requests", async () => {
const request: GrpcRequest = {
service: serviceName,
method: "testUnary",
message: { input: "test" },
request_id: "test-123",
is_streaming: false,
}
await handleGrpcRequest(mockController, mockPostMessageToWebview, request)
// Verify the handler was called
expect(mockUnaryHandler.calledOnce).to.be.true
expect(mockUnaryHandler.firstCall.args[0]).to.equal(mockController)
expect(mockUnaryHandler.firstCall.args[1]).to.deep.equal({ input: "test" })
// Verify the response was sent
expect(mockPostMessageToWebview.calledOnce).to.be.true
const sentMessage = mockPostMessageToWebview.firstCall.args[0]
expect(sentMessage).to.deep.equal({
type: "grpc_response",
grpc_response: {
message: mockResponse,
request_id: "test-123",
},
})
})
it("should handle errors in unary requests", async () => {
const request: GrpcRequest = {
service: serviceName,
method: "testUnaryFailing",
message: { input: "test" },
request_id: "test-456",
is_streaming: false,
}
await handleGrpcRequest(mockController, mockPostMessageToWebview, request)
// Verify the error response was sent
expect(mockPostMessageToWebview.calledOnce).to.be.true
const sentMessage = mockPostMessageToWebview.firstCall.args[0]
expect(sentMessage).to.deep.equal({
type: "grpc_response",
grpc_response: {
error: "Test error unary",
request_id: "test-456",
is_streaming: false,
},
})
})
it("should handle unknown service errors", async () => {
const request: GrpcRequest = {
service: "UnknownService",
method: "someMethod",
message: {},
request_id: "test-789",
is_streaming: false,
}
await handleGrpcRequest(mockController, mockPostMessageToWebview, request)
// Verify the error response was sent
expect(mockPostMessageToWebview.calledOnce).to.be.true
const sentMessage = mockPostMessageToWebview.firstCall.args[0]
expect(sentMessage.type).to.equal("grpc_response")
expect(sentMessage.grpc_response?.error).to.include("Unknown service: UnknownService")
expect(sentMessage.grpc_response?.request_id).to.equal("test-789")
})
it("should handle unknown method errors", async () => {
const request: GrpcRequest = {
service: serviceName,
method: "unknownMethod",
message: {},
request_id: "test-999",
is_streaming: false,
}
await handleGrpcRequest(mockController, mockPostMessageToWebview, request)
// Verify the error response was sent
expect(mockPostMessageToWebview.calledOnce).to.be.true
const sentMessage = mockPostMessageToWebview.firstCall.args[0]
expect(sentMessage.type).to.equal("grpc_response")
expect(sentMessage.grpc_response?.error).to.include("Unknown rpc: cline.TestService.unknownMethod")
expect(sentMessage.grpc_response?.request_id).to.equal("test-999")
})
})
describe("Streaming requests", () => {
it("should handle successful streaming requests", async () => {
// Set up a streaming handler that sends multiple responses
const request: GrpcRequest = {
service: serviceName,
method: "testStreaming",
message: { input: "stream" },
request_id: "stream-123",
is_streaming: true,
}
// Reset the mock and set up the handler using callsFake
mockStreamingHandler.reset()
mockStreamingHandler.callsFake(
async (_controller: any, _message: any, responseStream: any, _requestId: string) => {
// Simulate streaming multiple messages
await responseStream({ value: 1 }, false, 0)
await responseStream({ value: 2 }, false, 1)
await responseStream({ value: 3 }, true, 2) // Last message
},
)
await handleGrpcRequest(mockController, mockPostMessageToWebview, request)
// Verify the handler was called
expect(mockStreamingHandler.calledOnce).to.be.true
expect(mockStreamingHandler.firstCall.args[0]).to.equal(mockController)
expect(mockStreamingHandler.firstCall.args[1]).to.deep.equal({ input: "stream" })
expect(mockStreamingHandler.firstCall.args[3]).to.equal("stream-123")
// Verify all streaming responses were sent
expect(mockPostMessageToWebview.callCount).to.equal(3)
// Check all responses
expect(mockPostMessageToWebview.firstCall.args[0]).to.deep.equal({
type: "grpc_response",
grpc_response: {
message: { value: 1 },
request_id: "stream-123",
is_streaming: true,
sequence_number: 0,
},
})
expect(mockPostMessageToWebview.secondCall.args[0]).to.deep.equal({
type: "grpc_response",
grpc_response: {
message: { value: 2 },
request_id: "stream-123",
is_streaming: true,
sequence_number: 1,
},
})
expect(mockPostMessageToWebview.thirdCall.args[0]).to.deep.equal({
type: "grpc_response",
grpc_response: {
message: { value: 3 },
request_id: "stream-123",
is_streaming: false, // Last message has is_streaming: false
sequence_number: 2,
},
})
})
it("should handle errors in streaming requests", async () => {
const request: GrpcRequest = {
service: serviceName,
method: "testStreamingFailing",
message: { input: "stream" },
request_id: "stream-456",
is_streaming: true,
}
await handleGrpcRequest(mockController, mockPostMessageToWebview, request)
// Verify the error response was sent
expect(mockPostMessageToWebview.calledOnce).to.be.true
const sentMessage = mockPostMessageToWebview.firstCall.args[0]
expect(sentMessage).to.deep.equal({
type: "grpc_response",
grpc_response: {
error: "Stream error",
request_id: "stream-456",
is_streaming: false,
},
})
})
it("should handle streaming with message, error, then another message", async () => {
// This test simulates a scenario where:
// 1. First message is sent successfully
// 2. An error occurs
// 3. Another message is attempted (which should not be sent after error)
const request: GrpcRequest = {
service: serviceName,
method: "testStreaming",
message: { input: "stream-with-error" },
request_id: "stream-error-mid",
is_streaming: true,
}
// Reset the mock and set up the handler to throw an error after being called
mockStreamingHandler.reset()
mockStreamingHandler.callsFake(
async (_controller: any, _message: any, responseStream: any, _requestId: string) => {
// Send first message successfully
await responseStream({ value: "first" }, false, 0)
// Throw an error
throw new Error("Mid-stream error")
},
)
await handleGrpcRequest(mockController, mockPostMessageToWebview, request)
// Verify the handler was called
expect(mockStreamingHandler.calledOnce).to.be.true
// Verify that we got the first message and then the error
expect(mockPostMessageToWebview.callCount).to.equal(2)
// Check first message was sent successfully
expect(mockPostMessageToWebview.firstCall.args[0]).to.deep.equal({
type: "grpc_response",
grpc_response: {
message: { value: "first" },
request_id: "stream-error-mid",
is_streaming: true,
sequence_number: 0,
},
})
// Check error response was sent
expect(mockPostMessageToWebview.secondCall.args[0]).to.deep.equal({
type: "grpc_response",
grpc_response: {
error: "Mid-stream error",
request_id: "stream-error-mid",
is_streaming: false,
},
})
// Try to send another message after the error (simulating what might happen
// if the handler tried to continue after an error)
const responseStream = mockStreamingHandler.firstCall.args[2]
// This should still work as the responseStream function is still valid
await responseStream({ value: "after-error" }, false, 1)
// Verify we now have 3 total calls (first message, error, after-error message)
expect(mockPostMessageToWebview.callCount).to.equal(3)
// Verify the message after error was still sent
// (In a real scenario, the handler would have stopped due to the error,
// but this tests that the responseStream function itself still works)
expect(mockPostMessageToWebview.thirdCall.args[0]).to.deep.equal({
type: "grpc_response",
grpc_response: {
message: { value: "after-error" },
request_id: "stream-error-mid",
is_streaming: true,
sequence_number: 1,
},
})
})
})
describe("handleGrpcRequestCancel", () => {
it("should cancel an active request", async () => {
// Register a request in the registry
const registry = getRequestRegistry()
const cleanupStub = sandbox.stub()
registry.registerRequest("cancel-123", cleanupStub)
const cancelRequest: GrpcCancel = {
request_id: "cancel-123",
}
await handleGrpcRequestCancel(mockPostMessageToWebview, cancelRequest)
// Verify the cleanup was called
expect(cleanupStub.calledOnce).to.be.true
// Verify the cancellation confirmation was sent
expect(mockPostMessageToWebview.calledOnce).to.be.true
const sentMessage = mockPostMessageToWebview.firstCall.args[0]
expect(sentMessage).to.deep.equal({
type: "grpc_response",
grpc_response: {
message: { cancelled: true },
request_id: "cancel-123",
is_streaming: false,
},
})
// Verify the request was removed from the registry
expect(registry.hasRequest("cancel-123")).to.be.false
})
it("should handle cancellation of non-existent request", async () => {
const cancelRequest: GrpcCancel = {
request_id: "non-existent",
}
await handleGrpcRequestCancel(mockPostMessageToWebview, cancelRequest)
// Verify no message was sent (request not found)
expect(mockPostMessageToWebview.called).to.be.false
})
it("should handle cleanup errors gracefully", async () => {
// Register a request with a failing cleanup
const registry = getRequestRegistry()
const cleanupStub = sandbox.stub().throws(new Error("Cleanup failed"))
registry.registerRequest("cancel-error", cleanupStub)
const cancelRequest: GrpcCancel = {
request_id: "cancel-error",
}
// Should not throw
await handleGrpcRequestCancel(mockPostMessageToWebview, cancelRequest)
// Verify the cleanup was attempted
expect(cleanupStub.calledOnce).to.be.true
// Verify the cancellation confirmation was still sent
expect(mockPostMessageToWebview.calledOnce).to.be.true
// Verify the request was removed despite the error
expect(registry.hasRequest("cancel-error")).to.be.false
})
})
describe("Concurrent requests", () => {
it("should handle concurrent requests", async () => {
// Set up handlers
mockUnaryHandler.resolves({ result: "unary" })
mockStreamingHandler.callsFake(async (_controller: any, _message: any, responseStream: any) => {
await responseStream({ value: "stream1" }, false, 0)
await responseStream({ value: "stream2" }, true, 1)
})
// Send multiple requests concurrently
const requests = [
handleGrpcRequest(mockController, mockPostMessageToWebview, {
service: serviceName,
method: "testUnary",
message: { id: 1 },
request_id: "concurrent-1",
is_streaming: false,
}),
handleGrpcRequest(mockController, mockPostMessageToWebview, {
service: serviceName,
method: "testStreaming",
message: { id: 2 },
request_id: "concurrent-2",
is_streaming: true,
}),
handleGrpcRequest(mockController, mockPostMessageToWebview, {
service: serviceName,
method: "testUnary",
message: { id: 3 },
request_id: "concurrent-3",
is_streaming: false,
}),
]
await Promise.all(requests)
// Verify all handlers were called
expect(mockUnaryHandler.callCount).to.equal(2)
expect(mockStreamingHandler.callCount).to.equal(1)
// Verify all responses were sent (2 unary + 2 streaming)
expect(mockPostMessageToWebview.callCount).to.equal(4)
})
})
})
})
@@ -1,50 +0,0 @@
import { describe, it } from "bun:test"
import "should"
import { GrpcRecorderNoops } from "@/core/controller/grpc-recorder/grpc-recorder"
import { GrpcRecorderBuilder } from "@/core/controller/grpc-recorder/grpc-recorder.builder"
import { LogFileHandler } from "@/core/controller/grpc-recorder/log-file-handler"
describe("GrpcRecorderBuilder", () => {
describe("when not enabling", () => {
it("should return GrpcRecorderNoops when enableIf is false", () => {
const builder = new GrpcRecorderBuilder()
const recorder = builder.enableIf(false).build()
recorder.should.be.instanceOf(GrpcRecorderNoops)
})
it("should return GrpcRecorderNoops when enableIf is false even with log file handler", () => {
const builder = new GrpcRecorderBuilder()
const logFileHandler = new LogFileHandler()
const recorder = builder.withLogFileHandler(logFileHandler).enableIf(false).build()
recorder.should.be.instanceOf(GrpcRecorderNoops)
})
})
describe("GrpcRecorderNoops functionality", () => {
it("should have no-op methods that don't throw errors", () => {
const recorder = new GrpcRecorderNoops()
recorder.recordRequest({
request_id: "test-id",
service: "TestService",
method: "testMethod",
message: {},
is_streaming: false,
})
recorder.recordResponse("test-id", {
request_id: "test-id",
message: {},
})
recorder.recordError("test-id", "test error")
const sessionLog = recorder.getSessionLog()
sessionLog.should.have.property("startTime").which.is.a.String()
sessionLog.should.have.property("entries").which.is.an.Array()
sessionLog.entries.should.have.length(0)
})
})
})
@@ -1,215 +0,0 @@
import { beforeAll, describe, it } from "bun:test"
import { GrpcRecorder, IRecorder } from "@core/controller/grpc-recorder/grpc-recorder"
import { expect } from "chai"
import { ExtensionMessage } from "@/shared/ExtensionMessage"
import { GrpcRequest } from "@/shared/WebviewMessage"
describe("grpc-recorder", () => {
let recorder: IRecorder
beforeAll(async () => {
recorder = GrpcRecorder.builder()
.withFilters((req: GrpcRequest) => req.service === "the-unwanted-service")
.enableIf(true)
.build()
})
describe("GrpcRecorder", () => {
it("matches multiple request, response and stats", async () => {
interface UseCase {
request: GrpcRequest
response: ExtensionMessage["grpc_response"]
expectedStatus: string
}
const requestResponseUseCases: UseCase[] = [
{
request: {
service: "the-service",
method: "the-method",
message: "the-message",
request_id: "request-id-1",
is_streaming: false,
},
response: {
request_id: "request-id-1",
message: "the-message-response",
error: "",
},
expectedStatus: "completed",
},
{
request: {
service: "streaming-service",
method: "stream-method",
message: { data: "streaming-data", count: 42 },
request_id: "request-id-2",
is_streaming: true,
},
response: {
request_id: "request-id-2",
message: { streamData: "chunk-1" },
error: "",
is_streaming: true,
sequence_number: 1,
},
expectedStatus: "completed",
},
{
request: {
service: "another-service",
method: "another-method",
message: { complex: { nested: "object", array: [1, 2, 3] } },
request_id: "request-id-3",
is_streaming: false,
},
response: {
request_id: "request-id-3",
message: "",
error: "Something went wrong",
},
expectedStatus: "error",
},
]
const initialExpectedStatus = "pending"
requestResponseUseCases.forEach((us: UseCase, index: number) => {
recorder.recordRequest(us.request)
let sessionLog = recorder.getSessionLog()
expect(sessionLog.entries).length(index + 1, `unexpected request_id: ${us.request.request_id}`)
expect(sessionLog.entries[index]).to.include({
service: us.request.service,
method: us.request.method,
isStreaming: us.request.is_streaming,
requestId: us.request.request_id,
status: initialExpectedStatus,
})
if (us.response) {
recorder.recordResponse(us.request.request_id, us.response)
}
sessionLog = recorder.getSessionLog()
expect(sessionLog.entries[index].status).equal(us.expectedStatus)
expect(sessionLog.entries[index].response).to.deep.include({
error: us.response?.error,
})
})
const sessionLog = recorder.getSessionLog()
expect(sessionLog.stats).to.include({
totalRequests: 3,
pendingRequests: 0,
completedRequests: 2,
errorRequests: 1,
})
recorder.recordRequest({
service: "the-unwanted-service",
method: "the-method",
message: "the-message",
request_id: "request-id-1",
is_streaming: false,
})
expect(sessionLog.entries).length(3)
})
it("using default filtering should filter out unwanted requests", async () => {
const customRecorder = GrpcRecorder.builder()
.withFilters(
(req) => req.is_streaming,
(req) => ["cline.UiService", "cline.McpService", "cline.WebService"].includes(req.service),
)
.enableIf(true)
.build()
const unwantedServices = ["cline.UiService", "cline.McpService", "cline.WebService"]
unwantedServices.forEach((us) => {
customRecorder.recordRequest({
service: us,
method: "the-method",
message: "the-message",
request_id: "request-id-1",
is_streaming: false,
})
})
let sessionLog = customRecorder.getSessionLog()
expect(sessionLog.entries).length(0)
customRecorder.recordRequest({
service: "streaming-request",
method: "the-method",
message: "the-message",
request_id: "request-id-1",
is_streaming: true,
})
sessionLog = customRecorder.getSessionLog()
expect(sessionLog.entries).length(0)
})
it("cleanupSyntheticEntries removes synthetic entries from session log", async () => {
const testRecorder = GrpcRecorder.builder().enableIf(true).build()
// Add regular request
testRecorder.recordRequest({
service: "regular-service",
method: "regular-method",
message: "regular-message",
request_id: "regular-id",
is_streaming: false,
})
// Add synthetic request
testRecorder.recordRequest(
{
service: "synthetic-service",
method: "synthetic-method",
message: "synthetic-message",
request_id: "synthetic-id",
is_streaming: false,
},
true, // synthetic = true
)
let sessionLog = testRecorder.getSessionLog()
expect(sessionLog.entries).length(2)
testRecorder.cleanupSyntheticEntries()
sessionLog = testRecorder.getSessionLog()
expect(sessionLog.entries).length(1)
expect(sessionLog.entries[0].requestId).equal("regular-id")
})
it("recordResponse executes post-record hooks", async () => {
let hookExecuted = false
let hookEntry: any = null
const mockHook = async (entry: any) => {
hookExecuted = true
hookEntry = entry
}
const testRecorder = GrpcRecorder.builder().withPostRecordHooks(mockHook).enableIf(true).build()
testRecorder.recordRequest({
service: "test-service",
method: "test-method",
message: "test-message",
request_id: "test-id",
is_streaming: false,
})
testRecorder.recordResponse("test-id", {
request_id: "test-id",
message: "response-message",
error: "",
})
expect(hookExecuted).to.be.true
expect(hookEntry).to.not.be.null
expect(hookEntry.requestId).equal("test-id")
})
})
})
@@ -1,19 +0,0 @@
import { beforeAll, describe, it } from "bun:test"
import { expect } from "chai"
import { LogFileHandler } from "@/core/controller/grpc-recorder/log-file-handler"
describe("log-file-handler", () => {
let logHandler: LogFileHandler
beforeAll(async () => {
logHandler = new LogFileHandler()
expect(logHandler.getFilePath()).not.empty
})
describe("LogFileHandler", () => {
it("returns file name with timestamp when env var not set", () => {
const result = logHandler.getFileName()
expect(result).to.contains("grpc_recorded_session")
})
})
})
@@ -1,69 +0,0 @@
import { afterEach, beforeEach, describe, it } from "bun:test"
import "should"
import { Controller } from "@core/controller"
import { IRecorder } from "@core/controller/grpc-recorder/grpc-recorder"
import { GrpcRecorderBuilder } from "@core/controller/grpc-recorder/grpc-recorder.builder"
import { testHooks } from "@core/controller/grpc-recorder/test-hooks"
import { GrpcLogEntry } from "@core/controller/grpc-recorder/types"
import * as sinon from "sinon"
describe("test-hooks", () => {
let cleanupSyntheticEntriesStub: sinon.SinonStub
let recordRequestStub: sinon.SinonStub
let recordResponseStub: sinon.SinonStub
let getRecorderStub: sinon.SinonStub
beforeEach(() => {
cleanupSyntheticEntriesStub = sinon.stub()
recordRequestStub = sinon.stub()
recordResponseStub = sinon.stub()
const mockRecorder: IRecorder = {
cleanupSyntheticEntries: cleanupSyntheticEntriesStub,
recordRequest: recordRequestStub,
recordResponse: recordResponseStub,
recordError: sinon.stub(),
getSessionLog: sinon.stub().returns({ startTime: "", entries: [] }),
}
getRecorderStub = sinon.stub(GrpcRecorderBuilder, "getRecorder").returns(mockRecorder)
})
afterEach(() => {
sinon.restore()
})
it("should return an array of post-record hooks", () => {
const mockController = {} as Controller
const hooks = testHooks(mockController)
hooks.should.be.an.Array()
hooks.should.have.length(1)
hooks[0].should.be.a.Function()
})
it("should execute hook and call recorder methods", async () => {
const mockController = {
getStateToPostToWebview: sinon.stub().returns({}),
} as any as Controller
const hooks = testHooks(mockController)
const mockEntry: GrpcLogEntry = {
requestId: "test-request-id",
service: "TestService",
method: "testMethod",
isStreaming: false,
request: { message: {} },
status: "pending",
}
await hooks[0](mockEntry)
// Validate sinon stub calls
sinon.assert.calledWith(getRecorderStub, mockController)
sinon.assert.calledOnce(cleanupSyntheticEntriesStub)
sinon.assert.calledOnce(recordRequestStub)
sinon.assert.calledOnce(recordResponseStub)
})
})
+131 -5
View File
@@ -1,7 +1,133 @@
// Replaces classic src/core/controller/index.ts (see origin/main)
// MINIMAL INERT CONTROLLER
//
// The Controller class is now provided by the SDK adapter layer.
// All gRPC handler modules in this directory continue to work as the
// thunking layer between the webview and the SDK.
// The full Controller (Task loop, providers, models, sessions, MCP, hooks,
// services, SDK bridge, storage) has been removed. This is a bare, constructable
// shell that exists only so the surviving plumbing keeps compiling and running:
//
// - core/webview/WebviewProvider.ts -> `new Controller(context)` + `dispose()`
// - core/controller/grpc-handler.ts -> uses `Controller` as a type only
// - core/controller/grpc-recorder/** -> uses `Controller` as a type; test-hooks
// calls getLatestState -> getStateToPostToWebview()
// - core/controller/<group>/*.ts -> gutted handlers reference these members
//
// Every member here is a no-op / empty-default. Nothing actually works.
export { Controller } from "@/sdk/SdkController"
import type { ExtensionState } from "@shared/ExtensionMessage"
import { ClineExtensionContext } from "@/shared/cline"
export class Controller {
readonly context: ClineExtensionContext
// Inert state holders that gutted handlers may read. They are deliberately
// typed loosely (`any`) because their real backing implementations were removed.
readonly stateManager: any = createInertStateManager()
task: any = undefined
accountService: any = undefined
terminalManager: any = undefined
workspaceManager: any = undefined
backgroundCommandRunning?: boolean = false
backgroundCommandTaskId?: string = undefined
constructor(context: ClineExtensionContext) {
this.context = context
}
async dispose(): Promise<void> {
// no-op: nothing to tear down in the inert shell
}
// --- state ---
async getStateToPostToWebview(): Promise<ExtensionState> {
// The inert shell has no real state to build.
return {} as unknown as ExtensionState
}
async postStateToWebview(): Promise<void> {
// no-op
}
// --- providers / models ---
getProviderCatalog(): any {
return createInertProviderCatalog()
}
getProviderConfigStore(): any {
return createInertProviderConfigStore()
}
async handleApiConfigurationChanged(_previous?: any, _next?: any): Promise<void> {
// no-op
}
async readOpenRouterModels(): Promise<any> {
return undefined
}
// --- task ---
async initTask(_text?: string, _images?: string[], _files?: string[], _historyItem?: any, _settings?: any): Promise<void> {
// no-op
}
async showTaskWithId(_id: string): Promise<void> {
// no-op
}
async exportTaskWithId(_id: string): Promise<void> {
// no-op
}
async getTaskHistory(_request?: any): Promise<any> {
return undefined
}
async toggleTaskFavorite(_taskId: string, _isFavorited: boolean): Promise<void> {
// no-op
}
async editMessageAndRegenerate(..._args: any[]): Promise<void> {
// no-op
}
// --- mode / telemetry ---
async togglePlanActMode(_mode?: any, _chatContent?: any): Promise<any> {
return undefined
}
async updateTelemetrySetting(_setting?: any): Promise<void> {
// no-op
}
}
function createInertStateManager(): any {
return {
getApiConfiguration: () => ({}),
getGlobalStateKey: (_key: string) => undefined,
getGlobalSettingsKey: (_key: string) => undefined,
getSecretKey: (_key: string) => undefined,
setGlobalState: (_key: string, _value: unknown) => {},
setGlobalStateBatch: (_values: unknown) => {},
setSecretsBatch: (_values: unknown) => {},
setApiConfiguration: (_config: unknown) => {},
setTaskSettings: (_settings: unknown) => {},
setTaskSettingsBatch: (_settings: unknown) => {},
flushPendingState: async () => {},
}
}
function createInertProviderCatalog(): any {
return {
listProviders: async () => [],
resolveModels: async () => ({}),
}
}
function createInertProviderConfigStore(): any {
return {
read: (_id: unknown) => ({}),
commitSelection: (_id: unknown, _mode: unknown, _selection: unknown) => {},
}
}
@@ -1,34 +1,7 @@
import type { AddRemoteMcpServerRequest } from "@shared/proto/cline/mcp"
import { McpServers } from "@shared/proto/cline/mcp"
import { convertMcpServersToProtoMcpServers } from "@/shared/proto-conversions/mcp/mcp-server-conversion"
import { Logger } from "@/shared/services/Logger"
import type { Controller } from "../index"
/**
* Adds a new remote MCP server via gRPC
* @param controller The controller instance
* @param request The request containing server name and URL
* @returns An array of McpServer objects
*/
export async function addRemoteMcpServer(controller: Controller, request: AddRemoteMcpServerRequest): Promise<McpServers> {
try {
// Validate required fields
if (!request.serverName) {
throw new Error("Server name is required")
}
if (!request.serverUrl) {
throw new Error("Server URL is required")
}
// Call the McpHub method to add the remote server
const servers = await controller.mcpHub?.addRemoteServer(request.serverName, request.serverUrl, request.transportType)
const protoServers = convertMcpServersToProtoMcpServers(servers)
return McpServers.create({ mcpServers: protoServers })
} catch (error) {
Logger.error(`Failed to add remote MCP server ${request.serverName}:`, error)
throw error
}
export async function addRemoteMcpServer(_controller: Controller, _request: AddRemoteMcpServerRequest): Promise<McpServers> {
return McpServers.create({})
}
@@ -1,27 +1,7 @@
import type { StringRequest } from "@shared/proto/cline/common"
import { Empty } from "@shared/proto/cline/common"
import { Logger } from "@/shared/services/Logger"
import type { Controller } from "../index"
/**
* Initiates OAuth authentication for an MCP server
* @param controller The controller instance
* @param request The request containing server name
* @returns Empty response
*/
export async function authenticateMcpServer(controller: Controller, request: StringRequest): Promise<Empty> {
try {
const serverName = request.value
if (!serverName) {
throw new Error("Server name is required")
}
// Call the McpHub method to initiate OAuth
await controller.mcpHub?.initiateOAuth(serverName)
return Empty.create()
} catch (error) {
Logger.error(`Failed to initiate OAuth for MCP server:`, error)
throw error
}
export async function authenticateMcpServer(_controller: Controller, _request: StringRequest): Promise<Empty> {
return Empty.create({})
}
@@ -1,26 +1,7 @@
import { StringRequest } from "@shared/proto/cline/common"
import { McpServers } from "@shared/proto/cline/mcp"
import { Logger } from "@/shared/services/Logger"
import { convertMcpServersToProtoMcpServers } from "../../../shared/proto-conversions/mcp/mcp-server-conversion"
import type { Controller } from "../index"
/**
* Deletes an MCP server
* @param controller The controller instance
* @param request The delete server request
* @returns The list of remaining MCP servers after deletion
*/
export async function deleteMcpServer(controller: Controller, request: StringRequest): Promise<McpServers> {
try {
// Call the RPC variant to delete the server and get updated server list
const mcpServers = (await controller.mcpHub?.deleteServerRPC(request.value)) || []
// Convert application types to protobuf types
const protoServers = convertMcpServersToProtoMcpServers(mcpServers)
return McpServers.create({ mcpServers: protoServers })
} catch (error) {
Logger.error(`Failed to delete MCP server: ${error}`)
throw error
}
export async function deleteMcpServer(_controller: Controller, _request: StringRequest): Promise<McpServers> {
return McpServers.create({})
}
@@ -1,26 +1,7 @@
import type { Empty } from "@shared/proto/cline/common"
import { McpServers } from "@shared/proto/cline/mcp"
import { convertMcpServersToProtoMcpServers } from "@/shared/proto-conversions/mcp/mcp-server-conversion"
import { Logger } from "@/shared/services/Logger"
import type { Controller } from "../index"
/**
* RPC handler for getting the latest MCP servers
* @param controller The controller instance
* @param _request Empty request
* @returns McpServers response with list of all MCP servers
*/
export async function getLatestMcpServers(controller: Controller, _request: Empty): Promise<McpServers> {
try {
// Get sorted servers from mcpHub using the RPC variant
const mcpServers = (await controller.mcpHub?.getLatestMcpServersRPC()) || []
// Convert to proto format
const protoServers = convertMcpServersToProtoMcpServers(mcpServers)
return McpServers.create({ mcpServers: protoServers })
} catch (error) {
Logger.error("Error fetching latest MCP servers:", error)
throw error
}
export async function getLatestMcpServers(_controller: Controller, _request: Empty): Promise<McpServers> {
return McpServers.create({})
}
@@ -1,17 +1,6 @@
import { openFile as openFileIntegration } from "@integrations/misc/open-file"
import { Empty, EmptyRequest } from "@shared/proto/cline/common"
import { Controller } from ".."
/**
* Opens the MCP settings file in the editor
* @param controller The controller instance
* @param _request Empty request
* @returns Empty response
*/
export async function openMcpSettings(controller: Controller, _request: EmptyRequest): Promise<Empty> {
const mcpSettingsFilePath = await controller.mcpHub?.getMcpSettingsFilePath()
if (mcpSettingsFilePath) {
await openFileIntegration(mcpSettingsFilePath)
}
return Empty.create()
export async function openMcpSettings(_controller: Controller, _request: EmptyRequest): Promise<Empty> {
return Empty.create({})
}
@@ -1,25 +1,7 @@
import { StringRequest } from "@shared/proto/cline/common"
import { McpServers } from "@shared/proto/cline/mcp"
import { convertMcpServersToProtoMcpServers } from "@shared/proto-conversions/mcp/mcp-server-conversion"
import { Logger } from "@/shared/services/Logger"
import type { Controller } from "../index"
/**
* Restarts an MCP server connection
* @param controller The controller instance
* @param request The request containing the server name
* @returns The updated list of MCP servers
*/
export async function restartMcpServer(controller: Controller, request: StringRequest): Promise<McpServers> {
try {
const mcpServers = await controller.mcpHub?.restartConnectionRPC(request.value)
// Convert from McpServer[] to ProtoMcpServer[] ensuring all required fields are set
const protoServers = convertMcpServersToProtoMcpServers(mcpServers)
return McpServers.create({ mcpServers: protoServers })
} catch (error) {
Logger.error(`Failed to restart MCP server ${request.value}:`, error)
throw error
}
export async function restartMcpServer(_controller: Controller, _request: StringRequest): Promise<McpServers> {
return McpServers.create({})
}
@@ -1,77 +1,17 @@
import { EmptyRequest } from "@shared/proto/cline/common"
import { McpServers } from "@shared/proto/cline/mcp"
import { convertMcpServersToProtoMcpServers } from "@shared/proto-conversions/mcp/mcp-server-conversion"
import { Logger } from "@/shared/services/Logger"
import { getRequestRegistry, StreamingResponseHandler } from "../grpc-handler"
import { StreamingResponseHandler } from "../grpc-handler"
import { Controller } from "../index"
// Keep track of active subscriptions
const activeMcpServersSubscriptions = new Set<StreamingResponseHandler<McpServers>>()
/**
* Subscribe to MCP servers events
* @param controller The controller instance
* @param request The empty request
* @param responseStream The streaming response handler
* @param requestId The ID of the request (passed by the gRPC handler)
*/
export async function subscribeToMcpServers(
controller: Controller,
_controller: Controller,
_request: EmptyRequest,
responseStream: StreamingResponseHandler<McpServers>,
requestId?: string,
_responseStream: StreamingResponseHandler<McpServers>,
_requestId?: string,
): Promise<void> {
// Add this subscription to the active subscriptions
activeMcpServersSubscriptions.add(responseStream)
// Register cleanup when the connection is closed
const cleanup = () => {
activeMcpServersSubscriptions.delete(responseStream)
}
// Register the cleanup function with the request registry if we have a requestId
if (requestId) {
getRequestRegistry().registerRequest(requestId, cleanup, { type: "mcpServers_subscription" }, responseStream)
}
// Send initial state if available
if (controller.mcpHub) {
const mcpServers = controller.mcpHub.getServers()
if (mcpServers.length > 0) {
try {
const protoServers = McpServers.create({
mcpServers: convertMcpServersToProtoMcpServers(mcpServers),
})
await responseStream(
protoServers,
false, // Not the last message
)
} catch (error) {
Logger.error("Error sending initial MCP servers:", error)
activeMcpServersSubscriptions.delete(responseStream)
}
}
}
return
}
/**
* Send an MCP servers update to all active subscribers
* @param mcpServers The MCP servers to send
*/
export async function sendMcpServersUpdate(mcpServers: McpServers): Promise<void> {
// Send the event to all active subscribers
const promises = Array.from(activeMcpServersSubscriptions).map(async (responseStream) => {
try {
await responseStream(
mcpServers,
false, // Not the last message
)
} catch (error) {
Logger.error("Error sending MCP servers update:", error)
// Remove the subscription if there was an error
activeMcpServersSubscriptions.delete(responseStream)
}
})
await Promise.all(promises)
export async function sendMcpServersUpdate(_mcpServers: McpServers): Promise<void> {
return
}
@@ -1,25 +1,7 @@
import type { ToggleMcpServerRequest } from "@shared/proto/cline/mcp"
import { McpServers } from "@shared/proto/cline/mcp"
import { Logger } from "@/shared/services/Logger"
import { convertMcpServersToProtoMcpServers } from "../../../shared/proto-conversions/mcp/mcp-server-conversion"
import type { Controller } from "../index"
/**
* Toggles an MCP server's enabled/disabled status
* @param controller The controller instance
* @param request The request containing server ID and disabled status
* @returns A response indicating success or failure
*/
export async function toggleMcpServer(controller: Controller, request: ToggleMcpServerRequest): Promise<McpServers> {
try {
const mcpServers = await controller.mcpHub?.toggleServerDisabledRPC(request.serverName, request.disabled)
// Convert from McpServer[] to ProtoMcpServer[] ensuring all required fields are set
const protoServers = convertMcpServersToProtoMcpServers(mcpServers)
return McpServers.create({ mcpServers: protoServers })
} catch (error) {
Logger.error(`Failed to toggle MCP server ${request.serverName}:`, error)
throw error
}
export async function toggleMcpServer(_controller: Controller, _request: ToggleMcpServerRequest): Promise<McpServers> {
return McpServers.create({})
}
@@ -1,25 +1,10 @@
import type { ToggleToolAutoApproveRequest } from "@shared/proto/cline/mcp"
import { McpServers } from "@shared/proto/cline/mcp"
import { convertMcpServersToProtoMcpServers } from "@shared/proto-conversions/mcp/mcp-server-conversion"
import { Logger } from "@/shared/services/Logger"
import type { Controller } from "../index"
/**
* Toggles auto-approve setting for MCP server tools
* @param controller The controller instance
* @param request The toggle tool auto-approve request
* @returns Updated list of MCP servers
*/
export async function toggleToolAutoApprove(controller: Controller, request: ToggleToolAutoApproveRequest): Promise<McpServers> {
try {
// Call the RPC variant that returns the servers directly
const mcpServers =
(await controller.mcpHub?.toggleToolAutoApproveRPC(request.serverName, request.toolNames, request.autoApprove)) || []
// Convert application types to proto types
return McpServers.create({ mcpServers: convertMcpServersToProtoMcpServers(mcpServers) })
} catch (error) {
Logger.error(`Failed to toggle tool auto-approve for ${request.serverName}:`, error)
throw error
}
export async function toggleToolAutoApprove(
_controller: Controller,
_request: ToggleToolAutoApproveRequest,
): Promise<McpServers> {
return McpServers.create({})
}
@@ -1,27 +1,6 @@
import { McpServers, UpdateMcpTimeoutRequest } from "@shared/proto/cline/mcp"
import { convertMcpServersToProtoMcpServers } from "@/shared/proto-conversions/mcp/mcp-server-conversion"
import { Logger } from "@/shared/services/Logger"
import { Controller } from ".."
/**
* Updates the timeout configuration for an MCP server.
* @param controller - The Controller instance
* @param request - Contains server name and timeout value
* @returns Array of updated McpServer objects
*/
export async function updateMcpTimeout(controller: Controller, request: UpdateMcpTimeoutRequest): Promise<McpServers> {
try {
if (request.serverName && typeof request.serverName === "string" && typeof request.timeout === "number") {
const mcpServers = await controller.mcpHub?.updateServerTimeoutRPC(request.serverName, request.timeout)
const convertedMcpServers = convertMcpServersToProtoMcpServers(mcpServers)
Logger.log("convertedMcpServers", convertedMcpServers)
return McpServers.create({ mcpServers: convertedMcpServers })
} else {
Logger.error("Server name and timeout are required")
throw new Error("Server name and timeout are required")
}
} catch (error) {
Logger.error(`Failed to update timeout for server ${request.serverName}:`, error)
throw error
}
export async function updateMcpTimeout(_controller: Controller, _request: UpdateMcpTimeoutRequest): Promise<McpServers> {
return McpServers.create({})
}
@@ -1,60 +0,0 @@
import * as assert from "assert"
import { afterEach, beforeEach, describe, it } from "mocha"
import sinon from "sinon"
import { Logger } from "@/shared/services/Logger"
import type { Controller } from "../../index"
import { clearOrganizationForClinePassProviderSelection } from "../handleClinePassProviderSelection"
describe("clearOrganizationForClinePassProviderSelection", () => {
let sandbox: sinon.SinonSandbox
let switchAccount: sinon.SinonStub
beforeEach(() => {
sandbox = sinon.createSandbox()
switchAccount = sandbox.stub().resolves()
sandbox.stub(Logger, "debug")
})
afterEach(() => {
sandbox.restore()
})
function createController(): Controller {
return {
accountService: { switchAccount },
} as unknown as Controller
}
it("does nothing when Cline Pass is not selected", async () => {
await clearOrganizationForClinePassProviderSelection(createController(), {
planModeApiProvider: "cline",
actModeApiProvider: "openrouter",
})
assert.strictEqual(switchAccount.callCount, 0)
})
it("switches to the personal account when Cline Pass is selected", async () => {
await clearOrganizationForClinePassProviderSelection(createController(), {
planModeApiProvider: "cline-pass",
actModeApiProvider: "openrouter",
})
assert.strictEqual(switchAccount.callCount, 1)
assert.strictEqual(switchAccount.firstCall.args[0], null)
})
it("logs and swallows account switch failures", async () => {
const error = new Error("not signed in")
switchAccount.rejects(error)
await clearOrganizationForClinePassProviderSelection(createController(), {
planModeApiProvider: "cline",
actModeApiProvider: "cline-pass",
})
assert.strictEqual(switchAccount.callCount, 1)
assert.strictEqual(switchAccount.firstCall.args[0], null)
assert.ok((Logger.debug as sinon.SinonStub).calledOnce)
})
})
@@ -1,289 +0,0 @@
import type { ApiConfiguration } from "@shared/api"
import { afterEach, describe, expect, it, vi } from "vitest"
import type { EffectiveProviderConfig, ProviderCatalog, ProviderConfigStore } from "@/sdk/model-catalog/contracts"
import { computeConfigFingerprint } from "@/sdk/model-catalog/fingerprint"
import { parseProviderId } from "@/sdk/model-catalog/provider-id"
import { ApiFormat, OpenRouterModelInfo } from "@/shared/proto/cline/models"
import type { ProviderCatalogController } from "../providerCatalogShared"
type TestStateManager = {
setGlobalStateBatch: ReturnType<typeof vi.fn>
getApiConfiguration?: ReturnType<typeof vi.fn<() => ApiConfiguration | undefined>>
}
function makeStore(config: EffectiveProviderConfig): ProviderConfigStore {
return {
read: vi.fn(() => config),
readSelection: vi.fn(() => undefined),
subscribe: vi.fn(() => ({ dispose: vi.fn() })),
write: vi.fn(() => config),
commitSelection: vi.fn(),
}
}
function makeCatalog(): ProviderCatalog {
return {
listProviders: vi.fn(async () => []),
resolveModels: vi.fn(),
peekModels: vi.fn(),
subscribe: vi.fn(() => ({ dispose: vi.fn() })),
}
}
function makeController(
store: ProviderConfigStore,
catalog: ProviderCatalog,
stateManager?: TestStateManager,
handleApiConfigurationChanged?: ReturnType<typeof vi.fn<(previous: ApiConfiguration, next: ApiConfiguration) => void>>,
): ProviderCatalogController {
return {
getProviderConfigStore: () => store,
getProviderCatalog: () => catalog,
...(stateManager ? { stateManager } : {}),
...(handleApiConfigurationChanged ? { handleApiConfigurationChanged } : {}),
}
}
describe("provider model catalog handlers", () => {
afterEach(() => {
vi.clearAllMocks()
})
it("listProviders returns provider listings from the catalog singleton", async () => {
const { listProviders } = await import("../listProviders")
const providerId = parseProviderId("deepseek")
const store = makeStore({ providerId })
const catalog = makeCatalog()
vi.mocked(catalog.listProviders).mockResolvedValue([
{
id: providerId,
name: "DeepSeek",
defaultModelId: "deepseek-v4-flash",
protocol: "openai-chat",
authDescription: "DeepSeek models",
allowsCustomModelIds: false,
usageCostDisplay: "show",
},
])
const controller = makeController(store, catalog)
const response = await listProviders(controller, {})
expect(response.providers).toEqual([
{
id: "deepseek",
name: "DeepSeek",
defaultModelId: "deepseek-v4-flash",
family: undefined,
protocol: "openai-chat",
authDescription: "DeepSeek models",
baseUrlDescription: undefined,
allowsCustomModelIds: false,
usageCostDisplay: "show",
},
])
expect(catalog.listProviders).toHaveBeenCalledTimes(1)
})
it("resolveProviderModels returns full protobuf model metadata and request id", async () => {
const { resolveProviderModels } = await import("../resolveProviderModels")
const providerId = parseProviderId("deepseek")
const fingerprint = computeConfigFingerprint(providerId, { providerId, apiKey: "secret" })
const store = makeStore({ providerId, apiKey: "secret" })
const catalog = makeCatalog()
vi.mocked(catalog.resolveModels).mockResolvedValue({
ok: true,
providerId,
configFingerprint: fingerprint,
models: new Map([
[
"deepseek-v4-flash",
{
name: "DeepSeek V4 Flash",
maxTokens: 123,
contextWindow: 456,
supportsImages: true,
supportsPromptCache: true,
supportsReasoning: true,
inputPrice: 1,
outputPrice: 2,
cacheWritesPrice: 3,
cacheReadsPrice: 4,
description: "rich metadata",
temperature: 0.2,
apiFormat: ApiFormat.OPENAI_CHAT,
},
],
]),
defaultModelId: "deepseek-v4-flash",
source: "sdk-dynamic",
fetchedAt: 99,
})
const controller = makeController(store, catalog)
const response = await resolveProviderModels(controller, {
providerId: "deepseek",
forceRefresh: true,
requestId: "req-1",
})
expect(response.requestId).toBe("req-1")
expect(response.configFingerprint).toBe(fingerprint)
expect(response.models["deepseek-v4-flash"]).toMatchObject({
name: "DeepSeek V4 Flash",
maxTokens: 123,
contextWindow: 456,
supportsImages: true,
supportsPromptCache: true,
supportsReasoning: true,
temperature: 0.2,
apiFormat: ApiFormat.OPENAI_CHAT,
})
expect(catalog.resolveModels).toHaveBeenCalledWith(providerId, { forceRefresh: true })
})
it("readProviderConfig redacts secrets", async () => {
const { readProviderConfig } = await import("../readProviderConfig")
const providerId = parseProviderId("cline")
const store = makeStore({
providerId,
apiKey: "SECRET_SENTINEL_API_KEY",
baseUrl: "https://api.example.com/v1",
auth: { accessToken: "SECRET_SENTINEL_ACCESS", refreshToken: "SECRET_SENTINEL_REFRESH", accountId: "acct-1" },
})
const controller = makeController(store, makeCatalog())
const response = await readProviderConfig(controller, { value: "cline" })
expect(response).toMatchObject({
providerId: "cline",
baseUrl: "https://api.example.com/v1",
apiKeyLength: "SECRET_SENTINEL_API_KEY".length,
hasAccessToken: true,
hasRefreshToken: true,
accountId: "acct-1",
})
expect(JSON.stringify(response)).not.toContain("SECRET_SENTINEL")
})
it("writeProviderConfig writes a patch and returns redacted updated config", async () => {
const { writeProviderConfig } = await import("../writeProviderConfig")
const providerId = parseProviderId("ollama")
const updatedConfig: EffectiveProviderConfig = {
providerId,
apiKey: "SECRET_SENTINEL_OLLAMA",
baseUrl: "http://localhost:11434/v1",
}
const store = makeStore(updatedConfig)
const controller = makeController(store, makeCatalog())
const response = await writeProviderConfig(controller, {
providerId: "ollama",
patch: { apiKey: "SECRET_SENTINEL_OLLAMA", baseUrl: "http://localhost:11434/v1", headers: {} },
})
expect(store.write).toHaveBeenCalledWith(providerId, {
apiKey: "SECRET_SENTINEL_OLLAMA",
baseUrl: "http://localhost:11434/v1",
})
expect(response.apiKeyLength).toBe("SECRET_SENTINEL_OLLAMA".length)
expect(JSON.stringify(response)).not.toContain("SECRET_SENTINEL")
})
it("writeProviderConfig can explicitly clear headers", async () => {
const { writeProviderConfig } = await import("../writeProviderConfig")
const providerId = parseProviderId("openai")
const updatedConfig: EffectiveProviderConfig = {
providerId,
headers: {},
}
const store = makeStore(updatedConfig)
const controller = makeController(store, makeCatalog())
await writeProviderConfig(controller, {
providerId: "openai",
patch: { headers: {}, clearHeaders: true },
})
expect(store.write).toHaveBeenCalledWith(providerId, { headers: {} })
})
it("commitModelSelection validates mode and commits the full selection envelope", async () => {
const { commitModelSelection } = await import("../commitModelSelection")
const providerId = parseProviderId("deepseek")
const store = makeStore({ providerId })
const stateManager: TestStateManager = { setGlobalStateBatch: vi.fn() }
const controller = makeController(store, makeCatalog(), stateManager)
await commitModelSelection(controller, {
providerId: "deepseek",
mode: "act",
modelId: "deepseek-v4-flash",
modelInfo: OpenRouterModelInfo.create({
name: "DeepSeek V4 Flash",
contextWindow: 456,
supportsPromptCache: true,
apiFormat: ApiFormat.OPENAI_CHAT,
}),
})
expect(store.commitSelection).toHaveBeenCalledWith(providerId, "act", {
providerId,
modelId: "deepseek-v4-flash",
modelInfo: expect.objectContaining({
name: "DeepSeek V4 Flash",
contextWindow: 456,
supportsPromptCache: true,
apiFormat: ApiFormat.OPENAI_CHAT,
}),
})
expect(stateManager.setGlobalStateBatch).toHaveBeenCalledWith({
actModeApiProvider: "deepseek",
actModeApiModelId: "deepseek-v4-flash",
})
})
it("commitModelSelection reports provider changes when config is initialized", async () => {
const { commitModelSelection } = await import("../commitModelSelection")
const providerId = parseProviderId("deepseek")
const store = makeStore({ providerId })
const stateManager: TestStateManager = {
setGlobalStateBatch: vi.fn(),
getApiConfiguration: vi
.fn<() => ApiConfiguration | undefined>()
.mockReturnValueOnce(undefined)
.mockReturnValueOnce({ actModeApiProvider: "deepseek" }),
}
const handleApiConfigurationChanged = vi.fn<(previous: ApiConfiguration, next: ApiConfiguration) => void>()
const controller = makeController(store, makeCatalog(), stateManager, handleApiConfigurationChanged)
await commitModelSelection(controller, {
providerId: "deepseek",
mode: "act",
modelId: "deepseek-v4-flash",
modelInfo: OpenRouterModelInfo.create({
name: "DeepSeek V4 Flash",
apiFormat: ApiFormat.OPENAI_CHAT,
}),
})
expect(handleApiConfigurationChanged).toHaveBeenCalledWith({}, { actModeApiProvider: "deepseek" })
})
it("commitModelSelection rejects invalid mode", async () => {
const { commitModelSelection } = await import("../commitModelSelection")
const providerId = parseProviderId("deepseek")
const store = makeStore({ providerId })
const controller = makeController(store, makeCatalog())
await expect(
commitModelSelection(controller, {
providerId: "deepseek",
mode: "invalid",
modelId: "deepseek-v4-flash",
modelInfo: OpenRouterModelInfo.create({ supportsPromptCache: true }),
}),
).rejects.toThrow('mode must be "plan" or "act"')
expect(store.commitSelection).not.toHaveBeenCalled()
})
})
@@ -1,89 +0,0 @@
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"
import { StateManager } from "@/core/storage/StateManager"
import { createProviderCatalog } from "@/sdk/model-catalog/catalog"
import type { ProviderCatalog, ProviderConfigStore } from "@/sdk/model-catalog/contracts"
import { createProviderConfigStore } from "@/sdk/model-catalog/store"
import { Empty, StringRequest } from "@/shared/proto/cline/common"
import { CommitModelSelectionRequest } from "@/shared/proto/cline/models"
import { createStorageContext } from "@/shared/storage/storage-context"
import { commitModelSelection } from "../commitModelSelection"
import { listProviders } from "../listProviders"
import type { ProviderCatalogController } from "../providerCatalogShared"
import { readProviderConfig } from "../readProviderConfig"
import { resolveProviderModels } from "../resolveProviderModels"
vi.mock("@/services/logging/distinctId", () => ({
initializeDistinctId: vi.fn(async () => undefined),
}))
describe("provider model catalog backend smoke", () => {
let clineDir: string
let store: ProviderConfigStore
let catalog: ProviderCatalog
let controller: ProviderCatalogController
let originalClineDir: string | undefined
beforeAll(async () => {
clineDir = await fs.mkdtemp(path.join(os.tmpdir(), "cline-provider-catalog-smoke-"))
originalClineDir = process.env.CLINE_DIR
process.env.CLINE_DIR = clineDir
await StateManager.initialize(createStorageContext({ clineDir, workspacePath: clineDir }))
store = createProviderConfigStore()
catalog = createProviderCatalog(store)
controller = {
getProviderConfigStore: () => store,
getProviderCatalog: () => catalog,
}
})
afterAll(async () => {
await StateManager.get().flushPendingState()
if (originalClineDir === undefined) {
delete process.env.CLINE_DIR
} else {
process.env.CLINE_DIR = originalClineDir
}
await StateManager.get().reInitialize()
await fs.rm(clineDir, { recursive: true, force: true })
})
it("lists providers, resolves DeepSeek models, and round-trips committed selection", async () => {
const providers = await listProviders(controller, Empty.create())
expect(providers.providers.length).toBeGreaterThanOrEqual(4)
expect(providers.providers.some((provider) => provider.id === "deepseek")).toBe(true)
const models = await resolveProviderModels(controller, {
providerId: "deepseek",
forceRefresh: false,
requestId: "smoke-request",
})
expect(models.ok).toBe(true)
expect(models.requestId).toBe("smoke-request")
expect(Object.keys(models.models).length).toBeGreaterThanOrEqual(4)
const modelId = models.defaultModelId || Object.keys(models.models)[0]
expect(modelId).toBeTruthy()
const modelInfo = models.models[modelId]
expect(modelInfo).toBeDefined()
await commitModelSelection(
controller,
CommitModelSelectionRequest.create({
providerId: "deepseek",
mode: "act",
modelId,
modelInfo,
}),
)
const config = await readProviderConfig(controller, StringRequest.create({ value: "deepseek" }))
expect(config.providerId).toBe("deepseek")
expect(config.actSelection?.providerId).toBe("deepseek")
expect(config.actSelection?.modelId).toBe(modelId)
expect(config.actSelection?.modelInfo).toEqual(modelInfo)
expect(JSON.stringify(config)).not.toContain("SECRET")
})
})
@@ -1,113 +0,0 @@
import { MODEL_COLLECTIONS_BY_PROVIDER_ID } from "@cline/llms"
import { describe, expect, it, vi } from "vitest"
import type { EffectiveProviderConfig, ProviderConfigStore } from "@/sdk/model-catalog/contracts"
import { parseProviderId } from "@/sdk/model-catalog/provider-id"
import { normalizeProviderSwitchModel } from "../providerSwitchNormalization"
function makeStore(config: EffectiveProviderConfig): ProviderConfigStore {
return {
read: vi.fn(() => config),
readSelection: vi.fn(() => undefined),
subscribe: vi.fn(() => ({ dispose: vi.fn() })),
write: vi.fn(() => config),
commitSelection: vi.fn(),
}
}
describe("normalizeProviderSwitchModel", () => {
it("uses the SDK default when switching to DeepSeek with a stale Anthropic model id", () => {
const providerId = parseProviderId("deepseek")
const defaultModelId = MODEL_COLLECTIONS_BY_PROVIDER_ID.deepseek.provider.defaultModelId
const store = makeStore({ providerId })
const normalized = normalizeProviderSwitchModel(
store,
{ actModeApiProvider: "anthropic", actModeApiModelId: "claude-sonnet-4-5-20250929" },
{ actModeApiProvider: "deepseek", actModeApiModelId: undefined },
)
expect(normalized.actModeApiProvider).toBe("deepseek")
expect(normalized.actModeApiModelId).toBe(defaultModelId)
})
it("uses the SDK default when switching to Gemini with a stale DeepSeek model id", () => {
const providerId = parseProviderId("gemini")
const store = makeStore({ providerId })
const normalized = normalizeProviderSwitchModel(
store,
{ actModeApiProvider: "deepseek", actModeApiModelId: "deepseek-v4-flash" },
{ actModeApiProvider: "gemini", actModeApiModelId: undefined },
)
expect(normalized.actModeApiProvider).toBe("gemini")
// The Gemini SDK manifest currently has a provider default that is not in
// the generated model catalog. Provider-switch normalization must match the
// model picker/catalog default instead of writing the invalid manifest value.
expect(MODEL_COLLECTIONS_BY_PROVIDER_ID.gemini.provider.defaultModelId).toBe("gemma-4-26b")
expect(normalized.actModeApiModelId).toBe("gemini-3.5-flash")
})
it("restores a previously committed DeepSeek selection before falling back to SDK default", () => {
const providerId = parseProviderId("deepseek")
const store = makeStore({ providerId })
vi.mocked(store.readSelection).mockReturnValue({
providerId,
modelId: "deepseek-v4-pro",
modelInfo: { name: "DeepSeek V4 Pro", contextWindow: 1_000_000, supportsPromptCache: true },
})
const normalized = normalizeProviderSwitchModel(
store,
{ actModeApiProvider: "anthropic", actModeApiModelId: "claude-sonnet-4-5-20250929" },
{ actModeApiProvider: "deepseek", actModeApiModelId: undefined },
)
expect(normalized.actModeApiModelId).toBe("deepseek-v4-pro")
})
it("keeps an already valid DeepSeek model id", () => {
const providerId = parseProviderId("deepseek")
const store = makeStore({ providerId })
const normalized = normalizeProviderSwitchModel(
store,
{ actModeApiProvider: "anthropic", actModeApiModelId: "deepseek-v4-flash" },
{ actModeApiProvider: "deepseek", actModeApiModelId: undefined },
)
expect(normalized.actModeApiModelId).toBe("deepseek-v4-flash")
expect(store.readSelection).not.toHaveBeenCalled()
})
it("does not change model id when switching to a provider the SDK does not know", () => {
// A custom/unregistered provider has no SDK catalog to resolve against, so
// the generic model-id slot is left untouched.
const providerId = parseProviderId("my-custom-provider")
const store = makeStore({ providerId })
const normalized = normalizeProviderSwitchModel(
store,
{ actModeApiProvider: "deepseek", actModeApiModelId: "deepseek-v4-flash" },
{ actModeApiProvider: "my-custom-provider" as never },
)
expect(normalized).toEqual({ actModeApiProvider: "my-custom-provider" })
expect(store.readSelection).not.toHaveBeenCalled()
})
it("normalizes plan mode independently", () => {
const providerId = parseProviderId("deepseek")
const defaultModelId = MODEL_COLLECTIONS_BY_PROVIDER_ID.deepseek.provider.defaultModelId
const store = makeStore({ providerId })
const normalized = normalizeProviderSwitchModel(
store,
{ planModeApiProvider: "anthropic", planModeApiModelId: "claude-sonnet-4-5-20250929" },
{ planModeApiProvider: "deepseek", planModeApiModelId: undefined },
)
expect(normalized.planModeApiProvider).toBe("deepseek")
expect(normalized.planModeApiModelId).toBe(defaultModelId)
})
})
@@ -1,73 +0,0 @@
import * as sdkCore from "@cline/core"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { ClineEnv } from "@/config"
import { refreshClineRecommendedModels, resetClineRecommendedModelsCacheForTests } from "../refreshClineRecommendedModels"
// The HTTP fetch + normalization + offline fallback lives in the SDK
// (`@cline/core` `fetchClineRecommendedModels`). These tests cover the
// extension-side wrapper: delegation to the SDK and in-memory caching. There is
// intentionally no feature-flag gate here; onboarding must not race against the
// remote-config cache and accidentally keep the hardcoded fallback list.
describe("refreshClineRecommendedModels", () => {
beforeEach(() => {
resetClineRecommendedModelsCacheForTests()
// ClineEnv is not initialized in the unit-test environment; the wrapper
// passes its apiBaseUrl to the SDK, so provide a stable stub.
vi.spyOn(ClineEnv, "config").mockReturnValue({ apiBaseUrl: "https://api.cline-test.bot" } as ReturnType<
typeof ClineEnv.config
>)
})
afterEach(() => {
resetClineRecommendedModelsCacheForTests()
vi.restoreAllMocks()
})
it("delegates to the SDK fetch", async () => {
const sdkResult = {
recommended: [{ id: "anthropic/claude-sonnet-4.6", name: "Claude Sonnet 4.6", description: "Remote", tags: ["NEW"] }],
free: [{ id: "z-ai/glm-5", name: "GLM 5", description: "Remote free", tags: [] }],
}
const sdkSpy = vi.spyOn(sdkCore, "fetchClineRecommendedModels").mockResolvedValue(sdkResult)
const result = await refreshClineRecommendedModels()
expect(sdkSpy).toHaveBeenCalledTimes(1)
expect(result).toEqual(sdkResult)
})
it("uses the in-memory cache after a populated upstream result", async () => {
const sdkResult = {
recommended: [{ id: "google/gemini-3.1-pro-preview", name: "Gemini 3.1 Pro", description: "Remote", tags: ["NEW"] }],
free: [],
}
const sdkSpy = vi.spyOn(sdkCore, "fetchClineRecommendedModels").mockResolvedValue(sdkResult)
const firstResult = await refreshClineRecommendedModels()
const secondResult = await refreshClineRecommendedModels()
expect(sdkSpy).toHaveBeenCalledTimes(1)
expect(secondResult).toEqual(firstResult)
})
it("does not cache the SDK fallback result", async () => {
const sdkFallbackClone = structuredClone(sdkCore.FALLBACK_CLINE_RECOMMENDED_MODELS)
const sdkSpy = vi
.spyOn(sdkCore, "fetchClineRecommendedModels")
.mockResolvedValueOnce(sdkFallbackClone)
.mockResolvedValueOnce({
recommended: [
{ id: "anthropic/claude-sonnet-4.6", name: "Claude Sonnet 4.6", description: "Remote", tags: ["NEW"] },
],
free: [],
})
const firstResult = await refreshClineRecommendedModels()
const secondResult = await refreshClineRecommendedModels()
expect(sdkSpy).toHaveBeenCalledTimes(2)
expect(firstResult).toEqual(sdkCore.FALLBACK_CLINE_RECOMMENDED_MODELS)
expect(secondResult).not.toEqual(sdkCore.FALLBACK_CLINE_RECOMMENDED_MODELS)
})
})
@@ -1,265 +0,0 @@
import { describe, expect, it, vi } from "vitest"
import type {
EffectiveProviderConfig,
ModelInfo,
ProviderCatalog,
ProviderConfigStore,
ProviderId,
ProviderModelsResult,
} from "@/sdk/model-catalog/contracts"
import { computeConfigFingerprint } from "@/sdk/model-catalog/fingerprint"
import { parseProviderId } from "@/sdk/model-catalog/provider-id"
import type { ProviderCatalogController } from "../providerCatalogShared"
function fingerprint(providerId: ProviderId): ReturnType<typeof computeConfigFingerprint> {
return computeConfigFingerprint(providerId, { providerId })
}
function makeStore(config: EffectiveProviderConfig): ProviderConfigStore {
return {
read: vi.fn(() => config),
readSelection: vi.fn(() => undefined),
subscribe: vi.fn(() => ({ dispose: vi.fn() })),
write: vi.fn(() => config),
commitSelection: vi.fn(),
}
}
function makeCatalog(): ProviderCatalog {
return {
listProviders: vi.fn(async () => []),
resolveModels: vi.fn(async (providerId) => ({
ok: true as const,
providerId,
configFingerprint: fingerprint(providerId),
models: new Map<string, ModelInfo>(),
defaultModelId: "",
source: "sdk-dynamic" as const,
fetchedAt: 0,
})),
peekModels: vi.fn(() => undefined),
subscribe: vi.fn(() => ({ dispose: vi.fn() })),
}
}
function makeController(store: ProviderConfigStore, catalog: ProviderCatalog): ProviderCatalogController {
return {
getProviderConfigStore: () => store,
getProviderCatalog: () => catalog,
}
}
function peekResult(providerId: string, entries: Array<[string, ModelInfo]>, defaultModelId: string): ProviderModelsResult {
return {
ok: true,
providerId: parseProviderId(providerId),
configFingerprint: fingerprint(parseProviderId(providerId)),
models: new Map(entries),
defaultModelId,
source: "sdk-dynamic" as const,
fetchedAt: 0,
}
}
describe("resolveModelInfo", () => {
it("returns committed-selection source when a matching selection exists", async () => {
const { resolveModelInfo } = await import("../resolveModelInfo")
const providerId = parseProviderId("deepseek")
const store = makeStore({ providerId })
vi.mocked(store.readSelection).mockImplementation((_, mode) =>
mode === "act"
? {
providerId,
modelId: "deepseek-v4-pro",
modelInfo: { name: "Committed Pro", supportsPromptCache: true, contextWindow: 999_999 },
}
: undefined,
)
const catalog = makeCatalog()
const response = await resolveModelInfo(makeController(store, catalog), {
providerId: "deepseek",
modelId: "deepseek-v4-pro",
})
expect(response.source).toBe("committed-selection")
expect(response.modelId).toBe("deepseek-v4-pro")
expect(response.modelInfo?.contextWindow).toBe(999_999)
expect(catalog.peekModels).not.toHaveBeenCalled()
expect(catalog.resolveModels).not.toHaveBeenCalled()
})
it("returns sdk-known-models from a populated catalog peek", async () => {
const { resolveModelInfo } = await import("../resolveModelInfo")
const store = makeStore({ providerId: parseProviderId("deepseek") })
const catalog = makeCatalog()
vi.mocked(catalog.peekModels).mockReturnValue(
peekResult(
"deepseek",
[
["deepseek-v4-pro", { name: "DeepSeek V4 Pro", supportsPromptCache: false, contextWindow: 1_000_000 }],
["deepseek-v4-flash", { name: "DeepSeek V4 Flash", supportsPromptCache: false, contextWindow: 1_000_000 }],
],
"deepseek-v4-flash",
),
)
const response = await resolveModelInfo(makeController(store, catalog), {
providerId: "deepseek",
modelId: "deepseek-v4-pro",
})
expect(response.source).toBe("sdk-known-models")
expect(response.modelId).toBe("deepseek-v4-pro")
expect(response.modelInfo?.contextWindow).toBe(1_000_000)
expect(catalog.resolveModels).not.toHaveBeenCalled()
})
it("returns sdk-default when the requested id is missing but the catalog has a default", async () => {
const { resolveModelInfo } = await import("../resolveModelInfo")
const store = makeStore({ providerId: parseProviderId("deepseek") })
const catalog = makeCatalog()
vi.mocked(catalog.peekModels).mockReturnValue(
peekResult(
"deepseek",
[["deepseek-v4-flash", { name: "DeepSeek V4 Flash", supportsPromptCache: false, contextWindow: 1_000_000 }]],
"deepseek-v4-flash",
),
)
const response = await resolveModelInfo(makeController(store, catalog), {
providerId: "deepseek",
modelId: "claude-sonnet-4-5-20250929",
})
expect(response.source).toBe("sdk-default")
expect(response.modelId).toBe("deepseek-v4-flash")
expect(response.modelInfo?.contextWindow).toBe(1_000_000)
expect(catalog.resolveModels).not.toHaveBeenCalled()
})
it("returns sdk-default when the request omits a model id and the catalog has a default", async () => {
const { resolveModelInfo } = await import("../resolveModelInfo")
const store = makeStore({ providerId: parseProviderId("deepseek") })
const catalog = makeCatalog()
vi.mocked(catalog.peekModels).mockReturnValue(
peekResult(
"deepseek",
[["deepseek-v4-flash", { name: "DeepSeek V4 Flash", supportsPromptCache: false, contextWindow: 1_000_000 }]],
"deepseek-v4-flash",
),
)
const response = await resolveModelInfo(makeController(store, catalog), {
providerId: "deepseek",
})
expect(response.source).toBe("sdk-default")
expect(response.modelId).toBe("deepseek-v4-flash")
expect(response.modelInfo?.contextWindow).toBe(1_000_000)
})
it("awaits the catalog when the peek is empty and surfaces the resolved info", async () => {
const { resolveModelInfo } = await import("../resolveModelInfo")
const store = makeStore({ providerId: parseProviderId("deepseek") })
const catalog = makeCatalog()
// peek returns undefined (default). resolveModels returns a
// populated catalog. The handler should await resolveModels and
// pick from its result rather than returning unknown.
vi.mocked(catalog.resolveModels).mockResolvedValue(
peekResult(
"deepseek",
[["deepseek-v4-pro", { name: "DeepSeek V4 Pro", supportsPromptCache: false, contextWindow: 1_000_000 }]],
"deepseek-v4-pro",
),
)
const response = await resolveModelInfo(makeController(store, catalog), {
providerId: "deepseek",
modelId: "deepseek-v4-pro",
})
expect(response.source).toBe("sdk-known-models")
expect(response.modelId).toBe("deepseek-v4-pro")
expect(response.modelInfo?.contextWindow).toBe(1_000_000)
expect(catalog.resolveModels).toHaveBeenCalledTimes(1)
})
it("returns unknown when both the peek and resolveModels yield nothing", async () => {
const { resolveModelInfo } = await import("../resolveModelInfo")
const store = makeStore({ providerId: parseProviderId("deepseek") })
const catalog = makeCatalog()
// Default peek mock returns undefined; default resolveModels mock
// returns an empty catalog with no default model.
const response = await resolveModelInfo(makeController(store, catalog), {
providerId: "deepseek",
modelId: "deepseek-v4-pro",
})
expect(response).toMatchObject({
providerId: "deepseek",
modelId: "deepseek-v4-pro",
source: "unknown",
})
expect(response.modelInfo).toBeUndefined()
expect(catalog.resolveModels).toHaveBeenCalledTimes(1)
})
it("does not coerce a custom OpenAI Compatible model id to the catalog default", async () => {
const { resolveModelInfo } = await import("../resolveModelInfo")
const store = makeStore({ providerId: parseProviderId("openai") })
const catalog = makeCatalog()
// The openai-compatible catalog only knows gpt-4o. A user-entered custom
// model id must NOT be replaced with that default — the requested id is
// authoritative for custom-model-id providers.
vi.mocked(catalog.peekModels).mockReturnValue(
peekResult("openai", [["gpt-4o", { name: "GPT-4o", supportsPromptCache: false, contextWindow: 128_000 }]], "gpt-4o"),
)
const response = await resolveModelInfo(makeController(store, catalog), {
providerId: "openai",
modelId: "my-custom-model-xyz",
})
expect(response.modelId).toBe("my-custom-model-xyz")
expect(response.source).toBe("unknown")
expect(response.modelInfo).toBeUndefined()
})
it("still honors a custom-provider model id that does match the catalog", async () => {
const { resolveModelInfo } = await import("../resolveModelInfo")
const store = makeStore({ providerId: parseProviderId("openai") })
const catalog = makeCatalog()
vi.mocked(catalog.peekModels).mockReturnValue(
peekResult("openai", [["gpt-4o", { name: "GPT-4o", supportsPromptCache: false, contextWindow: 128_000 }]], "gpt-4o"),
)
const response = await resolveModelInfo(makeController(store, catalog), {
providerId: "openai",
modelId: "gpt-4o",
})
expect(response.source).toBe("sdk-known-models")
expect(response.modelId).toBe("gpt-4o")
expect(response.modelInfo?.contextWindow).toBe(128_000)
})
it("returns unknown for an unknown provider without throwing", async () => {
const { resolveModelInfo } = await import("../resolveModelInfo")
const store = makeStore({ providerId: parseProviderId("not-real-provider") })
const catalog = makeCatalog()
const response = await resolveModelInfo(makeController(store, catalog), {
providerId: "not-real-provider",
modelId: "whatever",
})
expect(response).toMatchObject({
providerId: "not-real-provider",
modelId: "whatever",
source: "unknown",
})
expect(response.modelInfo).toBeUndefined()
})
})
@@ -1,37 +1,10 @@
import { toLegacyApiProvider } from "@/shared/model-catalog/provider-helpers"
import { Empty } from "@/shared/proto/cline/common"
import { CommitModelSelectionRequest } from "@/shared/proto/cline/models"
import { getProviderModelIdKey } from "@/shared/storage/provider-keys"
import {
hasProviderCatalogStateController,
type ProviderCatalogController,
parseModeRequest,
parseProviderIdRequest,
toModelSelection,
} from "./providerCatalogShared"
import { type ProviderCatalogController } from "./providerCatalogShared"
export async function commitModelSelection(
controller: ProviderCatalogController,
request: CommitModelSelectionRequest,
_controller: ProviderCatalogController,
_request: CommitModelSelectionRequest,
): Promise<Empty> {
const providerId = parseProviderIdRequest(request.providerId)
const mode = parseModeRequest(request.mode)
const selection = toModelSelection(request, providerId)
const previousApiConfiguration = hasProviderCatalogStateController(controller)
? controller.stateManager.getApiConfiguration?.()
: undefined
controller.getProviderConfigStore().commitSelection(providerId, mode, selection)
if (hasProviderCatalogStateController(controller)) {
controller.stateManager.setGlobalStateBatch({
[`${mode}ModeApiProvider`]: providerId,
[getProviderModelIdKey(toLegacyApiProvider(providerId.toString()), mode)]: selection.modelId,
})
const nextApiConfiguration = controller.stateManager.getApiConfiguration?.()
if (nextApiConfiguration) {
controller.handleApiConfigurationChanged?.(previousApiConfiguration ?? {}, nextApiConfiguration)
}
}
return Empty.create()
}
@@ -1,9 +1,5 @@
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
import { fetch } from "@/shared/net"
import { Logger } from "@/shared/services/Logger"
/**
* Shape of the LiteLLM `/v1/model/info` response.
* Inert shell: the LiteLLM model-info fetcher has been removed.
*/
export interface LiteLlmModelInfoResponse {
data: Array<{
@@ -23,51 +19,6 @@ export interface LiteLlmModelInfoResponse {
}>
}
/**
* Fetch LiteLLM model info from a LiteLLM proxy.
*
* @param baseUrl The base URL for the LiteLLM API
* @param apiKey The API key for authentication
* @returns The model info response or undefined if fetch fails
*/
export async function fetchLiteLlmModelsInfo(baseUrl: string, apiKey: string): Promise<LiteLlmModelInfoResponse | undefined> {
// Handle base URLs that already include /v1 to avoid double /v1/v1/
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`
const url = `${normalizedBaseUrl}/model/info`
try {
const response = await fetch(url, {
method: "GET",
headers: {
accept: "application/json",
"x-litellm-api-key": apiKey,
...buildExternalBasicHeaders(),
},
})
if (response.ok) {
const data: LiteLlmModelInfoResponse = await response.json()
return data
}
Logger.error("Failed to fetch LiteLLM model info:", response.statusText)
// Try with Authorization header instead
const retryResponse = await fetch(url, {
method: "GET",
headers: {
accept: "application/json",
Authorization: `Bearer ${apiKey}`,
...buildExternalBasicHeaders(),
},
})
if (retryResponse.ok) {
const data: LiteLlmModelInfoResponse = await retryResponse.json()
return data
}
Logger.error("Failed to fetch LiteLLM model info with Authorization header:", retryResponse.statusText)
throw new Error(`Failed to fetch LiteLLM model info: ${retryResponse.statusText}`)
} catch (error) {
Logger.error("Error fetching LiteLLM model info:", error)
throw error
}
export async function fetchLiteLlmModelsInfo(_baseUrl: string, _apiKey: string): Promise<LiteLlmModelInfoResponse | undefined> {
return undefined
}
@@ -1,74 +1,7 @@
import { EmptyRequest } from "@shared/proto/cline/common"
import { OpenRouterCompatibleModelInfo, OpenRouterModelInfo } from "@shared/proto/cline/models"
import axios from "axios"
import { getAxiosSettings } from "@/shared/net"
import { Logger } from "@/shared/services/Logger"
import { OpenRouterCompatibleModelInfo } from "@shared/proto/cline/models"
import { Controller } from ".."
/**
* Fetches available models from AIhubmix
* @param controller The controller instance
* @param request Empty request object
* @returns Response containing the AIhubmix models
*/
export async function getAihubmixModels(_controller: Controller, _request: EmptyRequest): Promise<OpenRouterCompatibleModelInfo> {
try {
const response = await axios.get("https://aihubmix.com/call/mdl_info_platform?tag=coding", getAxiosSettings())
if (!response.data?.success || !Array.isArray(response.data?.data)) {
Logger.error("Invalid response from AIhubmix API:", response.data)
return OpenRouterCompatibleModelInfo.create({ models: {} })
}
// 原始数据为数组,不能直接复用为 map;需构造独立的 modelsMap
const modelsArray = response.data.data as any[]
const modelsMap: Record<string, OpenRouterModelInfo> = {}
for (const modelData of modelsArray) {
if (!modelData.model || typeof modelData.model !== "string") {
continue
}
// 检查是否支持图像
const supportsImages =
modelData.modalities?.includes("vision") ||
modelData.modalities?.includes("image") ||
modelData.features?.includes("vision") ||
false
// 检查是否支持思维链
const supportsThinking = modelData.features?.includes("thinking") || false
// 检查是否支持缓存:cache_ratio 非1 或 读价与输入价不同
const pricing = modelData.pricing || {}
const supportsPromptCache =
(modelData.cache_ratio !== undefined && modelData.cache_ratio !== 1) ||
(pricing.cache_read !== undefined && pricing.input !== undefined && pricing.cache_read !== pricing.input)
const modelId = modelData.model
modelsMap[modelId] = OpenRouterModelInfo.create({
maxTokens: modelData.max_output ?? 8192,
contextWindow: modelData.context_window ?? 128000,
supportsImages: supportsImages,
supportsPromptCache: supportsPromptCache,
inputPrice: pricing.input ?? 0,
outputPrice: pricing.output ?? 0,
cacheWritesPrice: pricing.cache_write ?? 0,
cacheReadsPrice: pricing.cache_read ?? 0,
description: modelData.desc_en || modelData.desc || "",
thinkingConfig: supportsThinking
? modelData.thinking_config
? modelData.thinking_config
: undefined
: undefined,
supportsGlobalEndpoint: modelData.supports_global_endpoint ?? undefined,
tiers: [],
})
}
Logger.log(`Fetched ${Object.keys(modelsMap).length} AIhubmix models`)
return OpenRouterCompatibleModelInfo.create({ models: modelsMap })
} catch (error) {
Logger.error("Failed to fetch AIhubmix models:", error)
return OpenRouterCompatibleModelInfo.create({ models: {} })
}
return OpenRouterCompatibleModelInfo.create({})
}
@@ -1,64 +1,9 @@
import { featureFlagsService } from "@/services/feature-flags"
import { CLINE_ONBOARDING_MODELS } from "@/shared/cline/onboarding"
import { OnboardingModel, OnboardingModelGroup } from "@/shared/proto/cline/state"
type OnboardingModelOverride = OnboardingModel & { hidden?: boolean }
let cached: OnboardingModelGroup | null = null
import { OnboardingModelGroup } from "@/shared/proto/cline/state"
export function getClineOnboardingModels(): OnboardingModelGroup {
if (cached) {
return cached
}
const remoteOverrides = featureFlagsService.getOnboardingOverrides()
const models = [...CLINE_ONBOARDING_MODELS]
// Apply remote overrides if available
if (remoteOverrides) {
for (const [id, override] of Object.entries(remoteOverrides) as [string, OnboardingModelOverride][]) {
if (override.hidden) {
for (let i = models.length - 1; i >= 0; i--) {
if (models[i].id === id) {
models.splice(i, 1)
}
}
} else {
let found = false
for (let i = 0; i < models.length; i++) {
if (models[i].id === id) {
models[i] = mergeModelWithOverride(models[i], override)
found = true
}
}
if (!found) {
models.push(mergeModelWithOverride(undefined, override))
}
}
}
}
cached = { models }
return cached
}
function mergeModelWithOverride(baseModel: OnboardingModel | undefined, override: OnboardingModelOverride): OnboardingModel {
const baseInfo = baseModel?.info
const overrideInfo = override.info
// Merge info with proper defaults
const mergedInfo = {
...baseInfo,
...overrideInfo,
supportsPromptCache: overrideInfo?.supportsPromptCache ?? baseInfo?.supportsPromptCache ?? false,
tiers: overrideInfo?.tiers ?? baseInfo?.tiers ?? [],
}
// Return merged model, using base as foundation if available
return baseModel ? { ...baseModel, ...override, info: mergedInfo } : { ...override, info: mergedInfo }
return OnboardingModelGroup.create({})
}
export function clearOnboardingModelsCache(): void {
cached = null
return
}

Some files were not shown because too many files have changed in this diff Show More