import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; import { join } from "path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { SettingsManager } from "../src/core/settings-manager.js"; describe("SettingsManager", () => { const testDir = join(process.cwd(), "test-settings-tmp"); const agentDir = join(testDir, "agent"); const projectDir = join(testDir, "project"); beforeEach(() => { // Clean up and create fresh directories if (existsSync(testDir)) { rmSync(testDir, { recursive: true }); } mkdirSync(agentDir, { recursive: true }); mkdirSync(join(projectDir, ".pi"), { recursive: true }); }); afterEach(() => { if (existsSync(testDir)) { rmSync(testDir, { recursive: true }); } }); describe("preserves externally added settings", () => { it("should preserve enabledModels when changing thinking level", async () => { // Create initial settings file const settingsPath = join(agentDir, "settings.json"); writeFileSync( settingsPath, JSON.stringify({ theme: "dark", defaultModel: "claude-sonnet", }), ); // Create SettingsManager (simulates pi starting up) const manager = SettingsManager.create(projectDir, agentDir); // Simulate user editing settings.json externally to add enabledModels const currentSettings = JSON.parse(readFileSync(settingsPath, "utf-8")); currentSettings.enabledModels = ["claude-opus-4-5", "gpt-5.2-codex"]; writeFileSync(settingsPath, JSON.stringify(currentSettings, null, 2)); // User changes thinking level via Shift+Tab manager.setDefaultThinkingLevel("high"); await manager.flush(); // Verify enabledModels is preserved const savedSettings = JSON.parse(readFileSync(settingsPath, "utf-8")); expect(savedSettings.enabledModels).toEqual(["claude-opus-4-5", "gpt-5.2-codex"]); expect(savedSettings.defaultThinkingLevel).toBe("high"); expect(savedSettings.theme).toBe("dark"); expect(savedSettings.defaultModel).toBe("claude-sonnet"); }); it("should preserve custom settings when changing theme", async () => { const settingsPath = join(agentDir, "settings.json"); writeFileSync( settingsPath, JSON.stringify({ defaultModel: "claude-sonnet", }), ); const manager = SettingsManager.create(projectDir, agentDir); // User adds custom settings externally const currentSettings = JSON.parse(readFileSync(settingsPath, "utf-8")); currentSettings.shellPath = "/bin/zsh"; currentSettings.extensions = ["/path/to/extension.ts"]; writeFileSync(settingsPath, JSON.stringify(currentSettings, null, 2)); // User changes theme manager.setTheme("light"); await manager.flush(); // Verify all settings preserved const savedSettings = JSON.parse(readFileSync(settingsPath, "utf-8")); expect(savedSettings.shellPath).toBe("/bin/zsh"); expect(savedSettings.extensions).toEqual(["/path/to/extension.ts"]); expect(savedSettings.theme).toBe("light"); }); it("should let in-memory changes override file changes for same key", async () => { const settingsPath = join(agentDir, "settings.json"); writeFileSync( settingsPath, JSON.stringify({ theme: "dark", }), ); const manager = SettingsManager.create(projectDir, agentDir); // User externally sets thinking level to "low" const currentSettings = JSON.parse(readFileSync(settingsPath, "utf-8")); currentSettings.defaultThinkingLevel = "low"; writeFileSync(settingsPath, JSON.stringify(currentSettings, null, 2)); // But then changes it via UI to "high" manager.setDefaultThinkingLevel("high"); await manager.flush(); // In-memory change should win const savedSettings = JSON.parse(readFileSync(settingsPath, "utf-8")); expect(savedSettings.defaultThinkingLevel).toBe("high"); }); }); describe("packages migration", () => { it("should keep local-only extensions in extensions array", () => { const settingsPath = join(agentDir, "settings.json"); writeFileSync( settingsPath, JSON.stringify({ extensions: ["/local/ext.ts", "./relative/ext.ts"], }), ); const manager = SettingsManager.create(projectDir, agentDir); expect(manager.getPackages()).toEqual([]); expect(manager.getExtensionPaths()).toEqual(["/local/ext.ts", "./relative/ext.ts"]); }); it("should handle packages with filtering objects", () => { const settingsPath = join(agentDir, "settings.json"); writeFileSync( settingsPath, JSON.stringify({ packages: [ "npm:simple-pkg", { source: "npm:shitty-extensions", extensions: ["extensions/oracle.ts"], skills: [], }, ], }), ); const manager = SettingsManager.create(projectDir, agentDir); const packages = manager.getPackages(); expect(packages).toHaveLength(2); expect(packages[0]).toBe("npm:simple-pkg"); expect(packages[1]).toEqual({ source: "npm:shitty-extensions", extensions: ["extensions/oracle.ts"], skills: [], }); }); }); describe("reload", () => { it("should reload global settings from disk", () => { const settingsPath = join(agentDir, "settings.json"); writeFileSync( settingsPath, JSON.stringify({ theme: "dark", extensions: ["/before.ts"], }), ); const manager = SettingsManager.create(projectDir, agentDir); writeFileSync( settingsPath, JSON.stringify({ theme: "light", extensions: ["/after.ts"], defaultModel: "claude-sonnet", }), ); manager.reload(); expect(manager.getTheme()).toBe("light"); expect(manager.getExtensionPaths()).toEqual(["/after.ts"]); expect(manager.getDefaultModel()).toBe("claude-sonnet"); }); it("should keep previous settings when file is invalid", () => { const settingsPath = join(agentDir, "settings.json"); writeFileSync(settingsPath, JSON.stringify({ theme: "dark" })); const manager = SettingsManager.create(projectDir, agentDir); writeFileSync(settingsPath, "{ invalid json"); manager.reload(); expect(manager.getTheme()).toBe("dark"); }); }); describe("error tracking", () => { it("should collect and clear load errors via drainErrors", () => { const globalSettingsPath = join(agentDir, "settings.json"); const projectSettingsPath = join(projectDir, ".pi", "settings.json"); writeFileSync(globalSettingsPath, "{ invalid global json"); writeFileSync(projectSettingsPath, "{ invalid project json"); const manager = SettingsManager.create(projectDir, agentDir); const errors = manager.drainErrors(); expect(errors).toHaveLength(2); expect(errors.map((e) => e.scope).sort()).toEqual(["global", "project"]); expect(manager.drainErrors()).toEqual([]); }); }); describe("project settings directory creation", () => { it("should not create .pi folder when only reading project settings", () => { // Create agent dir with global settings, but NO .pi folder in project const settingsPath = join(agentDir, "settings.json"); writeFileSync(settingsPath, JSON.stringify({ theme: "dark" })); // Delete the .pi folder that beforeEach created rmSync(join(projectDir, ".pi"), { recursive: true }); // Create SettingsManager (reads both global and project settings) const manager = SettingsManager.create(projectDir, agentDir); // .pi folder should NOT have been created just from reading expect(existsSync(join(projectDir, ".pi"))).toBe(false); // Settings should still be loaded from global expect(manager.getTheme()).toBe("dark"); }); it("should create .pi folder when writing project settings", async () => { // Create agent dir with global settings, but NO .pi folder in project const settingsPath = join(agentDir, "settings.json"); writeFileSync(settingsPath, JSON.stringify({ theme: "dark" })); // Delete the .pi folder that beforeEach created rmSync(join(projectDir, ".pi"), { recursive: true }); const manager = SettingsManager.create(projectDir, agentDir); // .pi folder should NOT exist yet expect(existsSync(join(projectDir, ".pi"))).toBe(false); // Write a project-specific setting manager.setProjectPackages([{ source: "npm:test-pkg" }]); await manager.flush(); // Now .pi folder should exist expect(existsSync(join(projectDir, ".pi"))).toBe(true); // And settings file should be created expect(existsSync(join(projectDir, ".pi", "settings.json"))).toBe(true); }); }); describe("shellCommandPrefix", () => { it("should load shellCommandPrefix from settings", () => { const settingsPath = join(agentDir, "settings.json"); writeFileSync(settingsPath, JSON.stringify({ shellCommandPrefix: "shopt -s expand_aliases" })); const manager = SettingsManager.create(projectDir, agentDir); expect(manager.getShellCommandPrefix()).toBe("shopt -s expand_aliases"); }); it("should return undefined when shellCommandPrefix is not set", () => { const settingsPath = join(agentDir, "settings.json"); writeFileSync(settingsPath, JSON.stringify({ theme: "dark" })); const manager = SettingsManager.create(projectDir, agentDir); expect(manager.getShellCommandPrefix()).toBeUndefined(); }); it("should preserve shellCommandPrefix when saving unrelated settings", async () => { const settingsPath = join(agentDir, "settings.json"); writeFileSync(settingsPath, JSON.stringify({ shellCommandPrefix: "shopt -s expand_aliases" })); const manager = SettingsManager.create(projectDir, agentDir); manager.setTheme("light"); await manager.flush(); const savedSettings = JSON.parse(readFileSync(settingsPath, "utf-8")); expect(savedSettings.shellCommandPrefix).toBe("shopt -s expand_aliases"); expect(savedSettings.theme).toBe("light"); }); }); });