From e31349f5e46f998edb3092369070061f21d70d1b Mon Sep 17 00:00:00 2001 From: hellodk Date: Sun, 30 Aug 2026 20:21:44 +0530 Subject: [PATCH] =?UTF-8?q?refactor:=20single=20source=20of=20config=20?= =?UTF-8?q?=E2=80=94=20~/.champ/config.yaml=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All configuration now comes from the user-level ~/.champ/config.yaml. The workspace-level .champ/config.yaml is no longer read, written, or watched, so config can't branch per-repo or leak keys into committed projects. - resolveConfig reads only ~/.champ/config.yaml; no workspace fallback. - resolveLayered drops the "workspace-yaml" source/layer entirely (#115 -> #126). - generateConfig, configureProvider wizard, selectModel, firstRunSelect, and the MCP add/delete/install handlers all write to ~/.champ instead of the active workspace folder. - persistProviderSettings (saveSettings / Add-Model) writes to ~/.champ only. - File watcher reloads only on ~/.champ/config.yaml changes. - The repo's .champ/config.yaml is untracked and gitignored to prevent re-committing a workspace config. Tests: layered-source rewritten for the single-source contract; saveSettings test asserts the user-level path. Closes #126 --- .champ/config.yaml | 105 --------------- .gitignore | 2 + src/config/config-loader.ts | 77 +++-------- src/extension.ts | 166 ++++++------------------ src/ui/chat-view-provider.ts | 11 +- test/unit/config/layered-source.test.ts | 93 +++---------- test/unit/ui/chat-view-provider.test.ts | 9 +- 7 files changed, 93 insertions(+), 370 deletions(-) delete mode 100644 .champ/config.yaml diff --git a/.champ/config.yaml b/.champ/config.yaml deleted file mode 100644 index f40c73a..0000000 --- a/.champ/config.yaml +++ /dev/null @@ -1,105 +0,0 @@ -# Champ configuration -# Champ auto-discovers all models from every provider listed below. -# API keys: use 'Champ: Set API Key' command or set env vars below. - -# ── Active provider (Smart Router overrides this per task) ───────── -provider: llamacpp - -# ── Providers ────────────────────────────────────────────────────── -# Add baseUrl for any backend you have running. Champ scans each -# one on startup and shows all discovered models in the picker. - -providers: - # ── Local providers (no API key needed) ────────────────────────── - - ollama: - baseUrl: http://127.0.0.1:11434 - model: gemma4:latest - - llamacpp: - baseUrl: http://192.168.1.24:21434/v1 - model: default - - openai-compatible: - baseUrl: http://192.168.1.5:8000/v1 - model: default - apiKey: dummy - - # vllm: - # baseUrl: http://localhost:8000/v1 - # model: meta-llama/Llama-3.1-8B - - # ── Cloud providers (API key required) ─────────────────────────── - # API keys are stored securely via VS Code SecretStorage. - # Set them with: Ctrl+Shift+P → "Champ: Set API Key" - # - # Alternatively, set environment variables: - # export ANTHROPIC_API_KEY=sk-ant-... - # export OPENAI_API_KEY=sk-... - # export GEMINI_API_KEY=AI... - - # claude: - # model: claude-sonnet-4-20250514 - # # API key via 'Champ: Set API Key' or env ANTHROPIC_API_KEY - - # openai: - # model: gpt-4o - # # API key via 'Champ: Set API Key' or env OPENAI_API_KEY - - # gemini: - # model: gemini-2.0-flash - # # API key via 'Champ: Set API Key' or env GEMINI_API_KEY - - # ── Custom OpenAI-compatible endpoint ──────────────────────────── - # openai-compatible: - # baseUrl: http://my-server:9000/v1 - # model: custom-model - # # If your server needs a key, set it via 'Champ: Set API Key' - -# ── Smart Routing ────────────────────────────────────────────────── -# Champ auto-picks the best model per task. Override here if needed. -# routing: -# mode: smart # "smart" (auto) or "manual" (you pick) -# coding: null # force a specific model for coding tasks -# chat: null # force a specific model for chat -# completion: null # force a specific model for ghost-text -# embedding: null # force a specific model for embeddings - -# ── Autocomplete (ghost-text) ────────────────────────────────────── -autocomplete: - enabled: true - debounceMs: 300 - # Use a fast small model for completions: - # provider: ollama - # model: qwen2.5-coder:1.5b - -# ── Agent behavior ───────────────────────────────────────────────── -agent: - yoloMode: false - defaultMode: agent - autoFix: - enabled: true - maxIterations: 3 - -# ── Codebase indexing ────────────────────────────────────────────── -indexing: - enabled: true - embeddingProvider: ollama - ignore: - - node_modules/** - - dist/** - - .git/** - -# ── User rules (injected into system prompt) ────────────────────── -# userRules: | -# Always write tests first. -# Prefer TypeScript strict mode. - -# ── MCP servers (extend agent with external tools) ───────────────── -# mcp: -# servers: -# - name: github -# command: npx -# args: ["-y", "@modelcontextprotocol/server-github"] -# env: -# GITHUB_TOKEN: ${env:GITHUB_TOKEN} diff --git a/.gitignore b/.gitignore index fa6f39d..4aeb180 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ out/ .aidev/ .champ/sessions/ .champ/team-runs/ +# Config lives only in ~/.champ/config.yaml (#126) — never commit a workspace copy. +.champ/config.yaml .superpowers/ graphify-out/ test-reports/ diff --git a/src/config/config-loader.ts b/src/config/config-loader.ts index 8a746cc..1d4e3f7 100644 --- a/src/config/config-loader.ts +++ b/src/config/config-loader.ts @@ -315,7 +315,7 @@ export class ConfigLoader { const dupKey = keyMatch?.[1] ?? "a provider"; throw new Error( `Invalid YAML: duplicate key "${dupKey}" in providers section. ` + - `Open .champ/config.yaml and remove the duplicate block, keeping only one "${dupKey}:" entry. ` + + `Open ~/.champ/config.yaml and remove the duplicate block, keeping only one "${dupKey}:" entry. ` + `Original error: ${msg}`, ); } @@ -1437,17 +1437,18 @@ export class ConfigLoader { } // --------------------------------------------------------------------------- -// Layered resolution with explicit single source (issue #115) +// Resolve the single user-level config (~/.champ/config.yaml, issue #126) // --------------------------------------------------------------------------- -export type ConfigSource = "auto" | "workspace-yaml" | "user-yaml"; +export type ConfigSource = "auto" | "user-yaml"; -export type ConfigLayer = "workspace-yaml" | "user-yaml" | "default"; +export type ConfigLayer = "user-yaml" | "default"; export interface LayeredInput { - /** Raw text of /.champ/config.yaml (null if absent). */ - workspaceText?: string | null; - /** Raw text of ~/.champ/config.yaml (null if absent). */ + /** + * Raw text of ~/.champ/config.yaml (null if absent). This is the single + * source of configuration since #126 — no workspace-level file is read. + */ userText?: string | null; source: ConfigSource; } @@ -1461,7 +1462,7 @@ export interface LayeredResult { usedSource: ConfigLayer; /** Sources that were present but deliberately not consulted. */ ignoredSources: string[]; - /** True under `auto` when both yaml layers existed (workspace won). */ + /** Always false since #126 — there is a single config source. */ conflict: boolean; /** Origin of every top-level key in the final config. */ origins: Record; @@ -1482,43 +1483,25 @@ function mergeWithOrigins( } /** - * Resolve which single config source is active and produce the merged - * result plus per-key provenance. Pure: callers feed file contents. + * Resolve the effective config from the single user-level YAML file. + * Pure: the caller feeds the file contents. * - * Precedence under `auto` (unchanged from historical behaviour): - * workspace yaml > user yaml > settings (caller falls back when config=null) + * Since #126 there is exactly one config source — `~/.champ/config.yaml`. + * Workspace-level config is no longer consulted. */ export function resolveLayered(input: LayeredInput): LayeredResult { const ignoredSources: string[] = []; - let wsText = input.workspaceText ?? null; - let userText = input.userText ?? null; - - if (input.source === "workspace-yaml") { - if (userText) ignoredSources.push("user-yaml"); - userText = null; - } else if (input.source === "user-yaml") { - if (wsText) ignoredSources.push("workspace-yaml"); - wsText = null; - } + const userText = input.userText ?? null; - const parse = (text: string | null): ChampConfig | null => - text ? ConfigLoader.parseYaml(text) : null; - - if (input.source === "workspace-yaml" && !wsText) { - throw new Error( - "Invalid YAML or missing file: workspace .champ/config.yaml not found but source=workspace-yaml", - ); - } if (input.source === "user-yaml" && !userText) { throw new Error( "Invalid YAML or missing file: ~/.champ/config.yaml not found but source=user-yaml", ); } - const ws = parse(wsText); - const user = parse(userText); + const user = userText ? ConfigLoader.parseYaml(userText) : null; - if (!ws && !user) { + if (!user) { return { config: null, usedSource: "default", @@ -1530,33 +1513,15 @@ export function resolveLayered(input: LayeredInput): LayeredResult { const origins: Record = {}; let config: ChampConfig = {}; - let usedSource: ConfigLayer; - - if (ws && user) { - config = mergeWithOrigins(config, user, "user-yaml", origins); - config = mergeWithOrigins(config, ws, "workspace-yaml", origins); - usedSource = input.source === "auto" ? "workspace-yaml" : input.source; - } else if (ws) { - config = mergeWithOrigins(config, ws, "workspace-yaml", origins); - usedSource = "workspace-yaml"; - } else { - config = mergeWithOrigins( - config, - user as ChampConfig, - "user-yaml", - origins, - ); - usedSource = "user-yaml"; - } + config = mergeWithOrigins(config, user, "user-yaml", origins); + const usedSource: ConfigLayer = "user-yaml"; - // Defaults fill whatever no yaml layer supplied. + // Defaults fill whatever the yaml layer didn't supply. config = ConfigLoader.withDefaults(config); for (const key of Object.keys(config)) { if (!origins[key]) origins[key] = "default"; } - // Keys that were overridden keep their top-layer origin even after - // defaults merged — recompute is unnecessary since withDefaults only - // fills missing keys, but be explicit: + // Drop any keys that were overridden away by defaults merging. for (const key of Object.keys(origins)) { if ((config as Record)[key] === undefined) { delete (config as Record)[key]; @@ -1567,7 +1532,7 @@ export function resolveLayered(input: LayeredInput): LayeredResult { config, usedSource, ignoredSources, - conflict: Boolean(ws && user) && input.source === "auto", + conflict: false, origins, }; } diff --git a/src/extension.ts b/src/extension.ts index dc369b6..5c628dd 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1579,15 +1579,8 @@ export async function activate( ); }), vscode.commands.registerCommand("champ.generateConfig", async () => { - const activeFolder = resolveActiveWorkspaceFolder() ?? workspaceRoot; - if (!activeFolder) { - void vscode.window.showErrorMessage( - "Champ: open a workspace folder before generating a config file.", - ); - return; - } const targetUri = vscode.Uri.file( - path.join(activeFolder, ".champ", "config.yaml"), + path.join(os.homedir(), ".champ", "config.yaml"), ); // If file exists, just open it (don't prompt to overwrite). try { @@ -1601,7 +1594,7 @@ export async function activate( const template = generateDefaultConfigYaml(); try { await vscode.workspace.fs.createDirectory( - vscode.Uri.file(path.join(activeFolder, ".champ")), + vscode.Uri.file(path.join(os.homedir(), ".champ")), ); } catch { // Directory may already exist. @@ -1613,7 +1606,7 @@ export async function activate( const doc = await vscode.workspace.openTextDocument(targetUri); await vscode.window.showTextDocument(doc); void vscode.window.showInformationMessage( - "Champ: created .champ/config.yaml. Edit it and save to apply.", + "Champ: created ~/.champ/config.yaml. Edit it and save to apply.", ); }), // Guided provider setup wizard (#118). Collects provider → endpoint → @@ -1747,10 +1740,7 @@ export async function activate( if (outcome.status === "cancelled") return; - const activeFolder = resolveActiveWorkspaceFolder() ?? workspaceRoot; - const targetDirUri = vscode.Uri.file( - path.join(activeFolder ?? os.homedir(), ".champ"), - ); + const targetDirUri = vscode.Uri.file(path.join(os.homedir(), ".champ")); const targetUri = vscode.Uri.file( path.join(targetDirUri.fsPath, "config.yaml"), ); @@ -1865,18 +1855,11 @@ export async function activate( ); } } - // Surgically rewrite the workspace YAML's top-level - // `provider:` line. Comments and the rest of the file are - // preserved. The file watcher fires loadProvider() which - // broadcasts a fresh providerStatus to the chat view. - const activeFolder = resolveActiveWorkspaceFolder() ?? workspaceRoot; - if (!activeFolder) { - void vscode.window.showErrorMessage( - "Champ: cannot switch model without an open workspace.", - ); - return; - } - const yamlPath = path.join(activeFolder, ".champ", "config.yaml"); + // Surgically rewrite ~/.champ/config.yaml's top-level `provider:` + // line. Comments and the rest of the file are preserved. The file + // watcher fires loadProvider() which broadcasts a fresh + // providerStatus to the chat view. + const yamlPath = path.join(os.homedir(), ".champ", "config.yaml"); const yamlUri = vscode.Uri.file(yamlPath); let text: string; let configExisted = true; @@ -1890,7 +1873,7 @@ export async function activate( const template = generateDefaultConfigYaml(); try { await vscode.workspace.fs.createDirectory( - vscode.Uri.file(path.join(activeFolder, ".champ")), + vscode.Uri.file(path.join(os.homedir(), ".champ")), ); } catch { /* dir already exists */ @@ -1937,16 +1920,9 @@ export async function activate( ); return; } - const activeFolder = resolveActiveWorkspaceFolder() ?? workspaceRoot; - if (!activeFolder) { - void vscode.window.showErrorMessage( - "Champ: open a workspace folder before creating a config file.", - ); - return; - } - const targetDir = vscode.Uri.file(path.join(activeFolder, ".champ")); + const targetDir = vscode.Uri.file(path.join(os.homedir(), ".champ")); const targetUri = vscode.Uri.file( - path.join(activeFolder, ".champ", "config.yaml"), + path.join(os.homedir(), ".champ", "config.yaml"), ); try { await vscode.workspace.fs.createDirectory(targetDir); @@ -1960,7 +1936,7 @@ export async function activate( const doc = await vscode.workspace.openTextDocument(targetUri); await vscode.window.showTextDocument(doc); void vscode.window.showInformationMessage( - `Champ: created .champ/config.yaml from "${template.label}". Edit and save to customize.`, + `Champ: created ~/.champ/config.yaml from "${template.label}". Edit and save to customize.`, ); void context.globalState.update(FIRST_RUN_COMPLETE_KEY, true); // The file watcher fires loadProvider() automatically. @@ -2095,15 +2071,9 @@ export async function activate( "No YAML config active — VS Code settings (champ.*) are the source.", ); } else { - const { result, workspacePath, userPath } = lastLayeredInfo; + const { result, userPath } = lastLayeredInfo; channel.appendLine(`Source used : ${result.usedSource}`); - channel.appendLine(` workspace : ${workspacePath ?? "(none)"}`); channel.appendLine(` user : ${userPath}`); - if (result.ignoredSources.length > 0) { - channel.appendLine( - `Ignored : ${result.ignoredSources.join(", ")} (workspace > user)`, - ); - } channel.appendLine(""); channel.appendLine("Final merged config:"); channel.appendLine( @@ -2183,14 +2153,7 @@ export async function activate( }, action: "add" | "delete", ) => { - const activeMcpFolder = resolveActiveWorkspaceFolder() ?? workspaceRoot; - if (!activeMcpFolder) { - void vscode.window.showErrorMessage( - "Champ: open a workspace to configure MCP servers.", - ); - return; - } - const configPath = path.join(activeMcpFolder, ".champ", "config.yaml"); + const configPath = path.join(os.homedir(), ".champ", "config.yaml"); let rawConfig = ""; try { rawConfig = new TextDecoder().decode( @@ -2288,20 +2251,7 @@ export async function activate( const newServer = buildMcpServerConfig(entry, resolvedEnv); - const activeMcpInstallFolder = - resolveActiveWorkspaceFolder() ?? workspaceRoot; - if (!activeMcpInstallFolder) { - void vscode.window.showErrorMessage( - "Champ: open a workspace to install MCP servers.", - ); - return; - } - - const configPath = path.join( - activeMcpInstallFolder, - ".champ", - "config.yaml", - ); + const configPath = path.join(os.homedir(), ".champ", "config.yaml"); let rawConfig = ""; try { rawConfig = new TextDecoder().decode( @@ -3340,55 +3290,38 @@ export async function activate( }), ); - // ---- Config loader (YAML-only since #118) ----------------------- + // ---- Config loader (YAML-only since #118; single user config since #126) - /** - * Resolve the effective ChampConfig from (in order of precedence): - * 1. /.champ/config.yaml - * 2. ~/.champ/config.yaml - * 3. built-in defaults + * Resolve the effective ChampConfig from `~/.champ/config.yaml`. This is + * the single source of configuration — no workspace-level file is read. * - * Returns null when no source has a usable config — the loader path - * is then skipped and loadProvider surfaces the "run Champ: Configure - * Provider" hint. Errors during YAML parsing are surfaced to the user - * but do not crash activation. + * Returns null when no usable config exists — the loader path is then + * skipped and loadProvider surfaces the "run Champ: Configure Provider" + * hint. Errors during YAML parsing are surfaced to the user but do not + * crash activation. */ - // Last layered-resolution outcome, for Show Effective Config (#115). + // Last resolution outcome, for Show Effective Config (#115). let lastLayeredInfo: { result: import("./config/config-loader").LayeredResult; - workspacePath: string | null; userPath: string; } | null = null; - let conflictNoticeShown = false; const resolveConfig = async (): Promise => { - const activeFolder = resolveActiveWorkspaceFolder(); - const workspacePath = activeFolder - ? path.join(activeFolder, ".champ", "config.yaml") - : null; const userPath = path.join(os.homedir(), ".champ", "config.yaml"); - // Read raw bytes only — parse errors are attributed to their layer by - // resolveLayered so users learn WHICH file is broken (#115). - const readRaw = async (p: string | null): Promise => { - if (!p) return null; - try { - return new TextDecoder().decode( - await vscode.workspace.fs.readFile(vscode.Uri.file(p)), - ); - } catch { - return null; // missing file - } - }; + // Read raw bytes only — parse errors are surfaced to the user. + let raw: string | null; + try { + raw = new TextDecoder().decode( + await vscode.workspace.fs.readFile(vscode.Uri.file(userPath)), + ); + } catch { + return null; // missing file — no config yet + } - // YAML is the only source of truth since #118: favour the workspace - // .champ/config.yaml, then ~/.champ/config.yaml. let result: import("./config/config-loader").LayeredResult; try { - result = resolveLayered({ - workspaceText: await readRaw(workspacePath), - userText: await readRaw(userPath), - source: "auto", - }); + result = resolveLayered({ userText: raw, source: "auto" }); } catch (err) { void vscode.window.showErrorMessage( `Champ: ${err instanceof Error ? err.message : String(err)}`, @@ -3396,19 +3329,7 @@ export async function activate( return null; } - lastLayeredInfo = { result, workspacePath, userPath }; - - if (result.ignoredSources.length > 0) { - console.log( - `Champ config: ignoring ${result.ignoredSources.join(", ")} (auto precedence)`, - ); - } - if (result.conflict && !conflictNoticeShown) { - conflictNoticeShown = true; - void vscode.window.showInformationMessage( - `Champ: workspace .champ/config.yaml overrides ~/.champ/config.yaml.`, - ); - } + lastLayeredInfo = { result, userPath }; if (!result.config) return null; return ConfigLoader.withDefaults(ConfigLoader.substituteEnv(result.config)); @@ -3507,7 +3428,7 @@ export async function activate( ? await factory.createFromChampConfig(yamlConfig, context.secrets) : (() => { throw new Error( - "No Champ configuration found. Run 'Champ: Configure Provider' or create .champ/config.yaml.", + "No Champ configuration found. Run 'Champ: Configure Provider' or create ~/.champ/config.yaml.", ); })(); // Wrap primary provider in a CircuitBreaker so repeated failures stop @@ -3879,7 +3800,7 @@ export async function activate( }); chatViewProvider?.postMessage({ type: "error", - message: `Champ provider not ready: ${message}\n\nOpen settings (gear icon in the status bar) to configure the active provider, or create a .champ/config.yaml file.`, + message: `Champ provider not ready: ${message}\n\nOpen settings (gear icon in the status bar) to configure the active provider, or create a ~/.champ/config.yaml file.`, }); console.error("Champ: provider load failed:", err); } @@ -3903,7 +3824,7 @@ export async function activate( `Available agents: ${[...registeredAgentNames].join(", ")}`, ); void vscode.window.showWarningMessage( - `Champ: trigger "${trigger.name}" references unknown agent "${trigger.run}". Check your .champ/config.yaml.`, + `Champ: trigger "${trigger.name}" references unknown agent "${trigger.run}". Check your ~/.champ/config.yaml.`, ); } } @@ -4261,15 +4182,12 @@ export async function activate( }), ); - // Watch .champ/config.yaml in every open workspace folder and in ~/.champ/ - // for live reload. Created, changed, or deleted — any triggers a provider reload. + // Watch ~/.champ/config.yaml for live reload. Created, changed, or + // deleted — any triggers a provider reload. Since #126, config comes only + // from the user-level file, so no workspace folder is watched. { const yamlWatchers: vscode.FileSystemWatcher[] = []; - const watchedRoots = [ - ...(vscode.workspace.workspaceFolders?.map((f) => f.uri.fsPath) ?? - (workspaceRoot ? [workspaceRoot] : [])), - os.homedir(), - ]; + const watchedRoots = [os.homedir()]; let configReloadTimer: ReturnType | undefined; const debouncedReload = () => { if (configReloadTimer) clearTimeout(configReloadTimer); diff --git a/src/ui/chat-view-provider.ts b/src/ui/chat-view-provider.ts index 932f5e9..fc66c40 100644 --- a/src/ui/chat-view-provider.ts +++ b/src/ui/chat-view-provider.ts @@ -836,18 +836,17 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { /** * Persist a provider selection (from the in-webview settings overlay or - * Add-Model dialogue) into .champ/config.yaml (#123). The YAML file is - * the single config source since #118; the legacy champ.* VS Code - * settings no longer exist. Targets the workspace .champ/config.yaml, - * falling back to ~/.champ/config.yaml when no workspace is open. + * Add-Model dialogue) into ~/.champ/config.yaml (#123/#126). The YAML file + * is the single config source since #118; the legacy champ.* VS Code + * settings no longer exist. Since #126 the config lives only at the + * user level — never the workspace. */ private async persistProviderSettings( providerId: string, model: string, baseUrl?: string, ): Promise { - const root = - vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? os.homedir(); + const root = os.homedir(); const dirUri = vscode.Uri.file(path.join(root, ".champ")); const fileUri = vscode.Uri.file(path.join(root, ".champ", "config.yaml")); diff --git a/test/unit/config/layered-source.test.ts b/test/unit/config/layered-source.test.ts index 9b476a6..5178dae 100644 --- a/test/unit/config/layered-source.test.ts +++ b/test/unit/config/layered-source.test.ts @@ -1,26 +1,16 @@ /** - * TDD: layered config resolution (#115 / #118). + * TDD: config resolution (single user-level source, #126). * - * Contract (YAML-only since #118): - * - `.champ/config.yaml` (workspace) > `~/.champ/config.yaml` (user). - * The old `champ.configSource` setting and the legacy settings layer are - * gone — YAML is the single source of truth. - * - ignoredSources reports which files were deliberately not consulted. + * Contract since #126: + * - `~/.champ/config.yaml` is the SINGLE source of configuration. No + * workspace-level `.champ/config.yaml` is ever consulted. + * - A workspace file, even when present, is ignored entirely. * - origins maps every top-level key of the final config to the layer - * that supplied it ("workspace-yaml" | "user-yaml" | "default"). + * that supplied it ("user-yaml" | "default"). */ import { describe, it, expect } from "vitest"; import { ConfigLoader, resolveLayered } from "@/config/config-loader"; -const WS_YAML = ` -provider: openai-compatible -providers: - openai-compatible: - baseUrl: http://192.168.1.5:8000/v1 - model: mlx-community--Qwen3.5-4B-MLX-4bit - apiKey: dummy - supportsTools: true -`; const USER_YAML = ` provider: llamacpp providers: @@ -29,32 +19,19 @@ providers: model: default `; -describe("resolveLayered (#115)", () => { - it("auto: workspace beats user when both exist, flags conflict", () => { - const r = resolveLayered({ - workspaceText: WS_YAML, - userText: USER_YAML, - source: "auto", - }); - expect(r.usedSource).toBe("workspace-yaml"); - expect(r.config?.provider).toBe("openai-compatible"); - expect(r.conflict).toBe(true); - }); - - it("auto: user used when workspace absent", () => { +describe("resolveLayered (#126 single user config)", () => { + it("user yaml is used when present", () => { const r = resolveLayered({ - workspaceText: null, userText: USER_YAML, - source: "auto", + source: "user-yaml", }); expect(r.usedSource).toBe("user-yaml"); expect(r.config?.provider).toBe("llamacpp"); expect(r.conflict).toBe(false); }); - it("auto: null config when no yaml at all (no settings fallback, #118)", () => { + it("null config when no user yaml at all (no workspace fallback)", () => { const r = resolveLayered({ - workspaceText: null, userText: null, source: "auto", }); @@ -62,58 +39,22 @@ describe("resolveLayered (#115)", () => { expect(r.config).toBeNull(); }); - it("explicit workspace-yaml ignores user file entirely", () => { - const r = resolveLayered({ - workspaceText: WS_YAML, - userText: USER_YAML, - source: "workspace-yaml", - }); - expect(r.usedSource).toBe("workspace-yaml"); - expect(r.config?.provider).toBe("openai-compatible"); - expect(r.conflict).toBe(false); - expect(r.ignoredSources).toContain("user-yaml"); - }); - - it("explicit user-yaml ignores workspace file", () => { - const r = resolveLayered({ - workspaceText: WS_YAML, - userText: USER_YAML, - source: "user-yaml", - }); - expect(r.usedSource).toBe("user-yaml"); - expect(r.config?.provider).toBe("llamacpp"); - expect(r.ignoredSources).toContain("workspace-yaml"); - }); - - it("unknown source: the 'settings' option no longer exists (#118)", () => { - const r = resolveLayered({ - workspaceText: WS_YAML, - userText: USER_YAML, - source: "auto", - }); - expect(r.usedSource).toBe("workspace-yaml"); - // settings is not a config source anymore — YAML is the only store. - expect(["workspace-yaml", "user-yaml", "default"]).toContain(r.usedSource); - }); - it("origins attribute each top-level key to its layer", () => { const r = resolveLayered({ - workspaceText: WS_YAML, userText: USER_YAML, - source: "auto", + source: "user-yaml", }); - // provider + providers come from workspace; defaults filled the rest - expect(r.origins["provider"]).toBe("workspace-yaml"); - expect(r.origins["providers"]).toBe("workspace-yaml"); + // provider + providers come from the user yaml; defaults filled the rest + expect(r.origins["provider"]).toBe("user-yaml"); + expect(r.origins["providers"]).toBe("user-yaml"); expect(r.origins["autocomplete"]).toBe("default"); }); - it("invalid YAML in chosen layer surfaces as error, not silent skip", () => { + it("invalid YAML in the user layer surfaces as error, not silent skip", () => { expect(() => resolveLayered({ - workspaceText: "provider: [broken", - userText: USER_YAML, - source: "workspace-yaml", + userText: "provider: [broken", + source: "user-yaml", }), ).toThrow(/Invalid YAML/); }); diff --git a/test/unit/ui/chat-view-provider.test.ts b/test/unit/ui/chat-view-provider.test.ts index 57924b8..7df2608 100644 --- a/test/unit/ui/chat-view-provider.test.ts +++ b/test/unit/ui/chat-view-provider.test.ts @@ -6,6 +6,7 @@ * WebviewView resolution itself — that's exercised via F5/E2E tests. */ import { describe, it, expect, beforeEach, vi } from "vitest"; +import * as os from "node:os"; import { ChatViewProvider } from "@/ui/chat-view-provider"; import type { AgentController } from "@/agent/agent-controller"; @@ -985,9 +986,11 @@ describe("ChatViewProvider", () => { expect(writeFile).toHaveBeenCalled(); const [uri, bytes] = writeFile.mock.calls[0] as [unknown, Uint8Array]; - expect(String((uri as { fsPath: string }).fsPath)).toContain( - ".champ/config.yaml", - ); + const fsPath = String((uri as { fsPath: string }).fsPath); + // Since #126 the config is written only to the user-level path, never + // a workspace-local .champ/config.yaml. + expect(fsPath).toContain(".champ/config.yaml"); + expect(fsPath.startsWith(os.homedir())).toBe(true); const text = new TextDecoder().decode(bytes); expect(text).toContain("provider: openai-compatible"); expect(text).toContain("baseUrl: http://192.168.1.5:8000/v1");