feat(KB-185): add global/project settings hierarchy with scope-aware UI

- Add GlobalSettingsStore with file-based storage in ~/.pi/kb/settings.json
- Implement two-tier hierarchy: global (user prefs) and project (.kb/config.json)
- Update TaskStore to merge settings with project overriding global
- Add API endpoints: GET/PUT /api/settings/global and GET /api/settings/scopes
- Add scope indicators (🌐 global, 📁 project) to settings modal sidebar
- Implement scope-aware save: only save active scope, reject global-only fields
- Add frontend API functions for global settings operations
- Add tests for GlobalSettingsStore, settings routes, and SettingsModal scopes
- Create changeset for the new settings hierarchy feature
- Update AGENTS.md with settings hierarchy documentation
This commit is contained in:
gsxdsm
2026-03-31 03:12:33 -07:00
parent dfe5601622
commit dac4705761
15 changed files with 1178 additions and 104 deletions

View File

@@ -0,0 +1,5 @@
---
"@dustinbyrne/kb": minor
---
Add two-tier settings hierarchy: global user settings (~/.pi/kb/settings.json) and project-specific settings (.kb/config.json). Global settings (theme, default AI model, notifications) persist across all kb projects. Dashboard UI shows scope indicators and routes saves to the correct scope.

View File

@@ -105,7 +105,38 @@ Use `useBadgeWebSocket()` when a UI surface needs live badge snapshots for speci
## Settings ## Settings
The following settings are available in the kb configuration (stored in `.kb/config.json`): kb uses a two-tier settings hierarchy:
- **Global settings** — User preferences stored in `~/.pi/kb/settings.json`. These persist across all kb projects for the current user.
- **Project settings** — Project-specific workflow and resource settings stored in `.kb/config.json`. These control how the engine operates for a particular project.
When reading settings, project values override global values. The merged view is what the engine and dashboard use.
### Settings Hierarchy
**Global settings** (`~/.pi/kb/settings.json`):
- `themeMode` — UI theme preference (dark/light/system)
- `colorTheme` — Color theme (default/ocean/forest/etc)
- `defaultProvider` — Default AI model provider
- `defaultModelId` — Default AI model ID
- `defaultThinkingLevel` — Default thinking effort level
- `ntfyEnabled` — Enable push notifications
- `ntfyTopic` — ntfy.sh topic for notifications
**Project settings** (`~/.kb/config.json`):
- All other settings listed below (concurrency, merge, worktrees, commands, etc.)
The dashboard Settings modal shows scope indicators (🌐 global, 📁 project) in the sidebar to help users understand where each setting is stored. Saving only updates the scope matching the active section.
### API Endpoints
- `GET /api/settings` — Returns the merged view (project overrides global)
- `PUT /api/settings` — Updates project-level settings only (rejects global-only fields with 400)
- `GET /api/settings/global` — Returns global settings
- `PUT /api/settings/global` — Updates global settings
- `GET /api/settings/scopes` — Returns settings separated by scope: `{ global, project }`
The following settings are available in the kb configuration:
### `autoResolveConflicts` (default: `true`) ### `autoResolveConflicts` (default: `true`)

View File

