280 lines
9.4 KiB
TypeScript
280 lines
9.4 KiB
TypeScript
import { readFile } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import { homedir } from 'node:os';
|
|
|
|
export interface KthuluSettings {
|
|
// Model
|
|
model?: string;
|
|
temperature?: number;
|
|
maxTokens?: number;
|
|
contextLength?: number;
|
|
thinking?: boolean;
|
|
|
|
// Agent behavior
|
|
maxTurns?: number;
|
|
effort?: 'low' | 'medium' | 'high';
|
|
autoApprove?: boolean;
|
|
|
|
// Token budget
|
|
maxSessionTokens?: number;
|
|
budgetWarnThreshold?: number;
|
|
|
|
// Tools
|
|
allowedTools?: string[];
|
|
disallowedTools?: string[];
|
|
|
|
// Hooks
|
|
hooks?: Record<string, string[]>;
|
|
|
|
// Display
|
|
verbose?: boolean;
|
|
theme?: 'default' | 'minimal' | 'verbose';
|
|
}
|
|
|
|
/** All fields present — we guarantee defaults are always defined. */
|
|
type ResolvedSettings = Required<Omit<KthuluSettings, 'model' | 'hooks' | 'allowedTools' | 'disallowedTools'>> &
|
|
Pick<KthuluSettings, 'model' | 'hooks' | 'allowedTools' | 'disallowedTools'>;
|
|
|
|
function envInt(key: string, fallback: number): number {
|
|
const val = process.env[key];
|
|
if (val === undefined) return fallback;
|
|
const parsed = parseInt(val, 10);
|
|
return Number.isNaN(parsed) ? fallback : parsed;
|
|
}
|
|
|
|
function envFloat(key: string, fallback: number): number {
|
|
const val = process.env[key];
|
|
if (val === undefined) return fallback;
|
|
const parsed = parseFloat(val);
|
|
return Number.isNaN(parsed) ? fallback : parsed;
|
|
}
|
|
|
|
function envBool(key: string, fallback: boolean): boolean {
|
|
const val = process.env[key];
|
|
if (val === undefined) return fallback;
|
|
return val === '1' || val === 'true';
|
|
}
|
|
|
|
function envString<T extends string>(key: string, fallback: T, valid?: T[]): T {
|
|
const val = process.env[key] as T | undefined;
|
|
if (val === undefined) return fallback;
|
|
if (valid && !valid.includes(val)) return fallback;
|
|
return val;
|
|
}
|
|
|
|
const BUILT_IN_DEFAULTS: ResolvedSettings = {
|
|
// model is intentionally absent — callers supply a fallback
|
|
model: process.env['KTHULU_MODEL'],
|
|
temperature: envFloat('KTHULU_TEMPERATURE', 0.6),
|
|
maxTokens: envInt('KTHULU_MAX_TOKENS', 16384),
|
|
contextLength: envInt('KTHULU_CONTEXT_LENGTH', 32768),
|
|
thinking: envBool('KTHULU_THINKING', false),
|
|
|
|
maxTurns: envInt('KTHULU_MAX_TURNS', 20),
|
|
effort: envString('KTHULU_EFFORT', 'medium', ['low', 'medium', 'high']),
|
|
autoApprove: envBool('KTHULU_AUTO_APPROVE', false),
|
|
|
|
maxSessionTokens: envInt('KTHULU_MAX_SESSION_TOKENS', 0),
|
|
budgetWarnThreshold: envFloat('KTHULU_BUDGET_WARN_THRESHOLD', 0.8),
|
|
|
|
allowedTools: undefined,
|
|
disallowedTools: undefined,
|
|
|
|
hooks: undefined,
|
|
|
|
verbose: envBool('KTHULU_VERBOSE', false),
|
|
theme: envString('KTHULU_THEME', 'default', ['default', 'minimal', 'verbose']),
|
|
};
|
|
|
|
/**
|
|
* Loads KthuluSettings from a 4-tier hierarchy, each layer overriding the
|
|
* previous. We apply them in this order:
|
|
*
|
|
* 1. Built-in defaults (hardcoded above)
|
|
* 2. User settings — ~/.kthulu/settings.json
|
|
* 3. Project settings — <projectDir>/.kthulu/settings.json
|
|
* 4. Local overrides — <projectDir>/.kthulu/settings.local.json (gitignored)
|
|
*/
|
|
export class SettingsLoader {
|
|
private readonly userConfigDir: string;
|
|
|
|
constructor(userConfigDir?: string) {
|
|
this.userConfigDir = userConfigDir ?? join(homedir(), '.kthulu');
|
|
}
|
|
|
|
async load(projectDir: string): Promise<KthuluSettings> {
|
|
const [userSettings, projectSettings, localOverrides] = await Promise.all([
|
|
this.loadJsonFile(join(this.userConfigDir, 'settings.json')),
|
|
this.loadJsonFile(join(projectDir, '.kthulu', 'settings.json')),
|
|
this.loadJsonFile(join(projectDir, '.kthulu', 'settings.local.json')),
|
|
]);
|
|
|
|
let merged: KthuluSettings = { ...BUILT_IN_DEFAULTS };
|
|
merged = this.deepMerge(merged, userSettings);
|
|
merged = this.deepMerge(merged, projectSettings);
|
|
merged = this.deepMerge(merged, localOverrides);
|
|
|
|
return merged;
|
|
}
|
|
|
|
/**
|
|
* Reads and parses a JSON settings file. Returns an empty object when the
|
|
* file is absent or cannot be parsed so that we never abort the load chain
|
|
* due to a missing optional file.
|
|
*/
|
|
private async loadJsonFile(filePath: string): Promise<Partial<KthuluSettings>> {
|
|
let raw: string;
|
|
try {
|
|
raw = await readFile(filePath, 'utf-8');
|
|
} catch {
|
|
// File does not exist or is unreadable — treat as empty layer.
|
|
return {};
|
|
}
|
|
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch {
|
|
// Malformed JSON — we surface a warning but do not abort.
|
|
process.stderr.write(`[kthulu] Warning: settings file at "${filePath}" contains invalid JSON and will be ignored.\n`);
|
|
return {};
|
|
}
|
|
|
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
process.stderr.write(`[kthulu] Warning: settings file at "${filePath}" must be a JSON object and will be ignored.\n`);
|
|
return {};
|
|
}
|
|
|
|
return this.validateShape(parsed as Record<string, unknown>, filePath);
|
|
}
|
|
|
|
/**
|
|
* Strips unknown keys and validates types for known fields. Unknown keys
|
|
* are silently dropped so that future settings additions remain backwards
|
|
* compatible.
|
|
*/
|
|
private validateShape(
|
|
raw: Record<string, unknown>,
|
|
filePath: string,
|
|
): Partial<KthuluSettings> {
|
|
const out: Partial<KthuluSettings> = {};
|
|
const warn = (field: string, expected: string) =>
|
|
process.stderr.write(
|
|
`[kthulu] Warning: "${field}" in "${filePath}" must be ${expected} — ignoring field.\n`,
|
|
);
|
|
|
|
if ('model' in raw) {
|
|
if (typeof raw['model'] === 'string') out.model = raw['model'];
|
|
else warn('model', 'a string');
|
|
}
|
|
if ('temperature' in raw) {
|
|
if (typeof raw['temperature'] === 'number') out.temperature = raw['temperature'];
|
|
else warn('temperature', 'a number');
|
|
}
|
|
if ('maxTokens' in raw) {
|
|
if (typeof raw['maxTokens'] === 'number') out.maxTokens = raw['maxTokens'];
|
|
else warn('maxTokens', 'a number');
|
|
}
|
|
if ('contextLength' in raw) {
|
|
if (typeof raw['contextLength'] === 'number') out.contextLength = raw['contextLength'];
|
|
else warn('contextLength', 'a number');
|
|
}
|
|
if ('thinking' in raw) {
|
|
if (typeof raw['thinking'] === 'boolean') out.thinking = raw['thinking'];
|
|
else warn('thinking', 'a boolean');
|
|
}
|
|
if ('maxTurns' in raw) {
|
|
if (typeof raw['maxTurns'] === 'number') out.maxTurns = raw['maxTurns'];
|
|
else warn('maxTurns', 'a number');
|
|
}
|
|
if ('effort' in raw) {
|
|
if (raw['effort'] === 'low' || raw['effort'] === 'medium' || raw['effort'] === 'high') {
|
|
out.effort = raw['effort'];
|
|
} else {
|
|
warn('effort', '"low", "medium", or "high"');
|
|
}
|
|
}
|
|
if ('autoApprove' in raw) {
|
|
if (typeof raw['autoApprove'] === 'boolean') out.autoApprove = raw['autoApprove'];
|
|
else warn('autoApprove', 'a boolean');
|
|
}
|
|
if ('maxSessionTokens' in raw) {
|
|
if (typeof raw['maxSessionTokens'] === 'number') out.maxSessionTokens = raw['maxSessionTokens'];
|
|
else warn('maxSessionTokens', 'a number');
|
|
}
|
|
if ('budgetWarnThreshold' in raw) {
|
|
if (typeof raw['budgetWarnThreshold'] === 'number') out.budgetWarnThreshold = raw['budgetWarnThreshold'];
|
|
else warn('budgetWarnThreshold', 'a number');
|
|
}
|
|
if ('allowedTools' in raw) {
|
|
if (Array.isArray(raw['allowedTools']) && raw['allowedTools'].every((v) => typeof v === 'string')) {
|
|
out.allowedTools = raw['allowedTools'] as string[];
|
|
} else {
|
|
warn('allowedTools', 'an array of strings');
|
|
}
|
|
}
|
|
if ('disallowedTools' in raw) {
|
|
if (Array.isArray(raw['disallowedTools']) && raw['disallowedTools'].every((v) => typeof v === 'string')) {
|
|
out.disallowedTools = raw['disallowedTools'] as string[];
|
|
} else {
|
|
warn('disallowedTools', 'an array of strings');
|
|
}
|
|
}
|
|
if ('hooks' in raw) {
|
|
if (
|
|
typeof raw['hooks'] === 'object' &&
|
|
raw['hooks'] !== null &&
|
|
!Array.isArray(raw['hooks']) &&
|
|
Object.values(raw['hooks']).every(
|
|
(v) => Array.isArray(v) && (v as unknown[]).every((cmd) => typeof cmd === 'string'),
|
|
)
|
|
) {
|
|
out.hooks = raw['hooks'] as Record<string, string[]>;
|
|
} else {
|
|
warn('hooks', 'an object mapping event names to arrays of strings');
|
|
}
|
|
}
|
|
if ('verbose' in raw) {
|
|
if (typeof raw['verbose'] === 'boolean') out.verbose = raw['verbose'];
|
|
else warn('verbose', 'a boolean');
|
|
}
|
|
if ('theme' in raw) {
|
|
if (raw['theme'] === 'default' || raw['theme'] === 'minimal' || raw['theme'] === 'verbose') {
|
|
out.theme = raw['theme'];
|
|
} else {
|
|
warn('theme', '"default", "minimal", or "verbose"');
|
|
}
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Merges source into target. Primitive fields are replaced; arrays replace
|
|
* entirely (no concatenation); hooks objects are shallow-merged at the
|
|
* event-name level with arrays replacing.
|
|
*/
|
|
private deepMerge(
|
|
target: KthuluSettings,
|
|
source: Partial<KthuluSettings>,
|
|
): KthuluSettings {
|
|
const result: KthuluSettings = { ...target };
|
|
|
|
for (const key of Object.keys(source) as (keyof KthuluSettings)[]) {
|
|
const value = source[key];
|
|
if (value === undefined) continue;
|
|
|
|
if (key === 'hooks') {
|
|
// Merge hook maps: source event arrays replace target event arrays.
|
|
const targetHooks = target.hooks ?? {};
|
|
const sourceHooks = value as Record<string, string[]>;
|
|
result.hooks = { ...targetHooks, ...sourceHooks };
|
|
} else {
|
|
// Arrays and primitives both replace entirely.
|
|
(result as Record<string, unknown>)[key] = value;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
}
|