Browse Source

feat(roo): add versioned settings support with minPluginVersion gating (#9934)

Hannes Rudolph 3 months ago
parent
commit
83787a76ef

+ 7 - 0
packages/types/src/providers/roo.ts

@@ -41,7 +41,14 @@ export const RooModelSchema = z.object({
 	default_temperature: z.number().optional(),
 	// Dynamic settings that map directly to ModelInfo properties
 	// Allows the API to configure model-specific defaults like includedTools, excludedTools, reasoningEffort, etc.
+	// These are always direct values (e.g., includedTools: ['search_replace']) for backward compatibility with old clients.
 	settings: z.record(z.string(), z.unknown()).optional(),
+	// Versioned settings keyed by version number (e.g., '3.36.4').
+	// Each version key maps to a settings object that is used when plugin version >= that version.
+	// New clients find the highest version key <= current version and use those settings.
+	// Old clients ignore this field and use plain values from `settings`.
+	// Example: { '3.36.4': { includedTools: ['search_replace'] }, '3.35.0': { ... } }
+	versionedSettings: z.record(z.string(), z.record(z.string(), z.unknown())).optional(),
 })
 
 export const RooModelsResponseSchema = z.object({

+ 17 - 1
pnpm-lock.yaml

@@ -795,6 +795,9 @@ importers:
       say:
         specifier: ^0.16.0
         version: 0.16.0
+      semver-compare:
+        specifier: ^1.0.0
+        version: 1.0.0
       serialize-error:
         specifier: ^12.0.0
         version: 12.0.0
@@ -898,6 +901,9 @@ importers:
       '@types/ps-tree':
         specifier: ^1.1.6
         version: 1.1.6
+      '@types/semver-compare':
+        specifier: ^1.0.3
+        version: 1.0.3
       '@types/shell-quote':
         specifier: ^1.7.5
         version: 1.7.5
@@ -4160,6 +4166,9 @@ packages:
   '@types/[email protected]':
     resolution: {integrity: sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==}
 
+  '@types/[email protected]':
+    resolution: {integrity: sha512-mVZkB2QjXmZhh+MrtwMlJ8BqUnmbiSkpd88uOWskfwB8yitBT0tBRAKt+41VRgZD9zr9Sc+Xs02qGgvzd1Rq/Q==}
+
   '@types/[email protected]':
     resolution: {integrity: sha512-+UE8GAGRPbJVQDdxi16dgadcBfQ+KG2vgZhV1+3A1XmHbmwcdwhCUwIdy+d3pAGrbvgRoVSjeI9vOWyq376Yzw==}
 
@@ -8909,6 +8918,9 @@ packages:
   [email protected]:
     resolution: {integrity: sha512-34EQV6AAHQGhoc0tn/96a9Fsi6v2xdqe/dMUwljGRaFOzR3EgRmECvD0O8vi8X+/uQ50LGHfkNu/Eue5TPKZkQ==}
 
+  [email protected]:
+    resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==}
+
   [email protected]:
     resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
     hasBin: true
@@ -13898,6 +13910,8 @@ snapshots:
 
   '@types/[email protected]': {}
 
+  '@types/[email protected]': {}
+
   '@types/[email protected]': {}
 
   '@types/[email protected]': {}
@@ -14117,7 +14131,7 @@ snapshots:
       sirv: 3.0.1
       tinyglobby: 0.2.14
       tinyrainbow: 2.0.0
-      vitest: 3.2.4(@types/[email protected])(@types/node@24.2.1)(@vitest/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])
+      vitest: 3.2.4(@types/[email protected])(@types/node@20.17.50)(@vitest/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])
 
   '@vitest/[email protected]':
     dependencies:
@@ -19479,6 +19493,8 @@ snapshots:
 
   [email protected]: {}
 
+  [email protected]: {}
+
   [email protected]: {}
 
   [email protected]: {}

+ 175 - 0
src/api/providers/fetchers/__tests__/roo.spec.ts