@@ -0,0 +1,185 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { GlobalSettingsStore } from "./global-settings.js";
import { DEFAULT_GLOBAL_SETTINGS } from "./types.js";
import { readFile, rm, writeFile, mkdir } from "node:fs/promises";
import { join } from "node:path";
import { mkdtempSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-global-settings-test-"));
}
describe("GlobalSettingsStore", () => {
let dir: string;
let store: GlobalSettingsStore;
beforeEach(() => {
dir = makeTmpDir();
store = new GlobalSettingsStore(dir);
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
describe("init()", () => {
it("creates the directory and settings.json if missing", async () => {
const nested = join(dir, "nested", "deep");
const nestedStore = new GlobalSettingsStore(nested);
const created = await nestedStore.init();
expect(created).toBe(true);
expect(existsSync(join(nested, "settings.json"))).toBe(true);
});
it("creates settings.json with defaults on first init", async () => {
await store.init();
const raw = await readFile(join(dir, "settings.json"), "utf-8");
const parsed = JSON.parse(raw);
expect(parsed.themeMode).toBe("dark");
expect(parsed.colorTheme).toBe("default");
expect(parsed.ntfyEnabled).toBe(false);
});
it("returns false if settings.json already exists", async () => {
await store.init(); // creates file
const created = await store.init(); // second call
expect(created).toBe(false);
});
it("preserves existing settings on re-init", async () => {
await store.init();
await store.updateSettings({ themeMode: "light" });
const created = await store.init();
expect(created).toBe(false);
const settings = await store.getSettings();
expect(settings.themeMode).toBe("light");
});
});
describe("getSettings()", () => {
it("returns defaults when file does not exist", async () => {
const settings = await store.getSettings();
expect(settings).toEqual(DEFAULT_GLOBAL_SETTINGS);
});
it("returns persisted values merged with defaults", async () => {
await mkdir(dir, { recursive: true });
await writeFile(
join(dir, "settings.json"),
JSON.stringify({ themeMode: "light", colorTheme: "ocean" }),
);
const settings = await store.getSettings();
expect(settings.themeMode).toBe("light");
expect(settings.colorTheme).toBe("ocean");
// Defaults are filled in for missing fields
expect(settings.ntfyEnabled).toBe(false);
expect(settings.defaultProvider).toBeUndefined();
});
it("returns defaults on invalid JSON", async () => {
await mkdir(dir, { recursive: true });
await writeFile(join(dir, "settings.json"), "not-json{{{");
const settings = await store.getSettings();
expect(settings).toEqual(DEFAULT_GLOBAL_SETTINGS);
});
it("returns defaults when directory does not exist", async () => {
const nonExistent = new GlobalSettingsStore(join(dir, "nope", "nada"));
const settings = await nonExistent.getSettings();
expect(settings).toEqual(DEFAULT_GLOBAL_SETTINGS);
});
});
describe("updateSettings()", () => {
it("persists a partial update and returns merged settings", async () => {
await store.init();
const updated = await store.updateSettings({ themeMode: "system" });
expect(updated.themeMode).toBe("system");
expect(updated.colorTheme).toBe("default"); // unchanged default
// Verify persistence
const raw = await readFile(join(dir, "settings.json"), "utf-8");
const parsed = JSON.parse(raw);
expect(parsed.themeMode).toBe("system");
});
it("merges multiple updates without losing fields", async () => {
await store.init();
await store.updateSettings({ defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5" });
await store.updateSettings({ ntfyEnabled: true, ntfyTopic: "my-topic" });
const settings = await store.getSettings();
expect(settings.defaultProvider).toBe("anthropic");
expect(settings.defaultModelId).toBe("claude-sonnet-4-5");
expect(settings.ntfyEnabled).toBe(true);
expect(settings.ntfyTopic).toBe("my-topic");
expect(settings.themeMode).toBe("dark"); // preserved default
});
it("creates directory if missing", async () => {
const nested = join(dir, "auto", "create");
const nestedStore = new GlobalSettingsStore(nested);
await nestedStore.updateSettings({ themeMode: "light" });
expect(existsSync(join(nested, "settings.json"))).toBe(true);
const settings = await nestedStore.getSettings();
expect(settings.themeMode).toBe("light");
});
it("can clear a field by setting it to undefined", async () => {
await store.init();
await store.updateSettings({ defaultProvider: "anthropic" });
await store.updateSettings({ defaultProvider: undefined });
const settings = await store.getSettings();
expect(settings.defaultProvider).toBeUndefined();
});
it("handles concurrent updates safely via locking", async () => {
await store.init();
// Fire 10 concurrent updates
const promises = Array.from({ length: 10 }, (_, i) =>
store.updateSettings({ ntfyTopic: `topic-${i}` }),
);
await Promise.all(promises);
// The final value should be one of the submitted values (last writer wins)
const settings = await store.getSettings();
expect(settings.ntfyTopic).toMatch(/^topic-\d$/);
});
});
describe("getSettingsPath()", () => {
it("returns the path to settings.json", () => {
const path = store.getSettingsPath();
expect(path).toBe(join(dir, "settings.json"));
});
});
describe("atomic writes", () => {
it("does not leave tmp files after a successful write", async () => {
await store.init();
await store.updateSettings({ themeMode: "light" });
const tmpPath = join(dir, "settings.json.tmp");
expect(existsSync(tmpPath)).toBe(false);
});
});
});

View File

@@ -0,0 +1,122 @@
/**
* Global settings store — manages user-level settings in `~/.pi/kb/settings.json`.
*
* Global settings persist across all kb projects for the current user.
* They include UI theme preferences, default AI model selection, and
* notification configuration.
*
* @see {@link GlobalSettings} for the full list of global fields.
*/
import { homedir } from "node:os";
import { join } from "node:path";
import { mkdir, readFile, writeFile, rename } from "node:fs/promises";
import { existsSync } from "node:fs";
import type { GlobalSettings } from "./types.js";
import { DEFAULT_GLOBAL_SETTINGS } from "./types.js";
/** Default directory for global kb settings: `~/.pi/kb/` */
function defaultGlobalDir(): string {
return join(homedir(), ".pi", "kb");
}
export class GlobalSettingsStore {
private readonly settingsPath: string;
private readonly dir: string;
/** Promise chain for serializing read-modify-write cycles */
private lock: Promise<void> = Promise.resolve();
/**
* Create a GlobalSettingsStore.
* @param dir — Directory to store settings.json. Defaults to `~/.pi/kb/`.
* Accepts a custom path for testing.
*/
constructor(dir?: string) {
this.dir = dir ?? defaultGlobalDir();
this.settingsPath = join(this.dir, "settings.json");
}
/**
* Ensure the settings directory exists. Creates it recursively if needed.
* If the settings file doesn't exist, creates it with defaults.
* Returns true if the file was created for the first time.
*/
async init(): Promise<boolean> {
await mkdir(this.dir, { recursive: true });
if (!existsSync(this.settingsPath)) {
await this.atomicWrite(DEFAULT_GLOBAL_SETTINGS);
return true;
}
return false;
}
/**
* Read global settings from disk. Returns defaults merged with persisted values.
* If the file doesn't exist or is invalid, returns defaults without throwing.
*/
async getSettings(): Promise<GlobalSettings> {
try {
const raw = await readFile(this.settingsPath, "utf-8");
const parsed = JSON.parse(raw) as Partial<GlobalSettings>;
return { ...DEFAULT_GLOBAL_SETTINGS, ...parsed };
} catch {
// File missing, unreadable, or invalid JSON → return defaults
return { ...DEFAULT_GLOBAL_SETTINGS };
}
}
/**
* Update global settings by merging a partial patch into the existing values.
* Only fields present in the patch are overwritten; other fields are preserved.
* Uses atomic write (write-to-temp-then-rename) and serialized locking.
*
* @returns The full updated settings after merge.
*/
async updateSettings(patch: Partial<GlobalSettings>): Promise<GlobalSettings> {
return this.withLock(async () => {
const current = await this.getSettings();
const updated = { ...current, ...patch };
await mkdir(this.dir, { recursive: true });
await this.atomicWrite(updated);
return updated;
});
}
/**
* Get the path to the settings file (useful for diagnostics/logging).
*/
getSettingsPath(): string {
return this.settingsPath;
}
// ── Private helpers ─────────────────────────────────────────────
/**
* Atomically write settings to disk. Writes to a temp file first,
* then renames into place (atomic on POSIX).
*/
private async atomicWrite(settings: GlobalSettings): Promise<void> {
const tmpPath = this.settingsPath + ".tmp";
await writeFile(tmpPath, JSON.stringify(settings, null, 2));
await rename(tmpPath, this.settingsPath);
}
/**
* Serialize operations via promise chain to prevent lost-update races.
*/
private withLock<T>(fn: () => Promise<T>): Promise<T> {
let resolve: () => void;
const next = new Promise<void>((r) => { resolve = r; });
const prev = this.lock;
this.lock = next;
return prev.then(async () => {
try {
return await fn();
} finally {
resolve!();
}
});
}
}

View File

@@ -1,6 +1,7 @@
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, THINKING_LEVELS, THEME_MODES, COLOR_THEMES } from "./types.js"; export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, THINKING_LEVELS, THEME_MODES, COLOR_THEMES } from "./types.js";
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeResult, Settings, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, SteeringComment, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset } from "./types.js"; export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, SteeringComment, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset } from "./types.js";
export { TaskStore } from "./store.js"; export { TaskStore } from "./store.js";
export { GlobalSettingsStore } from "./global-settings.js";
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js"; export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";
export { export {
isGhAvailable, isGhAvailable,

View File

@@ -12,17 +12,20 @@ function makeTmpDir(): string {
describe("TaskStore", () => { describe("TaskStore", () => {
let rootDir: string; let rootDir: string;
let globalDir: string;
let store: TaskStore; let store: TaskStore;
beforeEach(async () => { beforeEach(async () => {
rootDir = makeTmpDir(); rootDir = makeTmpDir();
store = new TaskStore(rootDir); globalDir = makeTmpDir();
store = new TaskStore(rootDir, globalDir);
await store.init(); await store.init();
}); });
afterEach(async () => { afterEach(async () => {
store.stopWatching(); store.stopWatching();
await rm(rootDir, { recursive: true, force: true }); await rm(rootDir, { recursive: true, force: true });
await rm(globalDir, { recursive: true, force: true });
}); });
async function createTestTask(): Promise<Task> { async function createTestTask(): Promise<Task> {
@@ -356,8 +359,8 @@ describe("TaskStore", () => {
// ── Settings tests ──────────────────────────────────────────────── // ── Settings tests ────────────────────────────────────────────────
describe("model settings", () => { describe("model settings", () => {
it("persists defaultProvider and defaultModelId and returns them via getSettings", async () => { it("persists defaultProvider and defaultModelId via updateGlobalSettings", async () => {
await store.updateSettings({ defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5" }); await store.updateGlobalSettings({ defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5" });
const settings = await store.getSettings(); const settings = await store.getSettings();
expect(settings.defaultProvider).toBe("anthropic"); expect(settings.defaultProvider).toBe("anthropic");
expect(settings.defaultModelId).toBe("claude-sonnet-4-5"); expect(settings.defaultModelId).toBe("claude-sonnet-4-5");
@@ -409,6 +412,111 @@ describe("TaskStore", () => {
}); });
}); });
// ── Global/Project Settings Merging ─────────────────────────────
describe("global/project settings merging", () => {
it("getSettings returns global defaults when no overrides exist", async () => {
const settings = await store.getSettings();
expect(settings.themeMode).toBe("dark");
expect(settings.colorTheme).toBe("default");
expect(settings.maxConcurrent).toBe(2);
});
it("global settings are visible through getSettings", async () => {
await store.updateGlobalSettings({ themeMode: "light", colorTheme: "ocean" });
const settings = await store.getSettings();
expect(settings.themeMode).toBe("light");
expect(settings.colorTheme).toBe("ocean");
});
it("project settings override global defaults", async () => {
await store.updateSettings({ maxConcurrent: 8 });
const settings = await store.getSettings();
expect(settings.maxConcurrent).toBe(8);
});
it("updateSettings silently filters out global-only fields", async () => {
// themeMode is a global field — should not be persisted to project config
await store.updateSettings({ maxConcurrent: 5, themeMode: "light" } as any);
const settings = await store.getSettings();
expect(settings.maxConcurrent).toBe(5);
// themeMode should still be the global default, not "light"
expect(settings.themeMode).toBe("dark");
// Verify the project config doesn't contain themeMode
const configRaw = await readFile(join(rootDir, ".kb", "config.json"), "utf-8");
const config = JSON.parse(configRaw);
expect(config.settings.themeMode).toBeUndefined();
});
it("updateGlobalSettings persists global fields", async () => {
await store.updateGlobalSettings({ defaultProvider: "openai", defaultModelId: "gpt-4o" });
const settings = await store.getSettings();
expect(settings.defaultProvider).toBe("openai");
expect(settings.defaultModelId).toBe("gpt-4o");
});
it("updateGlobalSettings emits settings:updated event", async () => {
const events: Array<{ settings: any; previous: any }> = [];
store.on("settings:updated", (data) => events.push(data));
await store.updateGlobalSettings({ ntfyEnabled: true, ntfyTopic: "test" });
expect(events).toHaveLength(1);
expect(events[0].settings.ntfyEnabled).toBe(true);
expect(events[0].settings.ntfyTopic).toBe("test");
});
it("getSettingsByScope returns separated global and project settings", async () => {
await store.updateGlobalSettings({ themeMode: "system", defaultProvider: "anthropic" });
await store.updateSettings({ maxConcurrent: 4, autoMerge: false });
const { global, project } = await store.getSettingsByScope();
expect(global.themeMode).toBe("system");
expect(global.defaultProvider).toBe("anthropic");
expect(project.maxConcurrent).toBe(4);
expect(project.autoMerge).toBe(false);
});
it("getSettingsByScope does not include global keys in project settings", async () => {
// Write global-key directly into config.json for backward compat testing
const configRaw = await readFile(join(rootDir, ".kb", "config.json"), "utf-8");
const config = JSON.parse(configRaw);
config.settings = { maxConcurrent: 3, themeMode: "light" };
await writeFile(join(rootDir, ".kb", "config.json"), JSON.stringify(config));
const { project } = await store.getSettingsByScope();
expect(project.maxConcurrent).toBe(3);
// themeMode is a global key — should not appear in project scope
expect((project as any).themeMode).toBeUndefined();
});
it("backward compat: existing projects with global fields in config.json still work", async () => {
// Simulate an old config that has both global and project fields
const configRaw = await readFile(join(rootDir, ".kb", "config.json"), "utf-8");
const config = JSON.parse(configRaw);
config.settings = { maxConcurrent: 6, themeMode: "system", ntfyEnabled: true };
await writeFile(join(rootDir, ".kb", "config.json"), JSON.stringify(config));
// getSettings should still see these values (project overrides global)
const settings = await store.getSettings();
expect(settings.maxConcurrent).toBe(6);
// These are global fields stored in old config — they show up via config.settings spread
expect(settings.themeMode).toBe("system");
expect(settings.ntfyEnabled).toBe(true);
});
it("getGlobalSettingsStore returns the store instance", () => {
const globalStore = store.getGlobalSettingsStore();
expect(globalStore).toBeDefined();
expect(globalStore.getSettingsPath()).toContain("settings.json");
});
});
// ── Concurrent stress test ─────────────────────────────────────── // ── Concurrent stress test ───────────────────────────────────────
describe("concurrent stress", () => { describe("concurrent stress", () => {
@@ -2773,7 +2881,7 @@ describe("TaskStore", () => {
await store.cleanupArchivedTasks(); await store.cleanupArchivedTasks();
// Create new store instance // Create new store instance
const newStore = new TaskStore(rootDir); const newStore = new TaskStore(rootDir, globalDir);
await newStore.init(); await newStore.init();
const entries = await newStore.readArchiveLog(); const entries = await newStore.readArchiveLog();
@@ -2866,7 +2974,7 @@ describe("TaskStore", () => {
await store.recordActivity({ type: "task:created", taskId: "KB-001", details: "Test" }); await store.recordActivity({ type: "task:created", taskId: "KB-001", details: "Test" });
// Create new store instance // Create new store instance
const newStore = new TaskStore(rootDir); const newStore = new TaskStore(rootDir, globalDir);
await newStore.init(); await newStore.init();
const logs = await newStore.getActivityLog(); const logs = await newStore.getActivityLog();
@@ -2915,7 +3023,8 @@ describe("TaskStore", () => {
}); });
it("records activity on settings:updated for important changes", async () => { it("records activity on settings:updated for important changes", async () => {
await store.updateSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" }); // ntfyEnabled/ntfyTopic are now global settings, use updateGlobalSettings
await store.updateGlobalSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
// Wait for async activity recording // Wait for async activity recording
await new Promise((r) => setTimeout(r, 10)); await new Promise((r) => setTimeout(r, 10));
const logs = await store.getActivityLog({ type: "settings:updated" }); const logs = await store.getActivityLog({ type: "settings:updated" });

View File

@@ -3,8 +3,9 @@ import { execSync } from "node:child_process";
import { appendFile, mkdir, readFile, writeFile, readdir, rename, unlink } from "node:fs/promises"; import { appendFile, mkdir, readFile, writeFile, readdir, rename, unlink } from "node:fs/promises";
import { join, sep } from "node:path"; import { join, sep } from "node:path";
import { existsSync, watch, type FSWatcher, readFileSync } from "node:fs"; import { existsSync, watch, type FSWatcher, readFileSync } from "node:fs";
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings, ActivityLogEntry, ActivityEventType } from "./types.js"; import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType } from "./types.js";
import { VALID_TRANSITIONS, DEFAULT_SETTINGS } from "./types.js"; import { VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS } from "./types.js";
import { GlobalSettingsStore } from "./global-settings.js";
export interface TaskStoreEvents { export interface TaskStoreEvents {
"task:created": [task: Task]; "task:created": [task: Task];
@@ -37,8 +38,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
private taskLocks: Map<string, Promise<void>> = new Map(); private taskLocks: Map<string, Promise<void>> = new Map();
/** Promise chain for serializing config.json read-modify-write cycles */ /** Promise chain for serializing config.json read-modify-write cycles */
private configLock: Promise<void> = Promise.resolve(); private configLock: Promise<void> = Promise.resolve();
/** Global settings store (`~/.pi/kb/settings.json`) */
private globalSettingsStore: GlobalSettingsStore;
constructor(private rootDir: string) { constructor(private rootDir: string, globalSettingsDir?: string) {
super(); super();
this.setMaxListeners(100); this.setMaxListeners(100);
this.kbDir = join(rootDir, ".kb"); this.kbDir = join(rootDir, ".kb");
@@ -46,6 +49,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
this.configPath = join(this.kbDir, "config.json"); this.configPath = join(this.kbDir, "config.json");
this.archiveLogPath = join(this.kbDir, "archive.jsonl"); this.archiveLogPath = join(this.kbDir, "archive.jsonl");
this.activityLogPath = join(this.kbDir, "activity-log.jsonl"); this.activityLogPath = join(this.kbDir, "activity-log.jsonl");
this.globalSettingsStore = new GlobalSettingsStore(globalSettingsDir);
} }
async init(): Promise<void> { async init(): Promise<void> {
@@ -232,23 +236,107 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
await rename(tmpPath, taskJsonPath); await rename(tmpPath, taskJsonPath);
} }
/**
* Get merged settings: global defaults ← global user prefs ← project overrides.
*
* Returns the combined view that most consumers should use. Project-level
* values in `.kb/config.json` override global values from `~/.pi/kb/settings.json`.
*/
async getSettings(): Promise<Settings> { async getSettings(): Promise<Settings> {
const config = await this.readConfig(); const [globalSettings, config] = await Promise.all([
return { ...DEFAULT_SETTINGS, ...config.settings }; this.globalSettingsStore.getSettings(),
this.readConfig(),
]);
return {
...DEFAULT_SETTINGS,
...globalSettings,
...config.settings,
};
} }
/**
* Get settings separated by scope. Returns both the global and
* project-level settings independently (useful for the UI to show
* which scope a value comes from).
*/
async getSettingsByScope(): Promise<{ global: GlobalSettings; project: Partial<ProjectSettings> }> {
const [globalSettings, config] = await Promise.all([
this.globalSettingsStore.getSettings(),
this.readConfig(),
]);
// Extract only project-level keys from config.settings
const projectSettings: Partial<ProjectSettings> = {};
if (config.settings) {
const globalKeySet = new Set<string>(GLOBAL_SETTINGS_KEYS);
for (const key of Object.keys(config.settings)) {
if (!globalKeySet.has(key)) {
(projectSettings as any)[key] = (config.settings as any)[key];
}
}
}
return { global: globalSettings, project: projectSettings };
}
/**
* Update project-level settings in `.kb/config.json`.
*
* Accepts `Partial<Settings>` for backward compatibility. Any global-only
* fields in the patch are silently filtered out — they will not be persisted
* to the project config. Use `updateGlobalSettings()` for global fields.
*/
async updateSettings(patch: Partial<Settings>): Promise<Settings> { async updateSettings(patch: Partial<Settings>): Promise<Settings> {
// Filter out global-only fields — they should go through updateGlobalSettings()
const projectPatch: Partial<Settings> = {};
for (const [key, value] of Object.entries(patch)) {
if (!(GLOBAL_SETTINGS_KEYS as readonly string[]).includes(key)) {
(projectPatch as Record<string, unknown>)[key] = value;
}
}
return this.withConfigLock(async () => { return this.withConfigLock(async () => {
const config = await this.readConfig(); const config = await this.readConfig();
const previous = { ...DEFAULT_SETTINGS, ...config.settings }; const globalSettings = await this.globalSettingsStore.getSettings();
const updated = { ...previous, ...patch }; const previousMerged: Settings = { ...DEFAULT_SETTINGS, ...globalSettings, ...config.settings } as Settings;
config.settings = updated; const updatedProjectSettings = { ...config.settings, ...projectPatch };
config.settings = updatedProjectSettings as Settings;
await this.writeConfig(config); await this.writeConfig(config);
this.emit("settings:updated", { settings: updated, previous }); const updatedMerged: Settings = { ...DEFAULT_SETTINGS, ...globalSettings, ...updatedProjectSettings } as Settings;
return updated; this.emit("settings:updated", { settings: updatedMerged, previous: previousMerged });
return updatedMerged;
}); });
} }
/**
* Update global (user-level) settings in `~/.pi/kb/settings.json`.
*
* These settings persist across all kb projects for the current user.
* Only fields defined in `GlobalSettings` are accepted.
*/
async updateGlobalSettings(patch: Partial<GlobalSettings>): Promise<Settings> {
// Read previous state BEFORE writing so the diff is correct
const [previousGlobal, config] = await Promise.all([
this.globalSettingsStore.getSettings(),
this.readConfig(),
]);
const previous: Settings = { ...DEFAULT_SETTINGS, ...previousGlobal, ...config.settings } as Settings;
const updatedGlobal = await this.globalSettingsStore.updateSettings(patch);
const merged: Settings = { ...DEFAULT_SETTINGS, ...updatedGlobal, ...config.settings } as Settings;
// Emit settings:updated so SSE listeners pick up the change
this.emit("settings:updated", { settings: merged, previous });
return merged;
}
/**
* Get the GlobalSettingsStore instance (used by API routes).
*/
getGlobalSettingsStore(): GlobalSettingsStore {
return this.globalSettingsStore;
}
private async readConfig(): Promise<BoardConfig> { private async readConfig(): Promise<BoardConfig> {
const data = await readFile(this.configPath, "utf-8"); const data = await readFile(this.configPath, "utf-8");
return JSON.parse(data); return JSON.parse(data);
@@ -1917,7 +2005,11 @@ ${notificationsSection}`;
/** /**
* Synchronous version of getSettings for internal use. * Synchronous version of getSettings for internal use.
* Returns cached settings or default settings if not loaded. * Returns project-level settings merged with defaults.
* Note: This does NOT merge global settings because it's synchronous
* and global settings require async I/O. For prompt generation this
* is fine since the fields used (ntfyEnabled, ntfyTopic) will be
* present in project config for backward compatibility.
*/ */
private getSettingsSync(): Settings { private getSettingsSync(): Settings {
// Since we can't easily make generateSpecifiedPrompt async, // Since we can't easily make generateSpecifiedPrompt async,

View File

@@ -236,7 +236,69 @@ export interface TaskCreateInput {
validatorModelId?: string; validatorModelId?: string;
} }
export interface Settings { // ── Settings Scope Types ────────────────────────────────────────────────
//
// Settings are split into two scopes:
//
// 1. **GlobalSettings** — User preferences stored in `~/.pi/kb/settings.json`.
// These persist across all kb projects for the current user (theme, default
// AI models, notification preferences).
//
// 2. **ProjectSettings** — Project-specific workflow and resource settings stored
// in `.kb/config.json`. These control how the engine operates for this
// particular project (concurrency, merge strategy, worktree management, etc.).
//
// The merged view (`Settings`) combines both scopes: project values override
// global values. This is the type returned by `TaskStore.getSettings()` and
// used by most consumers.
//
// Computed/server-only fields (like `githubTokenConfigured`) live only on
// `Settings` and are injected at read time by the API layer.
/** Settings scope discriminator for UI and validation. */
export type SettingsScope = "global" | "project";
/**
* Global (user-level) settings stored in `~/.pi/kb/settings.json`.
*
* These are user preferences that persist across all kb projects.
* The dashboard UI shows these under a "Global" section.
*/
export interface GlobalSettings {
/** Theme mode preference: dark, light, or system (follows OS). Default: "dark". */
themeMode?: ThemeMode;
/** Color theme preference for accent colors and styling. Default: "default". */
colorTheme?: ColorTheme;
/** Default AI model provider name (e.g. `"anthropic"`, `"openai"`).
* Must be set together with `defaultModelId`. When both are undefined,
* the engine uses pi's automatic model resolution. */
defaultProvider?: string;
/** Default AI model ID within the provider (e.g. `"claude-sonnet-4-5"`).
* Must be set together with `defaultProvider`. When both are undefined,
* the engine uses pi's automatic model resolution. */
defaultModelId?: string;
/** Default thinking effort level for AI agent sessions.
* Controls how much reasoning effort the model uses — higher levels
* produce better results but cost more. When undefined, the engine
* uses the model's default thinking level. */
defaultThinkingLevel?: ThinkingLevel;
/** When true, enables ntfy.sh push notifications for task completion and failures.
* Requires ntfyTopic to be set. Default: false. */
ntfyEnabled?: boolean;
/** ntfy.sh topic name for push notifications. When set along with ntfyEnabled,
* notifications are sent to https://ntfy.sh/{topic} when tasks complete or fail. */
ntfyTopic?: string;
}
/**
* Project-level settings stored in `.kb/config.json`.
*
* These control how the engine operates for this particular project:
* concurrency, merge strategy, worktree management, build/test commands, etc.
* Runtime state fields (globalPause, enginePaused) also live here because
* different projects may need independent pause control.
*/
export interface ProjectSettings {
/** Hard stop: when true, all automated agent activity is **immediately** /** Hard stop: when true, all automated agent activity is **immediately**
* terminated — active triage, execution, and merge agent sessions are * terminated — active triage, execution, and merge agent sessions are
* killed, and the scheduler stops dispatching new work. Acts as a * killed, and the scheduler stops dispatching new work. Acts as a
@@ -288,21 +350,10 @@ export interface Settings {
* Defaults to `"KB"`. Only affects new tasks — existing tasks retain * Defaults to `"KB"`. Only affects new tasks — existing tasks retain
* their original IDs. */ * their original IDs. */
taskPrefix?: string; taskPrefix?: string;
/** Whether GitHub token is configured for PR operations (read-only, set by server).
* When false, PR creation features are disabled in the UI. */
githubTokenConfigured?: boolean;
/** When true, merge commit messages include the task ID as the conventional /** When true, merge commit messages include the task ID as the conventional
* commit scope (e.g. `feat(KB-001): ...`). When false, the scope is * commit scope (e.g. `feat(KB-001): ...`). When false, the scope is
* omitted (e.g. `feat: ...`). Default: true. */ * omitted (e.g. `feat: ...`). Default: true. */
includeTaskIdInCommit?: boolean; includeTaskIdInCommit?: boolean;
/** Default AI model provider name (e.g. `"anthropic"`, `"openai"`).
* Must be set together with `defaultModelId`. When both are undefined,
* the engine uses pi's automatic model resolution. */
defaultProvider?: string;
/** Default AI model ID within the provider (e.g. `"claude-sonnet-4-5"`).
* Must be set together with `defaultProvider`. When both are undefined,
* the engine uses pi's automatic model resolution. */
defaultModelId?: string;
/** AI model provider for planning/triage (specification) agent. /** AI model provider for planning/triage (specification) agent.
* Must be set together with `planningModelId`. When both are undefined, * Must be set together with `planningModelId`. When both are undefined,
* falls back to `defaultProvider`/`defaultModelId`. */ * falls back to `defaultProvider`/`defaultModelId`. */
@@ -325,11 +376,6 @@ export interface Settings {
autoSelectModelPreset?: boolean; autoSelectModelPreset?: boolean;
/** Mapping of task sizes to preset IDs used for auto-selection during task creation. */ /** Mapping of task sizes to preset IDs used for auto-selection during task creation. */
defaultPresetBySize?: { S?: string; M?: string; L?: string }; defaultPresetBySize?: { S?: string; M?: string; L?: string };
/** Default thinking effort level for AI agent sessions.
* Controls how much reasoning effort the model uses — higher levels
* produce better results but cost more. When undefined, the engine
* uses the model's default thinking level. */
defaultThinkingLevel?: ThinkingLevel;
/** When true, auto-merge will automatically resolve common conflict patterns /** When true, auto-merge will automatically resolve common conflict patterns
* (lock files, generated files, trivial conflicts) without requiring AI * (lock files, generated files, trivial conflicts) without requiring AI
* intervention. When AI resolution fails, the system will retry with escalating * intervention. When AI resolution fails, the system will retry with escalating
@@ -344,24 +390,41 @@ export interface Settings {
* remain in triage with status "awaiting-approval" until a user approves * remain in triage with status "awaiting-approval" until a user approves
* or rejects the plan. Default: false. */ * or rejects the plan. Default: false. */
requirePlanApproval?: boolean; requirePlanApproval?: boolean;
/** ntfy.sh topic name for push notifications. When set along with ntfyEnabled,
* notifications are sent to https://ntfy.sh/{topic} when tasks complete or fail. */
ntfyTopic?: string;
/** When true, enables ntfy.sh push notifications for task completion and failures.
* Requires ntfyTopic to be set. Default: false. */
ntfyEnabled?: boolean;
/** Timeout in milliseconds for detecting stuck tasks. When a task's agent session /** Timeout in milliseconds for detecting stuck tasks. When a task's agent session
* shows no activity (no text deltas, tool calls, or progress updates) for longer * shows no activity (no text deltas, tool calls, or progress updates) for longer
* than this duration, the task is considered stuck and will be terminated and retried. * than this duration, the task is considered stuck and will be terminated and retried.
* Default: undefined (disabled). Suggested value: 600000 (10 minutes). */ * Default: undefined (disabled). Suggested value: 600000 (10 minutes). */
taskStuckTimeoutMs?: number; taskStuckTimeoutMs?: number;
/** Theme mode preference: dark, light, or system (follows OS). Default: "dark". */
themeMode?: ThemeMode;
/** Color theme preference for accent colors and styling. Default: "default". */
colorTheme?: ColorTheme;
} }
export const DEFAULT_SETTINGS: Settings = { /**
* Merged settings view combining global and project scopes.
*
* This is the primary type returned by `TaskStore.getSettings()` and used
* by most consumers. Project settings override global settings.
*
* Also includes computed/server-only fields like `githubTokenConfigured`
* that are injected at read time by the API layer.
*/
export interface Settings extends GlobalSettings, ProjectSettings {
/** Whether GitHub token is configured for PR operations (read-only, set by server).
* When false, PR creation features are disabled in the UI. */
githubTokenConfigured?: boolean;
}
/** Default values for global (user-level) settings. */
export const DEFAULT_GLOBAL_SETTINGS: Required<Pick<GlobalSettings, "themeMode" | "colorTheme">> & GlobalSettings = {
themeMode: "dark",
colorTheme: "default",
defaultProvider: undefined,
defaultModelId: undefined,
defaultThinkingLevel: undefined,
ntfyEnabled: false,
ntfyTopic: undefined,
};
/** Default values for project-level settings. */
export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = {
globalPause: false, globalPause: false,
enginePaused: false, enginePaused: false,
maxConcurrent: 2, maxConcurrent: 2,
@@ -375,8 +438,6 @@ export const DEFAULT_SETTINGS: Settings = {
worktreeNaming: "random", worktreeNaming: "random",
taskPrefix: undefined, taskPrefix: undefined,
includeTaskIdInCommit: true, includeTaskIdInCommit: true,
defaultProvider: undefined,
defaultModelId: undefined,
planningProvider: undefined, planningProvider: undefined,
planningModelId: undefined, planningModelId: undefined,
validatorProvider: undefined, validatorProvider: undefined,
@@ -384,17 +445,63 @@ export const DEFAULT_SETTINGS: Settings = {
modelPresets: [], modelPresets: [],
autoSelectModelPreset: false, autoSelectModelPreset: false,
defaultPresetBySize: {}, defaultPresetBySize: {},
defaultThinkingLevel: undefined,
autoResolveConflicts: true, autoResolveConflicts: true,
smartConflictResolution: true, smartConflictResolution: true,
requirePlanApproval: false, requirePlanApproval: false,
ntfyEnabled: false,
ntfyTopic: undefined,
taskStuckTimeoutMs: undefined, taskStuckTimeoutMs: undefined,
themeMode: "dark",
colorTheme: "default",
}; };
/**
* Merged default settings (backward compatible).
* This combines global and project defaults into a single object
* that matches the legacy `DEFAULT_SETTINGS` shape.
*/
export const DEFAULT_SETTINGS: Settings = {
...DEFAULT_GLOBAL_SETTINGS,
...DEFAULT_PROJECT_SETTINGS,
};
/** Keys that belong to the global settings scope. */
export const GLOBAL_SETTINGS_KEYS: ReadonlyArray<keyof GlobalSettings> = [
"themeMode",
"colorTheme",
"defaultProvider",
"defaultModelId",
"defaultThinkingLevel",
"ntfyEnabled",
"ntfyTopic",
] as const;
/** Keys that belong to the project settings scope. */
export const PROJECT_SETTINGS_KEYS: ReadonlyArray<keyof ProjectSettings> = [
"globalPause",
"enginePaused",
"maxConcurrent",
"maxWorktrees",
"pollIntervalMs",
"groupOverlappingFiles",
"autoMerge",
"mergeStrategy",
"worktreeInitCommand",
"testCommand",
"buildCommand",
"recycleWorktrees",
"worktreeNaming",
"taskPrefix",
"includeTaskIdInCommit",
"planningProvider",
"planningModelId",
"validatorProvider",
"validatorModelId",
"modelPresets",
"autoSelectModelPreset",
"defaultPresetBySize",
"autoResolveConflicts",
"smartConflictResolution",
"requirePlanApproval",
"taskStuckTimeoutMs",
] as const;
export interface BoardConfig { export interface BoardConfig {
nextId: number; nextId: number;
settings?: Settings; settings?: Settings;

View File

@@ -394,8 +394,11 @@ When `KB_BADGE_PUBSUB_REDIS_URL` is not set, the dashboard uses an in-memory ada
### Configuration ### Configuration
- `GET /api/config` - Server configuration - `GET /api/config` - Server configuration
- `GET /api/settings` - User settings - `GET /api/settings` - Merged settings (project overrides global)
- `PUT /api/settings` - Update settings - `PUT /api/settings` - Update project-level settings (rejects global-only fields)
- `GET /api/settings/global` - Global user settings (~/.pi/kb/settings.json)
- `PUT /api/settings/global` - Update global user settings
- `GET /api/settings/scopes` - Settings separated by scope: { global, project }
- `GET /api/models` - Available AI models - `GET /api/models` - Available AI models
- `GET /api/auth/status` - OAuth provider status - `GET /api/auth/status` - OAuth provider status
- `POST /api/auth/login` - Initiate OAuth login - `POST /api/auth/login` - Initiate OAuth login

View File

@@ -7,6 +7,8 @@ import type {
Column, Column,
MergeResult, MergeResult,
Settings, Settings,
GlobalSettings,
ProjectSettings,
BatchStatusResult, BatchStatusResult,
BatchStatusResponse, BatchStatusResponse,
BatchStatusEntry, BatchStatusEntry,
@@ -198,6 +200,24 @@ export function updateSettings(settings: Partial<Settings>): Promise<Settings> {
}); });
} }
/** Fetch global (user-level) settings from ~/.pi/kb/settings.json */
export function fetchGlobalSettings(): Promise<GlobalSettings> {
return api<GlobalSettings>("/settings/global");
}
/** Update global (user-level) settings. These persist across all kb projects. */
export function updateGlobalSettings(settings: Partial<GlobalSettings>): Promise<Settings> {
return api<Settings>("/settings/global", {
method: "PUT",
body: JSON.stringify(settings),
});
}
/** Fetch settings separated by scope: { global, project } */
export function fetchSettingsByScope(): Promise<{ global: GlobalSettings; project: Partial<ProjectSettings> }> {
return api<{ global: GlobalSettings; project: Partial<ProjectSettings> }>("/settings/scopes");
}
export function testNtfyNotification(): Promise<{ success: boolean }> { export function testNtfyNotification(): Promise<{ success: boolean }> {
return api<{ success: boolean }>("/settings/test-ntfy", { return api<{ success: boolean }>("/settings/test-ntfy", {
method: "POST", method: "POST",

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { THINKING_LEVELS } from "@kb/core"; import { THINKING_LEVELS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS } from "@kb/core";
import type { Settings, ThemeMode, ColorTheme, ModelPreset } from "@kb/core"; import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset } from "@kb/core";
import { fetchSettings, updateSettings, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, testNtfyNotification } from "../api"; import { fetchSettings, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, testNtfyNotification } from "../api";
import type { AuthProvider, ModelInfo } from "../api"; import type { AuthProvider, ModelInfo } from "../api";
import type { ToastType } from "../hooks/useToast"; import type { ToastType } from "../hooks/useToast";
import { ThemeSelector } from "./ThemeSelector"; import { ThemeSelector } from "./ThemeSelector";
@@ -12,32 +12,38 @@ import { applyPresetToSelection, generatePresetId, validatePresetId } from "../u
* Settings sections configuration. * Settings sections configuration.
* *
* Each section groups related settings fields under a sidebar nav item. * Each section groups related settings fields under a sidebar nav item.
* Sections have a `scope` to indicate where their settings are stored:
* - "global": User-level settings stored in ~/.pi/kb/settings.json (shared across projects)
* - "project": Project-specific settings stored in .kb/config.json
* - undefined: Section operates independently of settings storage (e.g. authentication)
*
* To add a new section: * To add a new section:
* 1. Add an entry to SETTINGS_SECTIONS with a unique id and label * 1. Add an entry to SETTINGS_SECTIONS with a unique id, label, and scope
* 2. Add a corresponding case in renderSectionFields() * 2. Add a corresponding case in renderSectionFields()
* *
* Sections: * Sections:
* - general: Task prefix configuration * - general: Task prefix configuration (project)
* - model: Default AI model selection * - model: Default AI model selection (global)
* - appearance: Theme and color settings * - model-presets: Reusable model presets (project)
* - scheduling: Concurrency, poll interval, file overlap serialization * - appearance: Theme and color settings (global)
* - worktrees: Worktree limits, init commands, recycling * - scheduling: Concurrency, poll interval, file overlap serialization (project)
* - commands: Test and build command configuration * - worktrees: Worktree limits, init commands, recycling (project)
* - merge: Auto-merge settings * - commands: Test and build command configuration (project)
* - notifications: ntfy.sh notification settings * - merge: Auto-merge settings (project)
* - authentication: OAuth provider status, login/logout (operates independently of Save) * - notifications: ntfy.sh notification settings (global)
* - authentication: OAuth provider status, login/logout (independent)
*/ */
const SETTINGS_SECTIONS = [ const SETTINGS_SECTIONS = [
{ id: "general", label: "General" }, { id: "general", label: "General", scope: "project" as const },
{ id: "model", label: "Model" }, { id: "model", label: "Model", scope: "global" as const },
{ id: "model-presets", label: "Model Presets" }, { id: "model-presets", label: "Model Presets", scope: "project" as const },
{ id: "appearance", label: "Appearance" }, { id: "appearance", label: "Appearance", scope: "global" as const },
{ id: "scheduling", label: "Scheduling" }, { id: "scheduling", label: "Scheduling", scope: "project" as const },
{ id: "worktrees", label: "Worktrees" }, { id: "worktrees", label: "Worktrees", scope: "project" as const },
{ id: "commands", label: "Commands" }, { id: "commands", label: "Commands", scope: "project" as const },
{ id: "merge", label: "Merge" }, { id: "merge", label: "Merge", scope: "project" as const },
{ id: "notifications", label: "Notifications" }, { id: "notifications", label: "Notifications", scope: "global" as const },
{ id: "authentication", label: "Authentication" }, { id: "authentication", label: "Authentication", scope: undefined },
] as const; ] as const;
export type SectionId = (typeof SETTINGS_SECTIONS)[number]["id"]; export type SectionId = (typeof SETTINGS_SECTIONS)[number]["id"];
@@ -212,6 +218,9 @@ export function SettingsModal({
[onClose], [onClose],
); );
/** Get the scope of the currently active section */
const activeSectionScope = SETTINGS_SECTIONS.find((s) => s.id === activeSection)?.scope;
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
if (prefixError || presetDraft) return; if (prefixError || presetDraft) return;
try { try {
@@ -220,13 +229,38 @@ export function SettingsModal({
worktreeInitCommand: form.worktreeInitCommand?.trim() || undefined, worktreeInitCommand: form.worktreeInitCommand?.trim() || undefined,
taskPrefix: form.taskPrefix?.trim() || undefined, taskPrefix: form.taskPrefix?.trim() || undefined,
}; };
await updateSettings(payload);
// Save only the scope matching the currently active section.
// This prevents stale values from one scope being accidentally
// overwritten when the user only changed fields in the other scope.
if (activeSectionScope === "global") {
const globalKeySet = new Set<string>(GLOBAL_SETTINGS_KEYS);
const globalPatch: Partial<GlobalSettings> = {};
for (const [key, value] of Object.entries(payload)) {
if (globalKeySet.has(key)) {
(globalPatch as any)[key] = value;
}
}
await updateGlobalSettings(globalPatch);
} else if (activeSectionScope === "project") {
const projectKeySet = new Set<string>(PROJECT_SETTINGS_KEYS as readonly string[]);
const projectPatch: Partial<Settings> = {};
for (const [key, value] of Object.entries(payload)) {
if (key === "githubTokenConfigured") continue; // server-only field
if (projectKeySet.has(key)) {
(projectPatch as any)[key] = value;
}
}
await updateSettings(projectPatch);
}
// Authentication section (scope: undefined) doesn't use the save button
addToast("Settings saved", "success"); addToast("Settings saved", "success");
onClose(); onClose();
} catch (err: any) { } catch (err: any) {
addToast(err.message, "error"); addToast(err.message, "error");
} }
}, [form, prefixError, onClose, addToast]); }, [form, prefixError, presetDraft, activeSectionScope, onClose, addToast]);
const savePresetDraft = () => { const savePresetDraft = () => {
if (!presetDraft) return; if (!presetDraft) return;
@@ -266,11 +300,33 @@ export function SettingsModal({
setPresetIdTouched(false); setPresetIdTouched(false);
}; };
/** Render a scope indicator banner for the current section */
const renderScopeBanner = () => {
if (activeSectionScope === "global") {
return (
<div className="settings-scope-banner settings-scope-global">
<span>🌐</span>
<span>These settings are shared across all your kb projects.</span>
</div>
);
}
if (activeSectionScope === "project") {
return (
<div className="settings-scope-banner settings-scope-project">
<span>📁</span>
<span>These settings only affect this project.</span>
</div>
);
}
return null;
};
const renderSectionFields = () => { const renderSectionFields = () => {
switch (activeSection) { switch (activeSection) {
case "general": case "general":
return ( return (
<> <>
{renderScopeBanner()}
<h4 className="settings-section-heading">General</h4> <h4 className="settings-section-heading">General</h4>
<div className="form-group"> <div className="form-group">
<label htmlFor="taskPrefix">Task Prefix</label> <label htmlFor="taskPrefix">Task Prefix</label>
@@ -320,6 +376,7 @@ export function SettingsModal({
: ""; : "";
return ( return (
<> <>
{renderScopeBanner()}
<h4 className="settings-section-heading">Model</h4> <h4 className="settings-section-heading">Model</h4>
{modelsLoading ? ( {modelsLoading ? (
<div className="settings-empty-state">Loading available models</div> <div className="settings-empty-state">Loading available models</div>
@@ -437,6 +494,7 @@ export function SettingsModal({
return ( return (
<> <>
{renderScopeBanner()}
<h4 className="settings-section-heading">Model Presets</h4> <h4 className="settings-section-heading">Model Presets</h4>
<div className="form-group"> <div className="form-group">
<label>Configured presets</label> <label>Configured presets</label>
@@ -650,18 +708,26 @@ export function SettingsModal({
case "appearance": case "appearance":
return ( return (
<> <>
{renderScopeBanner()}
<h4 className="settings-section-heading">Appearance</h4> <h4 className="settings-section-heading">Appearance</h4>
<ThemeSelector <ThemeSelector
themeMode={themeMode} themeMode={themeMode}
colorTheme={colorTheme} colorTheme={colorTheme}
onThemeModeChange={onThemeModeChange || (() => {})} onThemeModeChange={(mode) => {
onColorThemeChange={onColorThemeChange || (() => {})} setForm((f) => ({ ...f, themeMode: mode }));
onThemeModeChange?.(mode);
}}
onColorThemeChange={(theme) => {
setForm((f) => ({ ...f, colorTheme: theme }));
onColorThemeChange?.(theme);
}}
/> />
</> </>
); );
case "scheduling": case "scheduling":
return ( return (
<> <>
{renderScopeBanner()}
<h4 className="settings-section-heading">Scheduling</h4> <h4 className="settings-section-heading">Scheduling</h4>
<div className="form-group"> <div className="form-group">
<label htmlFor="maxConcurrent">Max Concurrent Tasks</label> <label htmlFor="maxConcurrent">Max Concurrent Tasks</label>
@@ -724,6 +790,7 @@ export function SettingsModal({
case "worktrees": case "worktrees":
return ( return (
<> <>
{renderScopeBanner()}
<h4 className="settings-section-heading">Worktrees</h4> <h4 className="settings-section-heading">Worktrees</h4>
<div className="form-group"> <div className="form-group">
<label htmlFor="maxWorktrees">Max Worktrees</label> <label htmlFor="maxWorktrees">Max Worktrees</label>
@@ -791,6 +858,7 @@ export function SettingsModal({
case "commands": case "commands":
return ( return (
<> <>
{renderScopeBanner()}
<h4 className="settings-section-heading">Commands</h4> <h4 className="settings-section-heading">Commands</h4>
<div className="form-group"> <div className="form-group">
<label htmlFor="testCommand">Test Command</label> <label htmlFor="testCommand">Test Command</label>
@@ -823,6 +891,7 @@ export function SettingsModal({
case "merge": case "merge":
return ( return (
<> <>
{renderScopeBanner()}
<h4 className="settings-section-heading">Merge</h4> <h4 className="settings-section-heading">Merge</h4>
<div className="form-group"> <div className="form-group">
<label htmlFor="autoMerge" className="checkbox-label"> <label htmlFor="autoMerge" className="checkbox-label">
@@ -901,6 +970,7 @@ export function SettingsModal({
case "notifications": case "notifications":
return ( return (
<> <>
{renderScopeBanner()}
<h4 className="settings-section-heading">Notifications</h4> <h4 className="settings-section-heading">Notifications</h4>
<div className="form-group"> <div className="form-group">
<label htmlFor="ntfyEnabled" className="checkbox-label"> <label htmlFor="ntfyEnabled" className="checkbox-label">
@@ -1036,7 +1106,10 @@ export function SettingsModal({
key={section.id} key={section.id}
className={`settings-nav-item${activeSection === section.id ? " active" : ""}`} className={`settings-nav-item${activeSection === section.id ? " active" : ""}`}
onClick={() => setActiveSection(section.id)} onClick={() => setActiveSection(section.id)}
title={section.scope === "global" ? "Shared across all projects" : section.scope === "project" ? "Specific to this project" : undefined}
> >
{section.scope === "global" && <span className="settings-scope-icon" aria-label="Global setting">🌐</span>}
{section.scope === "project" && <span className="settings-scope-icon" aria-label="Project setting">📁</span>}
{section.label} {section.label}
</button> </button>
))} ))}

View File

@@ -28,6 +28,7 @@ const defaultSettings: Settings = {
vi.mock("../../api", () => ({ vi.mock("../../api", () => ({
fetchSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })), fetchSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
updateSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })), updateSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
updateGlobalSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
fetchAuthStatus: vi.fn(() => Promise.resolve({ providers: [{ id: "anthropic", name: "Anthropic", authenticated: false }] })), fetchAuthStatus: vi.fn(() => Promise.resolve({ providers: [{ id: "anthropic", name: "Anthropic", authenticated: false }] })),
loginProvider: vi.fn(() => Promise.resolve({ url: "https://auth.example.com/login" })), loginProvider: vi.fn(() => Promise.resolve({ url: "https://auth.example.com/login" })),
logoutProvider: vi.fn(() => Promise.resolve({ success: true })), logoutProvider: vi.fn(() => Promise.resolve({ success: true })),
@@ -38,7 +39,7 @@ vi.mock("../../api", () => ({
testNtfyNotification: vi.fn(() => Promise.resolve({ success: true })), testNtfyNotification: vi.fn(() => Promise.resolve({ success: true })),
})); }));
import { fetchSettings, updateSettings, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, testNtfyNotification } from "../../api"; import { fetchSettings, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, testNtfyNotification } from "../../api";
const onClose = vi.fn(); const onClose = vi.fn();
const addToast = vi.fn(); const addToast = vi.fn();
@@ -369,6 +370,36 @@ describe("SettingsModal", () => {
expect(payload.pollIntervalMs).toBe(15000); expect(payload.pollIntervalMs).toBe(15000);
}); });
it("saving project settings does not update global settings endpoint", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// General section is project-scoped — change a project setting
const input = screen.getByLabelText("Task Prefix") as HTMLInputElement;
fireEvent.change(input, { target: { value: "TEST" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
// Global settings should NOT be called when in a project section
expect(updateGlobalSettings).not.toHaveBeenCalled();
});
it("saving in Model section only updates global settings", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Model"));
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
fireEvent.click(screen.getByText("Save"));
// Model section is global-scoped
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
// Project settings should NOT be updated when in a global section
expect(updateSettings).not.toHaveBeenCalled();
});
it("shows Model in sidebar", async () => { it("shows Model in sidebar", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />); render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled()); await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
@@ -464,9 +495,10 @@ describe("SettingsModal", () => {
await user.click(screen.getByText("Claude Sonnet 4.5")); await user.click(screen.getByText("Claude Sonnet 4.5"));
fireEvent.click(screen.getByText("Save")); fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1)); // defaultProvider and defaultModelId are global settings, so they go through updateGlobalSettings
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0]; const payload = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(payload.defaultProvider).toBe("anthropic"); expect(payload.defaultProvider).toBe("anthropic");
expect(payload.defaultModelId).toBe("claude-sonnet-4-5"); expect(payload.defaultModelId).toBe("claude-sonnet-4-5");
}); });
@@ -499,9 +531,10 @@ describe("SettingsModal", () => {
} }
fireEvent.click(screen.getByText("Save")); fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1)); // defaultProvider and defaultModelId are global settings
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0]; const payload = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(payload.defaultProvider).toBeUndefined(); expect(payload.defaultProvider).toBeUndefined();
expect(payload.defaultModelId).toBeUndefined(); expect(payload.defaultModelId).toBeUndefined();
}); });
@@ -684,9 +717,10 @@ describe("SettingsModal", () => {
fireEvent.change(select, { target: { value: "high" } }); fireEvent.change(select, { target: { value: "high" } });
fireEvent.click(screen.getByText("Save")); fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1)); // defaultThinkingLevel is a global setting
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0]; const payload = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(payload.defaultThinkingLevel).toBe("high"); expect(payload.defaultThinkingLevel).toBe("high");
}); });
@@ -799,17 +833,29 @@ describe("SettingsModal", () => {
expect(layout!.querySelector(".settings-content")).toBeTruthy(); expect(layout!.querySelector(".settings-content")).toBeTruthy();
}); });
it("has .settings-sidebar with 8 .settings-nav-item buttons for all sections", async () => { it("has .settings-sidebar with 10 .settings-nav-item buttons for all sections", async () => {
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />); const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled()); await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
const sidebar = container.querySelector(".settings-sidebar"); const sidebar = container.querySelector(".settings-sidebar");
expect(sidebar).toBeTruthy(); expect(sidebar).toBeTruthy();
const navItems = sidebar!.querySelectorAll(".settings-nav-item"); const navItems = sidebar!.querySelectorAll(".settings-nav-item");
expect(navItems.length).toBe(9); expect(navItems.length).toBe(10);
// Labels include scope emoji indicators (🌐 for global, 📁 for project)
const labels = Array.from(navItems).map((el) => el.textContent); const labels = Array.from(navItems).map((el) => el.textContent);
expect(labels).toEqual(["General", "Model", "Appearance", "Scheduling", "Worktrees", "Commands", "Merge", "Notifications", "Authentication"]); expect(labels).toEqual([
"📁General",
"🌐Model",
"📁Model Presets",
"🌐Appearance",
"📁Scheduling",
"📁Worktrees",
"📁Commands",
"📁Merge",
"🌐Notifications",
"Authentication",
]);
}); });
it("has .settings-content as sibling of .settings-sidebar", async () => { it("has .settings-content as sibling of .settings-sidebar", async () => {
@@ -828,16 +874,16 @@ describe("SettingsModal", () => {
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />); const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled()); await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Default active section is General // Default active section is General (with scope emoji prefix)
const activeItems = container.querySelectorAll(".settings-nav-item.active"); const activeItems = container.querySelectorAll(".settings-nav-item.active");
expect(activeItems.length).toBe(1); expect(activeItems.length).toBe(1);
expect(activeItems[0].textContent).toBe("General"); expect(activeItems[0].textContent).toBe("📁General");
// Switch to Scheduling // Switch to Scheduling
fireEvent.click(screen.getByText("Scheduling")); fireEvent.click(screen.getByText("Scheduling"));
const newActive = container.querySelectorAll(".settings-nav-item.active"); const newActive = container.querySelectorAll(".settings-nav-item.active");
expect(newActive.length).toBe(1); expect(newActive.length).toBe(1);
expect(newActive[0].textContent).toBe("Scheduling"); expect(newActive[0].textContent).toBe("📁Scheduling");
}); });
it("auth provider rows contain .auth-provider-info and action button", async () => { it("auth provider rows contain .auth-provider-info and action button", async () => {
@@ -910,9 +956,10 @@ describe("SettingsModal", () => {
fireEvent.click(checkbox); fireEvent.click(checkbox);
fireEvent.click(screen.getByText("Save")); fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1)); // ntfyEnabled is a global setting, so it goes through updateGlobalSettings
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0]; const payload = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(payload.ntfyEnabled).toBe(true); expect(payload.ntfyEnabled).toBe(true);
}); });
@@ -929,9 +976,10 @@ describe("SettingsModal", () => {
expect(input.value).toBe("my-topic"); expect(input.value).toBe("my-topic");
fireEvent.click(screen.getByText("Save")); fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1)); // ntfyTopic is a global setting
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0]; const payload = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(payload.ntfyTopic).toBe("my-topic"); expect(payload.ntfyTopic).toBe("my-topic");
}); });
@@ -950,9 +998,10 @@ describe("SettingsModal", () => {
fireEvent.change(input, { target: { value: "" } }); fireEvent.change(input, { target: { value: "" } });
fireEvent.click(screen.getByText("Save")); fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1)); // ntfyTopic is a global setting
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0]; const payload = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(payload.ntfyTopic).toBeUndefined(); expect(payload.ntfyTopic).toBeUndefined();
}); });
@@ -1263,4 +1312,18 @@ describe("SettingsModal", () => {
fireEvent.click(screen.getByText("Scheduling")); fireEvent.click(screen.getByText("Scheduling"));
expect(screen.getByText(/Timeout in milliseconds for detecting stuck tasks/)).toBeTruthy(); expect(screen.getByText(/Timeout in milliseconds for detecting stuck tasks/)).toBeTruthy();
}); });
it("scope banners render for global and project sections", async () => {
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// General section is project-scoped → should show project banner
expect(container.querySelector(".settings-scope-project")).toBeTruthy();
expect(container.querySelector(".settings-scope-global")).toBeNull();
// Switch to Model → should show global banner
fireEvent.click(screen.getByText("Model"));
expect(container.querySelector(".settings-scope-global")).toBeTruthy();
expect(container.querySelector(".settings-scope-project")).toBeNull();
});
}); });

View File

@@ -1170,6 +1170,33 @@ body {
margin-bottom: 4px; margin-bottom: 4px;
} }
/* Scope indicators in sidebar nav items */
.settings-scope-icon {
margin-right: 6px;
font-size: 12px;
line-height: 1;
}
/* Scope banner above section content */
.settings-scope-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 20px;
margin: 0 0 4px 0;
font-size: 12px;
border-radius: var(--radius);
color: var(--text-muted);
}
.settings-scope-global {
background: rgba(59, 130, 246, 0.08);
border-left: 3px solid rgba(59, 130, 246, 0.4);
}
.settings-scope-project {
background: rgba(34, 197, 94, 0.08);
border-left: 3px solid rgba(34, 197, 94, 0.4);
}
/* === Auth Provider Cards === */ /* === Auth Provider Cards === */
.auth-provider-row { .auth-provider-row {
display: flex; display: flex;

View File

@@ -23,6 +23,15 @@ import { isGhAuthenticated } from "@kb/core";
const mockIsGhAuthenticated = vi.mocked(isGhAuthenticated); const mockIsGhAuthenticated = vi.mocked(isGhAuthenticated);
function createMockGlobalSettingsStore() {
return {
getSettings: vi.fn().mockResolvedValue({}),
updateSettings: vi.fn().mockResolvedValue({}),
getSettingsPath: vi.fn().mockReturnValue("/fake/home/.pi/kb/settings.json"),
init: vi.fn().mockResolvedValue(false),
};
}
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore { function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
return { return {
getTask: vi.fn(), getTask: vi.fn(),
@@ -36,6 +45,9 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
unarchiveTask: vi.fn(), unarchiveTask: vi.fn(),
getSettings: vi.fn().mockResolvedValue({}), getSettings: vi.fn().mockResolvedValue({}),
updateSettings: vi.fn(), updateSettings: vi.fn(),
updateGlobalSettings: vi.fn(),
getSettingsByScope: vi.fn().mockResolvedValue({ global: {}, project: {} }),
getGlobalSettingsStore: vi.fn().mockReturnValue(createMockGlobalSettingsStore()),
logEntry: vi.fn().mockResolvedValue(undefined), logEntry: vi.fn().mockResolvedValue(undefined),
getAgentLogs: vi.fn().mockResolvedValue([]), getAgentLogs: vi.fn().mockResolvedValue([]),
addSteeringComment: vi.fn(), addSteeringComment: vi.fn(),
@@ -4487,6 +4499,50 @@ describe("PUT /settings", () => {
expect(res.body.error).toContain("must include both provider and modelId or neither"); expect(res.body.error).toContain("must include both provider and modelId or neither");
}); });
it("rejects global-only fields with 400 error and helpful message", async () => {
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings",
JSON.stringify({ themeMode: "dark", maxConcurrent: 4 }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("global settings");
expect(res.body.error).toContain("themeMode");
expect(res.body.error).toContain("/settings/global");
});
it("rejects when only global fields are sent", async () => {
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings",
JSON.stringify({ defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("defaultProvider");
});
it("allows project-only fields to pass through successfully", async () => {
const updatedSettings = { ...DEFAULT_SETTINGS, maxConcurrent: 8 };
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings",
JSON.stringify({ maxConcurrent: 8, autoMerge: false }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(store.updateSettings).toHaveBeenCalledWith({ maxConcurrent: 8, autoMerge: false });
});
it("returns 500 on store update error", async () => { it("returns 500 on store update error", async () => {
(store.updateSettings as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Write failed")); (store.updateSettings as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Write failed"));
@@ -4502,3 +4558,128 @@ describe("PUT /settings", () => {
expect(res.body.error).toContain("Write failed"); expect(res.body.error).toContain("Write failed");
}); });
}); });
describe("GET /settings/global", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("returns global settings from the global settings store", async () => {
const mockGlobalStore = createMockGlobalSettingsStore();
mockGlobalStore.getSettings.mockResolvedValue({ themeMode: "light", colorTheme: "ocean" });
(store.getGlobalSettingsStore as ReturnType<typeof vi.fn>).mockReturnValue(mockGlobalStore);
const res = await GET(buildApp(), "/api/settings/global");
expect(res.status).toBe(200);
expect(res.body.themeMode).toBe("light");
expect(res.body.colorTheme).toBe("ocean");
// Should NOT include server-only fields
expect(res.body.githubTokenConfigured).toBeUndefined();
});
it("returns 500 on global store error", async () => {
const mockGlobalStore = createMockGlobalSettingsStore();
mockGlobalStore.getSettings.mockRejectedValue(new Error("Read failed"));
(store.getGlobalSettingsStore as ReturnType<typeof vi.fn>).mockReturnValue(mockGlobalStore);
const res = await GET(buildApp(), "/api/settings/global");
expect(res.status).toBe(500);
expect(res.body.error).toContain("Read failed");
});
});
describe("PUT /settings/global", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("updates global settings via store.updateGlobalSettings", async () => {
const updatedMerged = { themeMode: "light", maxConcurrent: 2 };
(store.updateGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedMerged);
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings/global",
JSON.stringify({ themeMode: "light" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(store.updateGlobalSettings).toHaveBeenCalledWith({ themeMode: "light" });
});
it("returns 500 on update error", async () => {
(store.updateGlobalSettings as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Write failed"));
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings/global",
JSON.stringify({ themeMode: "light" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(500);
expect(res.body.error).toContain("Write failed");
});
});
describe("GET /settings/scopes", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("returns settings separated by scope", async () => {
(store.getSettingsByScope as ReturnType<typeof vi.fn>).mockResolvedValue({
global: { themeMode: "dark", defaultProvider: "anthropic" },
project: { maxConcurrent: 4, autoMerge: false },
});
const res = await GET(buildApp(), "/api/settings/scopes");
expect(res.status).toBe(200);
expect(res.body.global.themeMode).toBe("dark");
expect(res.body.global.defaultProvider).toBe("anthropic");
expect(res.body.project.maxConcurrent).toBe(4);
expect(res.body.project.autoMerge).toBe(false);
});
it("returns 500 on store error", async () => {
(store.getSettingsByScope as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Failed"));
const res = await GET(buildApp(), "/api/settings/scopes");
expect(res.status).toBe(500);
expect(res.body.error).toContain("Failed");
});
});

View File

@@ -3,7 +3,7 @@ import multer from "multer";
import { createReadStream, existsSync } from "node:fs"; import { createReadStream, existsSync } from "node:fs";
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import type { TaskStore, Column, MergeResult, ScheduleType, ActivityEventType, ModelPreset } from "@kb/core"; import type { TaskStore, Column, MergeResult, ScheduleType, ActivityEventType, ModelPreset } from "@kb/core";
import { COLUMNS, VALID_TRANSITIONS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, isGhAuthenticated, AUTOMATION_PRESETS, AutomationStore } from "@kb/core"; import { COLUMNS, VALID_TRANSITIONS, GLOBAL_SETTINGS_KEYS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, isGhAuthenticated, AUTOMATION_PRESETS, AutomationStore } from "@kb/core";
import type { ServerOptions } from "./server.js"; import type { ServerOptions } from "./server.js";
import { GitHubClient, getCurrentGitHubRepo, parseBadgeUrl } from "./github.js"; import { GitHubClient, getCurrentGitHubRepo, parseBadgeUrl } from "./github.js";
import { githubRateLimiter } from "./github-poll.js"; import { githubRateLimiter } from "./github-poll.js";
@@ -932,6 +932,16 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { githubTokenConfigured, ...clientSettings } = req.body; const { githubTokenConfigured, ...clientSettings } = req.body;
// Reject global-only fields with a helpful error pointing to the correct endpoint
const globalKeySet = new Set<string>(GLOBAL_SETTINGS_KEYS);
const globalFieldsFound = Object.keys(clientSettings).filter((k) => globalKeySet.has(k));
if (globalFieldsFound.length > 0) {
res.status(400).json({
error: `Cannot update global settings via this endpoint. Use PUT /settings/global instead. Global fields found: ${globalFieldsFound.join(", ")}`,
});
return;
}
if (Object.prototype.hasOwnProperty.call(clientSettings, "modelPresets")) { if (Object.prototype.hasOwnProperty.call(clientSettings, "modelPresets")) {
clientSettings.modelPresets = validateModelPresets(clientSettings.modelPresets); clientSettings.modelPresets = validateModelPresets(clientSettings.modelPresets);
} }
@@ -946,6 +956,51 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
} }
}); });
// ── Global Settings Routes ─────────────────────────────────────
/**
* GET /api/settings/global
* Returns the global (user-level) settings from ~/.pi/kb/settings.json.
* Does NOT include computed/server-only fields like githubTokenConfigured.
*/
router.get("/settings/global", async (_req, res) => {
try {
const globalStore = store.getGlobalSettingsStore();
const settings = await globalStore.getSettings();
res.json(settings);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* PUT /api/settings/global
* Update global (user-level) settings in ~/.pi/kb/settings.json.
* These settings persist across all kb projects for the current user.
*/
router.put("/settings/global", async (req, res) => {
try {
const settings = await store.updateGlobalSettings(req.body);
res.json(settings);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* GET /api/settings/scopes
* Returns settings separated by scope: { global, project }.
* Useful for the UI to show which scope each setting comes from.
*/
router.get("/settings/scopes", async (_req, res) => {
try {
const scopes = await store.getSettingsByScope();
res.json(scopes);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/** /**
* POST /api/settings/test-ntfy * POST /api/settings/test-ntfy
* Send a test notification to verify ntfy configuration. * Send a test notification to verify ntfy configuration.