Files
fusion/packages/cli/src/commands/settings.ts

425 lines
13 KiB
TypeScript

import {
GlobalSettingsStore,
type Settings,
type GlobalSettings,
DEFAULT_SETTINGS,
resolveWorktrunkSettings,
SUPPORTED_LOCALES,
} from "@fusion/core";
import { probeWorktrunk, resolveWorktrunkBinary } from "@fusion/engine";
import { resolveProject } from "../project-context.js";
// Settings that can be updated via CLI.
//
// NOTE: the step/review/model-lane policy keys (`runStepsInNewSessions`,
// `maxParallelSteps`, `requirePlanApproval`, etc.) were MOVED to workflow settings
// (U4/KTD-5) and are intentionally ABSENT here — they live as per-workflow values,
// not project/global settings. See WORKFLOW_SETTINGS_REDIRECT_HINT below.
export const VALID_SETTINGS = [
"maxConcurrent",
"maxWorktrees",
"worktreeNaming",
"worktreesDir",
"taskPrefix",
"ntfyTopic",
"autoResolveConflicts",
"smartConflictResolution",
"ntfyEnabled",
"defaultModel",
"defaultNodeId",
"unavailableNodePolicy",
"worktrunk.enabled",
"worktrunk.binaryPath",
"worktrunk.onFailure",
"language",
] as const;
// One-line redirect surfaced wherever the CLI lists/validates settings keys, so
// users who reach for a moved key learn where it lives now (U5/KTD-8).
export const WORKFLOW_SETTINGS_REDIRECT_HINT =
"Note: step, review, and model-lane policy now live in workflow settings — edit them in the workflow editor or via fn_workflow_settings.";
const GLOBAL_ONLY_SETTINGS = ["ntfyEnabled", "ntfyTopic", "defaultModel", "language"] as const;
const PROJECT_ONLY_SETTINGS = [
"maxConcurrent",
"maxWorktrees",
"worktreeNaming",
"worktreesDir",
"taskPrefix",
"autoResolveConflicts",
"smartConflictResolution",
"defaultNodeId",
"unavailableNodePolicy",
] as const;
type ValidSettingKey = (typeof VALID_SETTINGS)[number];
// Type guards for setting categories
const BOOLEAN_SETTINGS: readonly string[] = [
"autoResolveConflicts",
"smartConflictResolution",
"ntfyEnabled",
"worktrunk.enabled",
];
const NUMBER_SETTINGS: readonly string[] = ["maxConcurrent", "maxWorktrees"];
const ENUM_SETTINGS: Record<string, readonly string[]> = {
worktreeNaming: ["random", "task-id", "task-title"],
unavailableNodePolicy: ["block", "fallback-local"],
"worktrunk.onFailure": ["fail", "fallback-native"],
// "auto" clears the persisted locale and reverts to runtime detection.
language: [...SUPPORTED_LOCALES, "auto"],
};
const STRING_SETTINGS: readonly string[] = [
"taskPrefix",
"ntfyTopic",
"defaultModel",
"defaultNodeId",
"worktreesDir",
"worktrunk.binaryPath",
];
// Validation ranges for numeric settings
const NUMBER_RANGES: Record<string, { min: number; max: number }> = {
maxConcurrent: { min: 1, max: 10 },
maxWorktrees: { min: 1, max: 20 },
};
async function getGlobalSettingsStore(): Promise<GlobalSettingsStore> {
const store = new GlobalSettingsStore();
await store.init();
return store;
}
function isGlobalOnlySetting(key: ValidSettingKey): boolean {
return GLOBAL_ONLY_SETTINGS.includes(key as (typeof GLOBAL_ONLY_SETTINGS)[number]);
}
function isProjectOnlySetting(key: ValidSettingKey): boolean {
return PROJECT_ONLY_SETTINGS.includes(key as (typeof PROJECT_ONLY_SETTINGS)[number]);
}
/**
* Parse and validate a setting value based on its key's expected type
*/
export function parseValue(key: ValidSettingKey, value: string): unknown {
const trimmed = value.trim();
// Boolean settings
if (BOOLEAN_SETTINGS.includes(key)) {
const lower = trimmed.toLowerCase();
if (lower === "true" || lower === "yes") return true;
if (lower === "false" || lower === "no") return false;
throw new Error(
`Invalid boolean value for ${key}: "${value}". Use: true, false, yes, or no`
);
}
// Number settings
if (NUMBER_SETTINGS.includes(key)) {
const num = parseInt(trimmed, 10);
if (isNaN(num)) {
throw new Error(`Invalid numeric value for ${key}: "${value}". Expected an integer.`);
}
const range = NUMBER_RANGES[key];
if (range && (num < range.min || num > range.max)) {
throw new Error(
`Value out of range for ${key}: ${num}. Must be between ${range.min} and ${range.max}.`
);
}
return num;
}
// Enum settings
if (key in ENUM_SETTINGS) {
const validValues = ENUM_SETTINGS[key];
if (!validValues.includes(trimmed)) {
throw new Error(
`Invalid value for ${key}: "${value}". Valid options: ${validValues.join(", ")}`
);
}
return trimmed;
}
// String settings (default)
if (STRING_SETTINGS.includes(key)) {
return trimmed;
}
return trimmed;
}
/**
* Format a setting value for display
*/
function readDottedSetting(settings: GlobalSettings | Settings, key: string): unknown {
const parts = key.split(".");
let cursor: unknown = settings;
for (const part of parts) {
if (cursor == null || typeof cursor !== "object") return undefined;
cursor = (cursor as Record<string, unknown>)[part];
}
return cursor;
}
function applyDottedSetting(key: string, value: unknown, current: Record<string, unknown>): Record<string, unknown> {
if (!key.includes(".")) {
return { [key]: value };
}
const [root, leaf] = key.split(".", 2);
const existingRoot = current[root];
const nested =
existingRoot && typeof existingRoot === "object" && !Array.isArray(existingRoot)
? (existingRoot as Record<string, unknown>)
: {};
return {
[root]: {
...nested,
[leaf]: value,
},
};
}
type CliSettingKey = string;
function formatSettingValue(
key: CliSettingKey,
value: unknown,
_settings: GlobalSettings | Settings
): string {
if (Array.isArray(value)) {
return value.length > 0 ? `[${value.join(", ")}]` : "[]";
}
if (value === undefined) {
return "(not set)";
}
if (typeof value === "boolean") {
return value ? "true" : "false";
}
if (typeof value === "number") {
return String(value);
}
if (typeof value === "string") {
const defaultValue = readDottedSetting(DEFAULT_SETTINGS, key);
if (value === defaultValue) {
return `"${value}" (default)`;
}
return `"${value}"`;
}
return String(value);
}
/**
* Get display name for a setting (convert camelCase to readable)
*/
function getSettingLabel(key: CliSettingKey): string {
if (key === "ntfyEnabled") return "ntfy Enabled";
if (key === "ntfyTopic") return "ntfy Topic";
if (key === "worktrunk.enabled") return "Worktrunk Enabled";
if (key === "worktrunk.binaryPath") return "Worktrunk Binary Path";
if (key === "worktrunk.onFailure") return "Worktrunk On Failure";
return key
.replace(/([A-Z])/g, " $1")
.replace(/^./, (str) => str.toUpperCase());
}
/**
* Run settings show command.
*
* Behavior:
* - `fn settings` shows global settings
* - `fn settings --project <name>` shows project settings for that project
*/
export async function runSettingsShow(projectName?: string): Promise<void> {
const project = projectName ? await resolveProject(projectName) : undefined;
const settings = project
? await project.store.getSettings()
: await (await getGlobalSettingsStore()).getSettings();
console.log();
console.log(project
? ` fn Settings for project '${project.projectName}'`
: " fn Global Settings");
console.log(" " + "─".repeat(50));
const settingGroups = [
{
title: "Engine",
keys: ["maxConcurrent", "maxWorktrees", "autoResolveConflicts", "smartConflictResolution"],
},
{
title: "Worktrees",
keys: ["worktreeNaming", "worktreesDir", "recycleWorktrees"],
},
{
title: "Worktrunk Integration",
keys: ["worktrunk.enabled", "worktrunk.binaryPath", "worktrunk.onFailure"],
},
{
title: "Tasks",
keys: ["taskPrefix", "includeTaskIdInCommit"],
},
{
title: "Node Routing",
keys: ["defaultNodeId", "unavailableNodePolicy"],
},
{
title: "Notifications",
keys: ["ntfyEnabled", "ntfyTopic"],
},
{
title: "AI Model",
keys: ["defaultProvider", "defaultModelId", "defaultThinkingLevel"],
},
];
for (const group of settingGroups) {
const hasValues = group.keys.some((key) => readDottedSetting(settings, key) !== undefined);
if (!hasValues) continue;
console.log();
console.log(` ${group.title}:`);
for (const key of group.keys) {
const value = readDottedSetting(settings, key);
const label = getSettingLabel(key);
const formattedValue = formatSettingValue(key, value, settings);
console.log(` ${label.padEnd(25)} ${formattedValue}`);
}
}
console.log();
console.log(` ${WORKFLOW_SETTINGS_REDIRECT_HINT}`);
console.log();
}
/**
* Run settings set command - updates a single setting.
*
* Scope rules:
* - Global-only settings (`ntfy*`, `defaultModel`) update global settings
* - Project-only settings require an explicit `--project` target
*/
export async function runSettingsSet(key: string, value: string, projectName?: string): Promise<void> {
if (!VALID_SETTINGS.includes(key as ValidSettingKey)) {
console.error(`Error: Unknown setting "${key}"`);
console.error(`Valid settings: ${VALID_SETTINGS.join(", ")}`);
console.error(WORKFLOW_SETTINGS_REDIRECT_HINT);
process.exit(1);
return;
}
const validKey = key as ValidSettingKey;
if (projectName && isGlobalOnlySetting(validKey)) {
console.error(`Error: Setting "${key}" is global-only. Omit --project to update it.`);
process.exit(1);
return;
}
if (!projectName && isProjectOnlySetting(validKey)) {
console.error(`Error: Setting "${key}" is project-only. Use --project or run from a project directory.`);
process.exit(1);
return;
}
const projectContext = projectName ? await resolveProject(projectName) : undefined;
const store = projectContext?.store;
const globalStore = store ? undefined : await getGlobalSettingsStore();
try {
const parsedValue = parseValue(validKey, value);
if (key === "language" && parsedValue === "auto") {
// null-as-delete: removes the persisted key so the dashboard re-detects
// from the browser and the TUI falls back to the environment locale.
await globalStore!.updateSettings({ language: null } as unknown as Partial<GlobalSettings>);
console.log();
console.log(" ✓ Language reset to auto-detect (browser/environment locale)");
console.log();
return;
}
if (key === "defaultModel") {
const parts = (parsedValue as string).split("/");
if (parts.length !== 2) {
console.error(
`Error: Invalid format for defaultModel. Use "provider/model-id" (e.g., "anthropic/claude-sonnet-4-5")`
);
process.exit(1);
return;
}
const [provider, modelId] = parts;
if (store) {
await store.updateSettings({ defaultProvider: provider, defaultModelId: modelId });
} else {
await globalStore!.updateSettings({ defaultProvider: provider, defaultModelId: modelId });
}
console.log();
console.log(` ✓ Updated default model to ${provider}/${modelId}`);
console.log();
return;
}
if (key === "worktrunk.enabled" && parsedValue === true) {
const currentWorktrunk = store
? await store.getSettingsByScope().then((scoped) =>
resolveWorktrunkSettings(scoped.global?.worktrunk, scoped.project?.worktrunk),
)
: resolveWorktrunkSettings((await globalStore!.getSettings()).worktrunk, undefined);
const nextWorktrunk = { ...currentWorktrunk, enabled: true };
try {
const resolved = await resolveWorktrunkBinary({ settings: nextWorktrunk });
const probe = await probeWorktrunk(resolved.binaryPath);
if (!probe.ok) {
throw new Error("worktrunk probe failed");
}
} catch {
throw new Error(
"worktrunk.enabled cannot be set to true until the binary is installed and verified. Install it from Settings → Worktrunk integration (or /api/worktrunk/install-request) first.",
);
}
}
if (store) {
if (key.includes(".")) {
const currentSettings = await store.getSettingsByScope();
const projectPatch = applyDottedSetting(
key,
parsedValue,
(currentSettings.project ?? {}) as Record<string, unknown>,
);
await store.updateSettings(projectPatch as Partial<Settings>);
} else {
await store.updateSettings({ [key]: parsedValue } as Partial<Settings>);
}
} else if (key.includes(".")) {
const currentGlobalSettings = await globalStore!.getSettings();
const globalPatch = applyDottedSetting(key, parsedValue, currentGlobalSettings as Record<string, unknown>);
await globalStore!.updateSettings(globalPatch as Partial<GlobalSettings>);
} else {
await globalStore!.updateSettings({ [key]: parsedValue } as Partial<GlobalSettings>);
}
const currentSettings = store ? await store.getSettings() : await globalStore!.getSettings();
console.log();
console.log(` ✓ Updated ${getSettingLabel(key)} to ${formatSettingValue(key, parsedValue, currentSettings as Settings)}`);
console.log();
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
return;
}
}