@@ -801,4 +801,179 @@ describe("getRooModels", () => {
 		expect(model.anotherSetting).toBe(42)
 		expect(model.nestedConfig).toEqual({ key: "value" })
 	})
+
+	it("should apply versioned settings when version matches", async () => {
+		const mockResponse = {
+			object: "list",
+			data: [
+				{
+					id: "test/versioned-model",
+					object: "model",
+					created: 1234567890,
+					owned_by: "test",
+					name: "Model with Versioned Settings",
+					description: "Model with versioned settings",
+					context_window: 128000,
+					max_tokens: 8192,
+					type: "language",
+					tags: ["tool-use"],
+					pricing: {
+						input: "0.0001",
+						output: "0.0002",
+					},
+					// Plain settings for backward compatibility with old clients
+					settings: {
+						includedTools: ["apply_patch"],
+						excludedTools: ["write_to_file"],
+					},
+					// Versioned settings keyed by version number (low version - always met)
+					versionedSettings: {
+						"1.0.0": {
+							includedTools: ["apply_patch", "search_replace"],
+							excludedTools: ["apply_diff", "write_to_file"],
+						},
+					},
+				},
+			],
+		}
+
+		mockFetch.mockResolvedValueOnce({
+			ok: true,
+			json: async () => mockResponse,
+		})
+
+		const models = await getRooModels(baseUrl, apiKey)
+
+		// Versioned settings should be used instead of plain settings
+		expect(models["test/versioned-model"].includedTools).toEqual(["apply_patch", "search_replace"])
+		expect(models["test/versioned-model"].excludedTools).toEqual(["apply_diff", "write_to_file"])
+	})
+
+	it("should use plain settings when no versioned settings version matches", async () => {
+		const mockResponse = {
+			object: "list",
+			data: [
+				{
+					id: "test/old-version-model",
+					object: "model",
+					created: 1234567890,
+					owned_by: "test",
+					name: "Model for Old Version",
+					description: "Model with versioned settings for newer version",
+					context_window: 128000,
+					max_tokens: 8192,
+					type: "language",
+					tags: ["tool-use"],
+					pricing: {
+						input: "0.0001",
+						output: "0.0002",
+					},
+					settings: {
+						includedTools: ["apply_patch"],
+					},
+					// Versioned settings keyed by very high version - never met
+					versionedSettings: {
+						"99.0.0": {
+							includedTools: ["apply_patch", "search_replace"],
+						},
+					},
+				},
+			],
+		}
+
+		mockFetch.mockResolvedValueOnce({
+			ok: true,
+			json: async () => mockResponse,
+		})
+
+		const models = await getRooModels(baseUrl, apiKey)
+
+		// Should use plain settings since no versioned settings match current version
+		expect(models["test/old-version-model"].includedTools).toEqual(["apply_patch"])
+	})
+
+	it("should handle model with only versionedSettings and no plain settings", async () => {
+		const mockResponse = {
+			object: "list",
+			data: [
+				{
+					id: "test/versioned-only-model",
+					object: "model",
+					created: 1234567890,
+					owned_by: "test",
+					name: "Model with Only Versioned Settings",
+					description: "Model with only versioned settings",
+					context_window: 128000,
+					max_tokens: 8192,
+					type: "language",
+					tags: [],
+					pricing: {
+						input: "0.0001",
+						output: "0.0002",
+					},
+					// No plain settings, only versionedSettings keyed by version
+					versionedSettings: {
+						"1.0.0": {
+							customFeature: true,
+						},
+					},
+				},
+			],
+		}
+
+		mockFetch.mockResolvedValueOnce({
+			ok: true,
+			json: async () => mockResponse,
+		})
+
+		const models = await getRooModels(baseUrl, apiKey)
+		const model = models["test/versioned-only-model"] as Record<string, unknown>
+
+		expect(model.customFeature).toBe(true)
+	})
+
+	it("should select highest matching version from versionedSettings", async () => {
+		const mockResponse = {
+			object: "list",
+			data: [
+				{
+					id: "test/multi-version-model",
+					object: "model",
+					created: 1234567890,
+					owned_by: "test",
+					name: "Model with Multiple Versions",
+					description: "Model with multiple version settings",
+					context_window: 128000,
+					max_tokens: 8192,
+					type: "language",
+					tags: [],
+					pricing: {
+						input: "0.0001",
+						output: "0.0002",
+					},
+					settings: {
+						feature: "default",
+					},
+					// Multiple version keys - should use highest one <= current version
+					versionedSettings: {
+						"99.0.0": { feature: "future" },
+						"3.0.0": { feature: "current" },
+						"2.0.0": { feature: "old" },
+						"1.0.0": { feature: "very_old" },
+					},
+				},
+			],
+		}
+
+		mockFetch.mockResolvedValueOnce({
+			ok: true,
+			json: async () => mockResponse,
+		})
+
+		const models = await getRooModels(baseUrl, apiKey)
+		const model = models["test/multi-version-model"] as Record<string, unknown>
+
+		// Should use 3.0.0 version settings (highest that's <= current plugin version)
+		expect(model.feature).toBe("current")
+	})
 })

