From d438c4e8a0db32e2a0c649af53e19390f17d69d6 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 6 May 2026 17:12:22 +0200 Subject: [PATCH] feat(coding-agent): support path-scoped model config Fixes #947 --- docs/models.md | 17 +++++ packages/coding-agent/src/config/settings.ts | 72 ++++++++++++++++++- .../test/settings-manager.test.ts | 29 ++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) diff --git a/docs/models.md b/docs/models.md index 7a355cb73..086fc487c 100644 --- a/docs/models.md +++ b/docs/models.md @@ -377,6 +377,23 @@ For `enabledModels` and CLI `--models`: - explicit `provider/modelId` entries stay exact - globs and fuzzy matches still operate on concrete models +Global `enabledModels` and `disabledProviders` entries may also be scoped to a path prefix: + +```yaml +enabledModels: + - claude-sonnet-4-5 + - path: ~/work + models: + - anthropic/claude-opus-4-5 +disabledProviders: + - ollama + - path: ~/private + providers: + - anthropic +``` + +String entries apply everywhere. Scoped entries apply when the current working directory is the configured path or one of its subdirectories. Use `path`, `paths`, `pathPrefix`, or `pathPrefixes`; use `models` for `enabledModels`, `providers` for `disabledProviders`, or `values` for either. + ## `/model` and `--list-models` Both surfaces keep provider-prefixed models visible and selectable. diff --git a/packages/coding-agent/src/config/settings.ts b/packages/coding-agent/src/config/settings.ts index 52902aa32..f18f5bce3 100644 --- a/packages/coding-agent/src/config/settings.ts +++ b/packages/coding-agent/src/config/settings.ts @@ -12,6 +12,7 @@ */ import * as fs from "node:fs"; +import * as os from "node:os"; import * as path from "node:path"; import { getAgentDbPath, @@ -98,6 +99,74 @@ function setByPath(obj: RawSettings, segments: string[], value: unknown): void { current[segments[segments.length - 1]] = value; } +const PATH_SCOPED_ARRAY_SETTINGS = new Set(["enabledModels", "disabledProviders"]); + +type PathScopedStringArrayEntry = { + path?: unknown; + paths?: unknown; + pathPrefix?: unknown; + pathPrefixes?: unknown; + values?: unknown; + items?: unknown; + models?: unknown; + providers?: unknown; +}; + +function normalizePathPrefix(prefix: string): string { + const expanded = + prefix === "~" ? os.homedir() : prefix.startsWith("~/") ? path.join(os.homedir(), prefix.slice(2)) : prefix; + return path.resolve(expanded); +} + +function pathMatchesPrefix(cwd: string, prefix: string): boolean { + const relative = path.relative(normalizePathPrefix(prefix), path.resolve(cwd)); + return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function stringArrayFromUnknown(value: unknown): string[] { + if (typeof value === "string") return [value]; + if (Array.isArray(value)) return value.filter((item): item is string => typeof item === "string"); + return []; +} + +function resolvePathScopedStringArray(settingPath: SettingPath, value: unknown, cwd: string): string[] | undefined { + if (!PATH_SCOPED_ARRAY_SETTINGS.has(settingPath) || !Array.isArray(value)) return undefined; + + const resolved: string[] = []; + for (const entry of value) { + if (typeof entry === "string") { + resolved.push(entry); + continue; + } + if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue; + + const scoped = entry as PathScopedStringArrayEntry; + const prefixes = [ + ...stringArrayFromUnknown(scoped.path), + ...stringArrayFromUnknown(scoped.paths), + ...stringArrayFromUnknown(scoped.pathPrefix), + ...stringArrayFromUnknown(scoped.pathPrefixes), + ]; + if (prefixes.length === 0 || !prefixes.some(prefix => pathMatchesPrefix(cwd, prefix))) continue; + + const values = + settingPath === "enabledModels" + ? [ + ...stringArrayFromUnknown(scoped.values), + ...stringArrayFromUnknown(scoped.items), + ...stringArrayFromUnknown(scoped.models), + ] + : [ + ...stringArrayFromUnknown(scoped.values), + ...stringArrayFromUnknown(scoped.items), + ...stringArrayFromUnknown(scoped.providers), + ]; + resolved.push(...values); + } + + return resolved; +} + // ═══════════════════════════════════════════════════════════════════════════ // Settings Class // ═══════════════════════════════════════════════════════════════════════════ @@ -201,7 +270,8 @@ export class Settings { const segments = path.split("."); const value = getByPath(this.#merged, segments); if (value !== undefined) { - return value as SettingValue

; + const pathScopedValue = resolvePathScopedStringArray(path, value, this.#cwd); + return (pathScopedValue ?? value) as SettingValue

; } return getDefault(path); } diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index 109e1f841..82d3ab110 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -80,6 +80,35 @@ describe("Settings", () => { expect((savedSettings.modelRoles as { default?: string } | undefined)?.default).toBe("claude-sonnet"); }); + it("filters model allow-list and disabled providers by current path prefix", async () => { + const workDir = path.join(projectDir, "work", "service"); + const privateDir = path.join(projectDir, "private", "app"); + fs.mkdirSync(workDir, { recursive: true }); + fs.mkdirSync(privateDir, { recursive: true }); + + await writeSettings({ + enabledModels: [ + "claude-sonnet-4-5", + { path: path.join(projectDir, "work"), values: ["anthropic/claude-opus-4-5"] }, + { path: path.join(projectDir, "private"), values: ["openai/gpt-5.2-codex"] }, + ], + disabledProviders: [ + "ollama", + { path: path.join(projectDir, "work"), values: ["openai"] }, + { path: path.join(projectDir, "private"), values: ["anthropic"] }, + ], + }); + + const workSettings = await Settings.init({ cwd: workDir, agentDir }); + expect(workSettings.get("enabledModels")).toEqual(["claude-sonnet-4-5", "anthropic/claude-opus-4-5"]); + expect(workSettings.get("disabledProviders")).toEqual(["ollama", "openai"]); + + _resetSettingsForTest(); + const privateSettings = await Settings.init({ cwd: privateDir, agentDir }); + expect(privateSettings.get("enabledModels")).toEqual(["claude-sonnet-4-5", "openai/gpt-5.2-codex"]); + expect(privateSettings.get("disabledProviders")).toEqual(["ollama", "anthropic"]); + }); + it("should preserve custom settings when changing theme", async () => { await writeSettings({ modelRoles: { default: "claude-sonnet" },