@@ -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.
|
||||
|
||||
@@ -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<SettingPath>(["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<P>;
|
||||
const pathScopedValue = resolvePathScopedStringArray(path, value, this.#cwd);
|
||||
return (pathScopedValue ?? value) as SettingValue<P>;
|
||||
}
|
||||
return getDefault(path);
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user