+ 263 - 0
src/api/providers/fetchers/__tests__/versionedSettings.spec.ts

@@ -0,0 +1,263 @@
+import {
+	compareSemver,
+	meetsMinimumVersion,
+	findHighestMatchingVersion,
+	resolveVersionedSettings,
+	type VersionedSettings,
+} from "../versionedSettings"
+
+describe("versionedSettings", () => {
+	describe("compareSemver", () => {
+		it("should return 0 for equal versions", () => {
+			expect(compareSemver("1.0.0", "1.0.0")).toBe(0)
+			expect(compareSemver("3.36.4", "3.36.4")).toBe(0)
+			expect(compareSemver("0.0.1", "0.0.1")).toBe(0)
+		})
+
+		it("should return positive when first version is greater", () => {
+			expect(compareSemver("2.0.0", "1.0.0")).toBeGreaterThan(0)
+			expect(compareSemver("1.1.0", "1.0.0")).toBeGreaterThan(0)
+			expect(compareSemver("1.0.1", "1.0.0")).toBeGreaterThan(0)
+			expect(compareSemver("3.36.5", "3.36.4")).toBeGreaterThan(0)
+			expect(compareSemver("3.37.0", "3.36.4")).toBeGreaterThan(0)
+			expect(compareSemver("4.0.0", "3.36.4")).toBeGreaterThan(0)
+		})
+
+		it("should return negative when first version is smaller", () => {
+			expect(compareSemver("1.0.0", "2.0.0")).toBeLessThan(0)
+			expect(compareSemver("1.0.0", "1.1.0")).toBeLessThan(0)
+			expect(compareSemver("1.0.0", "1.0.1")).toBeLessThan(0)
+			expect(compareSemver("3.36.3", "3.36.4")).toBeLessThan(0)
+			expect(compareSemver("3.35.0", "3.36.4")).toBeLessThan(0)
+			expect(compareSemver("2.0.0", "3.36.4")).toBeLessThan(0)
+		})
+
+		it("should handle pre-release versions by ignoring pre-release suffix", () => {
+			expect(compareSemver("3.36.4-beta.1", "3.36.4")).toBe(0)
+			expect(compareSemver("3.36.4-rc.2", "3.36.4")).toBe(0)
+			expect(compareSemver("3.36.5-alpha", "3.36.4")).toBeGreaterThan(0)
+			expect(compareSemver("3.36.3-beta", "3.36.4")).toBeLessThan(0)
+		})
+
+		it("should handle edge cases", () => {
+			expect(compareSemver("0.0.0", "0.0.0")).toBe(0)
+			expect(compareSemver("10.20.30", "10.20.30")).toBe(0)
+			expect(compareSemver("10.0.0", "9.99.99")).toBeGreaterThan(0)
+		})
+	})
+
+	describe("meetsMinimumVersion", () => {
+		it("should return true when current version equals minimum", () => {
+			expect(meetsMinimumVersion("3.36.4", "3.36.4")).toBe(true)
+		})
+
+		it("should return true when current version exceeds minimum", () => {
+			expect(meetsMinimumVersion("3.36.4", "3.36.5")).toBe(true)
+			expect(meetsMinimumVersion("3.36.4", "3.37.0")).toBe(true)
+			expect(meetsMinimumVersion("3.36.4", "4.0.0")).toBe(true)
+		})
+
+		it("should return false when current version is below minimum", () => {
+			expect(meetsMinimumVersion("3.36.4", "3.36.3")).toBe(false)
+			expect(meetsMinimumVersion("3.36.4", "3.35.0")).toBe(false)
+			expect(meetsMinimumVersion("3.36.4", "2.0.0")).toBe(false)
+		})
+	})
+
+	describe("findHighestMatchingVersion", () => {
+		it("should return undefined when no versions match", () => {
+			const versionedSettings: VersionedSettings = {
+				"4.0.0": { includedTools: ["apply_diff"] },
+				"5.0.0": { includedTools: ["apply_diff", "search_replace"] },
+			}
+
+			const result = findHighestMatchingVersion(versionedSettings, "3.36.4")
+			expect(result).toBeUndefined()
+		})
+
+		it("should return the exact version when it matches", () => {
+			const versionedSettings: VersionedSettings = {
+				"3.36.4": { includedTools: ["apply_diff"] },
+				"3.35.0": { includedTools: ["search_replace"] },
+			}
+
+			const result = findHighestMatchingVersion(versionedSettings, "3.36.4")
+			expect(result).toBe("3.36.4")
+		})
+
+		it("should return the highest version that is <= current version", () => {
+			const versionedSettings: VersionedSettings = {
+				"3.37.0": { includedTools: ["future_tool"] },
+				"3.36.4": { includedTools: ["apply_diff"] },
+				"3.35.0": { includedTools: ["search_replace"] },
+				"3.34.0": { includedTools: ["basic_tool"] },
+			}
+
+			// Current version is 3.36.5, should match 3.36.4 (highest <= 3.36.5)
+			const result = findHighestMatchingVersion(versionedSettings, "3.36.5")
+			expect(result).toBe("3.36.4")
+		})
+
+		it("should handle single version", () => {
+			const versionedSettings: VersionedSettings = {
+				"3.35.0": { includedTools: ["search_replace"] },
+			}
+
+			expect(findHighestMatchingVersion(versionedSettings, "3.36.4")).toBe("3.35.0")
+			expect(findHighestMatchingVersion(versionedSettings, "3.34.0")).toBeUndefined()
+		})
+
+		it("should handle empty versionedSettings", () => {
+			const versionedSettings: VersionedSettings = {}
+
+			const result = findHighestMatchingVersion(versionedSettings, "3.36.4")
+			expect(result).toBeUndefined()
+		})
+	})
+
+	describe("resolveVersionedSettings", () => {
+		const currentVersion = "3.36.4"
+
+		it("should return settings for exact version match", () => {
+			const versionedSettings: VersionedSettings = {
+				"3.36.4": {
+					includedTools: ["search_replace"],
+					excludedTools: ["apply_diff"],
+				},
+			}
+
+			const resolved = resolveVersionedSettings(versionedSettings, currentVersion)
+
+			expect(resolved).toEqual({
+				includedTools: ["search_replace"],
+				excludedTools: ["apply_diff"],
+			})
+		})
+
+		it("should return settings for highest matching version", () => {
+			const versionedSettings: VersionedSettings = {
+				"4.0.0": {
+					includedTools: ["future_tool"],
+				},
+				"3.36.0": {
+					includedTools: ["search_replace"],
+					excludedTools: ["apply_diff"],
+				},
+				"3.35.0": {
+					includedTools: ["old_tool"],
+				},
+			}
+
+			const resolved = resolveVersionedSettings(versionedSettings, currentVersion)
+
+			expect(resolved).toEqual({
+				includedTools: ["search_replace"],
+				excludedTools: ["apply_diff"],
+			})
+		})
+
+		it("should return empty object when no versions match", () => {
+			const versionedSettings: VersionedSettings = {
+				"4.0.0": {
+					includedTools: ["future_tool"],
+				},
+				"3.37.0": {
+					includedTools: ["newer_tool"],
+				},
+			}
+
+			const resolved = resolveVersionedSettings(versionedSettings, currentVersion)
+
+			expect(resolved).toEqual({})
+		})
+
+		it("should handle empty versionedSettings", () => {
+			const resolved = resolveVersionedSettings({}, currentVersion)
+			expect(resolved).toEqual({})
+		})
+
+		it("should handle versioned boolean values", () => {
+			const versionedSettings: VersionedSettings = {
+				"3.36.0": {
+					supportsNativeTools: true,
+				},
+			}
+
+			const resolved = resolveVersionedSettings(versionedSettings, currentVersion)
+
+			expect(resolved).toEqual({
+				supportsNativeTools: true,
+			})
+		})
+
+		it("should handle versioned null values", () => {
+			const versionedSettings: VersionedSettings = {
+				"3.36.0": {
+					defaultTemperature: null,
+				},
+			}
+
+			const resolved = resolveVersionedSettings(versionedSettings, currentVersion)
+
+			expect(resolved).toEqual({
+				defaultTemperature: null,
+			})
+		})
+
+		it("should handle versioned nested objects", () => {
+			const versionedSettings: VersionedSettings = {
+				"3.36.0": {
+					complexSetting: { nested: { deeply: true } },
+				},
+			}
+
+			const resolved = resolveVersionedSettings(versionedSettings, currentVersion)
+
+			expect(resolved).toEqual({
+				complexSetting: { nested: { deeply: true } },
+			})
+		})
+
+		it("should use all settings from the matching version", () => {
+			const versionedSettings: VersionedSettings = {
+				"3.36.4": {
+					includedTools: ["search_replace", "apply_diff"],
+					excludedTools: ["write_to_file"],
+					supportsReasoningEffort: true,
+					description: "Updated model",
+				},
+				"3.35.0": {
+					includedTools: ["search_replace"],
+					description: "Old model",
+				},
+			}
+
+			const resolved = resolveVersionedSettings(versionedSettings, "3.36.4")
+
+			expect(resolved).toEqual({
+				includedTools: ["search_replace", "apply_diff"],
+				excludedTools: ["write_to_file"],
+				supportsReasoningEffort: true,
+				description: "Updated model",
+			})
+		})
+
+		it("should handle multiple versions and select correct one", () => {
+			const versionedSettings: VersionedSettings = {
+				"3.38.0": { feature: "very_new" },
+				"3.37.0": { feature: "new" },
+				"3.36.0": { feature: "current" },
+				"3.35.0": { feature: "old" },
+				"3.34.0": { feature: "very_old" },
+			}
+
+			// Test different current versions
+			expect(resolveVersionedSettings(versionedSettings, "3.40.0")).toEqual({ feature: "very_new" })
+			expect(resolveVersionedSettings(versionedSettings, "3.37.5")).toEqual({ feature: "new" })
+			expect(resolveVersionedSettings(versionedSettings, "3.36.5")).toEqual({ feature: "current" })
+			expect(resolveVersionedSettings(versionedSettings, "3.35.5")).toEqual({ feature: "old" })
+			expect(resolveVersionedSettings(versionedSettings, "3.34.5")).toEqual({ feature: "very_old" })
+			expect(resolveVersionedSettings(versionedSettings, "3.33.0")).toEqual({})
+		})
+	})
+})

+ 31 - 2
src/api/providers/fetchers/roo.ts

@@ -4,6 +4,7 @@ import type { ModelRecord } from "../../../shared/api"
 import { parseApiPrice } from "../../../shared/cost"
 
 import { DEFAULT_HEADERS } from "../constants"
+import { resolveVersionedSettings, type VersionedSettings } from "./versionedSettings"
 
 /**
  * Fetches available models from the Roo Code Cloud provider
@@ -128,9 +129,37 @@ export async function getRooModels(baseUrl: string, apiKey?: string): Promise<Mo
 				// Apply API-provided settings on top of base model info
 				// Settings allow the proxy to dynamically configure model-specific options
 				// like includedTools, excludedTools, reasoningEffort, etc.
-				const apiSettings = model.settings as Partial<ModelInfo> | undefined
+				//
+				// Two fields are used for backward compatibility:
+				// - `settings`: Plain values that work with all client versions (e.g., { includedTools: ['search_replace'] })
+				// - `versionedSettings`: Version-keyed settings (e.g., { '3.36.4': { includedTools: ['search_replace'] } })
+				//
+				// New clients check versionedSettings first - if a matching version is found, those settings are used.
+				// Otherwise, falls back to plain `settings`. Old clients only see `settings`.
+				const apiSettings = model.settings as Record<string, unknown> | undefined
+				const apiVersionedSettings = model.versionedSettings as VersionedSettings | undefined
+
+				// Start with base model info
+				let modelInfo: ModelInfo = { ...baseModelInfo }
+
+				// Try to resolve versioned settings first (finds highest version <= current plugin version)
+				// If versioned settings match, use them exclusively (they contain all necessary settings)
+				// Otherwise fall back to plain settings for backward compatibility
+				if (apiVersionedSettings) {
+					const resolvedVersionedSettings = resolveVersionedSettings<Partial<ModelInfo>>(apiVersionedSettings)
+					if (Object.keys(resolvedVersionedSettings).length > 0) {
+						// Versioned settings found - use them exclusively
+						modelInfo = { ...modelInfo, ...resolvedVersionedSettings }
+					} else if (apiSettings) {
+						// No matching versioned settings - fall back to plain settings
+						modelInfo = { ...modelInfo, ...(apiSettings as Partial<ModelInfo>) }
+					}
+				} else if (apiSettings) {
+					// No versioned settings at all - use plain settings
+					modelInfo = { ...modelInfo, ...(apiSettings as Partial<ModelInfo>) }
+				}
 
-				models[modelId] = apiSettings ? { ...baseModelInfo, ...apiSettings } : baseModelInfo
+				models[modelId] = modelInfo
 			}
 
 			return models

+ 113 - 0
src/api/providers/fetchers/versionedSettings.ts

@@ -0,0 +1,113 @@
+import cmp from "semver-compare"
+
+import { Package } from "../../../shared/package"
+
+/**
+ * Type for versioned settings where the version is the key.
+ * Each version key maps to a settings object that should be used
+ * when the current plugin version is >= that version.
+ *
+ * Example API response:
+ * ```
+ * {
+ *   settings: {
+ *     includedTools: ['search_replace']  // Plain value for old clients
+ *   },
+ *   versionedSettings: {
+ *     '3.36.4': {
+ *       includedTools: ['search_replace', 'apply_diff'],  // Enhanced value for 3.36.4+
+ *       excludedTools: ['write_to_file'],
+ *     },
+ *     '3.35.0': {
+ *       includedTools: ['search_replace'],  // Value for 3.35.0 - 3.36.3
+ *     },
+ *   }
+ * }
+ * ```
+ *
+ * The resolver will find the highest version key that is <= the current plugin version
+ * and use those settings. If no version matches, falls back to plain `settings`.
+ */
+export type VersionedSettings = Record<string, Record<string, unknown>>
+
+/**
+ * Compares two semantic version strings using semver-compare.
+ *
+ * @param version1 First version string (e.g., "3.36.4")
+ * @param version2 Second version string (e.g., "3.36.0")
+ * @returns -1 if version1 < version2, 0 if equal, 1 if version1 > version2
+ */
+export function compareSemver(version1: string, version2: string): number {
+	// Handle pre-release versions by stripping the suffix
+	// semver-compare doesn't handle pre-release properly
+	const stripPrerelease = (v: string): string => v.split("-")[0]
+	return cmp(stripPrerelease(version1), stripPrerelease(version2))
+}
+
+/**
+ * Checks if the current plugin version meets or exceeds the required minimum version.
+ *
+ * @param minPluginVersion The minimum required version
+ * @param currentVersion The current plugin version (defaults to Package.version)
+ * @returns true if current version >= minPluginVersion
+ */
+export function meetsMinimumVersion(minPluginVersion: string, currentVersion: string = Package.version): boolean {
+	return compareSemver(currentVersion, minPluginVersion) >= 0
+}
+
+/**
+ * Finds the highest version from versionedSettings that is <= the current plugin version.
+ *
+ * @param versionedSettings The versioned settings object with version keys
+ * @param currentVersion The current plugin version (defaults to Package.version)
+ * @returns The highest matching version key, or undefined if none match
+ */
+export function findHighestMatchingVersion(
+	versionedSettings: VersionedSettings,
+	currentVersion: string = Package.version,
+): string | undefined {
+	const versions = Object.keys(versionedSettings)
+
+	// Filter to versions that are <= currentVersion
+	const matchingVersions = versions.filter((version) => meetsMinimumVersion(version, currentVersion))
+
+	if (matchingVersions.length === 0) {
+		return undefined
+	}
+
+	// Sort in descending order and return the highest
+	matchingVersions.sort((a, b) => compareSemver(b, a))
+	return matchingVersions[0]
+}
+
+/**
+ * Resolves versioned settings by finding the highest version that is <= the current
+ * plugin version and returning those settings.
+ *
+ * The versionedSettings structure uses version numbers as keys:
+ * ```
+ * versionedSettings: {
+ *   '3.36.4': { includedTools: ['search_replace'], excludedTools: ['apply_diff'] },
+ *   '3.35.0': { includedTools: ['search_replace'] },
+ * }
+ * ```
+ *
+ * This function finds the highest version key that is <= currentVersion and returns
+ * the corresponding settings object. If no version matches, returns an empty object.
+ *
+ * @param versionedSettings The versioned settings object with version keys
+ * @param currentVersion The current plugin version (defaults to Package.version)
+ * @returns The settings object for the highest matching version, or empty object if none match
+ */
+export function resolveVersionedSettings<T extends Record<string, unknown>>(
+	versionedSettings: VersionedSettings,
+	currentVersion: string = Package.version,
+): Partial<T> {
+	const matchingVersion = findHighestMatchingVersion(versionedSettings, currentVersion)
+
+	if (!matchingVersion) {
+		return {}
+	}
+
+	return versionedSettings[matchingVersion] as Partial<T>
+}

+ 2 - 0
src/package.json

@@ -488,6 +488,7 @@
 		"safe-stable-stringify": "^2.5.0",
 		"sanitize-filename": "^1.6.3",
 		"say": "^0.16.0",
+		"semver-compare": "^1.0.0",
 		"serialize-error": "^12.0.0",
 		"shell-quote": "^1.8.2",
 		"simple-git": "^3.27.0",
@@ -524,6 +525,7 @@
 		"@types/node-ipc": "^9.2.3",
 		"@types/proper-lockfile": "^4.1.4",
 		"@types/ps-tree": "^1.1.6",
+		"@types/semver-compare": "^1.0.3",
 		"@types/shell-quote": "^1.7.5",
 		"@types/stream-json": "^1.7.8",
 		"@types/string-similarity": "^4.0.2",