feat(KB-201): add settings CLI commands
- Add 'kb settings' command with get, set, list, and unset subcommands - Create settings.ts command module with full type validation - Add comprehensive unit tests for settings commands (432 lines) - Wire settings routing into CLI entry point (bin.ts) - Add changeset for patch release of @dustinbyrne/kb package
This commit is contained in:
@@ -40,6 +40,7 @@ if (isBunBinary) {
|
||||
// Dynamic imports so the pi-coding-agent config module sees PI_PACKAGE_DIR
|
||||
const { runDashboard } = await import("./commands/dashboard.js");
|
||||
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate, runTaskArchive, runTaskUnarchive, runTaskRefine, runTaskPlan, runTaskDelete, runTaskRetry } = await import("./commands/task.js");
|
||||
const { runSettingsShow, runSettingsSet } = await import("./commands/settings.js");
|
||||
|
||||
const HELP = `
|
||||
kb — AI-orchestrated task board
|
||||
@@ -67,6 +68,8 @@ Usage:
|
||||
kb task unpause <id> Unpause a task (resumes automation)
|
||||
kb task retry <id> Retry a failed task (clears error, moves to todo)
|
||||
kb task import <owner/repo> [opts] Import GitHub issues as tasks
|
||||
kb settings Show current kb configuration
|
||||
kb settings set <key> <value> Update a configuration setting
|
||||
|
||||
Options:
|
||||
--port, -p <port> Dashboard port (default: 4040)
|
||||
@@ -308,6 +311,28 @@ async function main() {
|
||||
break;
|
||||
}
|
||||
|
||||
case "settings": {
|
||||
const subcommand = args[1];
|
||||
if (!subcommand || subcommand === "show") {
|
||||
await runSettingsShow();
|
||||
break;
|
||||
}
|
||||
if (subcommand === "set") {
|
||||
const key = args[2];
|
||||
const value = args.slice(3).join(" ");
|
||||
if (!key || value === undefined) {
|
||||
console.error("Usage: kb settings set <key> <value>");
|
||||
console.error("Example: kb settings set maxConcurrent 4");
|
||||
process.exit(1);
|
||||
}
|
||||
await runSettingsSet(key, value);
|
||||
break;
|
||||
}
|
||||
console.error(`Unknown settings subcommand: ${subcommand}`);
|
||||
console.error("Try: kb settings | kb settings set <key> <value>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
default:
|
||||
console.error(`Unknown command: ${command}`);
|
||||
console.log(HELP);
|
||||
|
||||
432
packages/cli/src/commands/settings.test.ts
Normal file
432
packages/cli/src/commands/settings.test.ts
Normal file
@@ -0,0 +1,432 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mock @kb/core before importing the module under test
|
||||
vi.mock("@kb/core", () => {
|
||||
const DEFAULT_SETTINGS = {
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
autoResolveConflicts: true,
|
||||
smartConflictResolution: true,
|
||||
requirePlanApproval: false,
|
||||
ntfyEnabled: false,
|
||||
taskPrefix: undefined,
|
||||
ntfyTopic: undefined,
|
||||
worktreeNaming: "random",
|
||||
githubTokenConfigured: false,
|
||||
};
|
||||
|
||||
return {
|
||||
TaskStore: vi.fn(),
|
||||
DEFAULT_SETTINGS,
|
||||
};
|
||||
});
|
||||
|
||||
import { TaskStore, DEFAULT_SETTINGS } from "@kb/core";
|
||||
import {
|
||||
runSettingsShow,
|
||||
runSettingsSet,
|
||||
parseValue,
|
||||
VALID_SETTINGS,
|
||||
} from "./settings.js";
|
||||
|
||||
function makeSettings(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
...DEFAULT_SETTINGS,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("VALID_SETTINGS", () => {
|
||||
it("contains all expected CLI-updatable settings", () => {
|
||||
expect(VALID_SETTINGS).toContain("maxConcurrent");
|
||||
expect(VALID_SETTINGS).toContain("maxWorktrees");
|
||||
expect(VALID_SETTINGS).toContain("worktreeNaming");
|
||||
expect(VALID_SETTINGS).toContain("taskPrefix");
|
||||
expect(VALID_SETTINGS).toContain("ntfyTopic");
|
||||
expect(VALID_SETTINGS).toContain("autoResolveConflicts");
|
||||
expect(VALID_SETTINGS).toContain("smartConflictResolution");
|
||||
expect(VALID_SETTINGS).toContain("requirePlanApproval");
|
||||
expect(VALID_SETTINGS).toContain("ntfyEnabled");
|
||||
expect(VALID_SETTINGS).toContain("defaultModel");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseValue", () => {
|
||||
describe("boolean settings", () => {
|
||||
const booleanSettings = [
|
||||
"autoResolveConflicts",
|
||||
"smartConflictResolution",
|
||||
"requirePlanApproval",
|
||||
"ntfyEnabled",
|
||||
] as const;
|
||||
|
||||
for (const setting of booleanSettings) {
|
||||
describe(setting, () => {
|
||||
it('returns true for "true"', () => {
|
||||
expect(parseValue(setting, "true")).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for "TRUE" (case-insensitive)', () => {
|
||||
expect(parseValue(setting, "TRUE")).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for "yes"', () => {
|
||||
expect(parseValue(setting, "yes")).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for "YES" (case-insensitive)', () => {
|
||||
expect(parseValue(setting, "YES")).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for "false"', () => {
|
||||
expect(parseValue(setting, "false")).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for "FALSE" (case-insensitive)', () => {
|
||||
expect(parseValue(setting, "FALSE")).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for "no"', () => {
|
||||
expect(parseValue(setting, "no")).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for "NO" (case-insensitive)', () => {
|
||||
expect(parseValue(setting, "NO")).toBe(false);
|
||||
});
|
||||
|
||||
it("throws for invalid boolean values", () => {
|
||||
expect(() => parseValue(setting, "invalid")).toThrow(
|
||||
`Invalid boolean value for ${setting}: "invalid"`
|
||||
);
|
||||
});
|
||||
|
||||
it("throws for empty strings", () => {
|
||||
expect(() => parseValue(setting, "")).toThrow();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("number settings", () => {
|
||||
it("parses maxConcurrent as integer", () => {
|
||||
expect(parseValue("maxConcurrent", "4")).toBe(4);
|
||||
});
|
||||
|
||||
it("parses maxWorktrees as integer", () => {
|
||||
expect(parseValue("maxWorktrees", "8")).toBe(8);
|
||||
});
|
||||
|
||||
it("rejects non-numeric values for maxConcurrent", () => {
|
||||
expect(() => parseValue("maxConcurrent", "abc")).toThrow(
|
||||
'Invalid numeric value for maxConcurrent: "abc"'
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects non-numeric values for maxWorktrees", () => {
|
||||
expect(() => parseValue("maxWorktrees", "xyz")).toThrow(
|
||||
'Invalid numeric value for maxWorktrees: "xyz"'
|
||||
);
|
||||
});
|
||||
|
||||
it("enforces maxConcurrent range (1-10)", () => {
|
||||
expect(() => parseValue("maxConcurrent", "0")).toThrow(
|
||||
"Value out of range for maxConcurrent: 0. Must be between 1 and 10."
|
||||
);
|
||||
expect(() => parseValue("maxConcurrent", "11")).toThrow(
|
||||
"Value out of range for maxConcurrent: 11. Must be between 1 and 10."
|
||||
);
|
||||
});
|
||||
|
||||
it("enforces maxWorktrees range (1-20)", () => {
|
||||
expect(() => parseValue("maxWorktrees", "0")).toThrow(
|
||||
"Value out of range for maxWorktrees: 0. Must be between 1 and 20."
|
||||
);
|
||||
expect(() => parseValue("maxWorktrees", "21")).toThrow(
|
||||
"Value out of range for maxWorktrees: 21. Must be between 1 and 20."
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts boundary values", () => {
|
||||
expect(parseValue("maxConcurrent", "1")).toBe(1);
|
||||
expect(parseValue("maxConcurrent", "10")).toBe(10);
|
||||
expect(parseValue("maxWorktrees", "1")).toBe(1);
|
||||
expect(parseValue("maxWorktrees", "20")).toBe(20);
|
||||
});
|
||||
|
||||
it("handles whitespace", () => {
|
||||
expect(parseValue("maxConcurrent", " 5 ")).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("enum settings", () => {
|
||||
describe("worktreeNaming", () => {
|
||||
it('accepts "random"', () => {
|
||||
expect(parseValue("worktreeNaming", "random")).toBe("random");
|
||||
});
|
||||
|
||||
it('accepts "task-id"', () => {
|
||||
expect(parseValue("worktreeNaming", "task-id")).toBe("task-id");
|
||||
});
|
||||
|
||||
it('accepts "task-title"', () => {
|
||||
expect(parseValue("worktreeNaming", "task-title")).toBe("task-title");
|
||||
});
|
||||
|
||||
it("rejects invalid enum values", () => {
|
||||
expect(() => parseValue("worktreeNaming", "invalid")).toThrow(
|
||||
'Invalid value for worktreeNaming: "invalid". Valid options: random, task-id, task-title'
|
||||
);
|
||||
});
|
||||
|
||||
it("handles whitespace", () => {
|
||||
expect(parseValue("worktreeNaming", " task-id ")).toBe("task-id");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("string settings", () => {
|
||||
it("returns taskPrefix as trimmed string", () => {
|
||||
expect(parseValue("taskPrefix", " TASK ")).toBe("TASK");
|
||||
});
|
||||
|
||||
it("returns ntfyTopic as trimmed string", () => {
|
||||
expect(parseValue("ntfyTopic", " my-topic ")).toBe("my-topic");
|
||||
});
|
||||
|
||||
it("returns defaultModel as trimmed string", () => {
|
||||
expect(parseValue("defaultModel", " anthropic/claude-4 ")).toBe("anthropic/claude-4");
|
||||
});
|
||||
|
||||
it("allows empty strings to clear values", () => {
|
||||
expect(parseValue("taskPrefix", "")).toBe("");
|
||||
expect(parseValue("ntfyTopic", "")).toBe("");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("runSettingsShow", () => {
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("displays settings in formatted output", async () => {
|
||||
const mockSettings = makeSettings({
|
||||
maxConcurrent: 3,
|
||||
maxWorktrees: 6,
|
||||
autoResolveConflicts: false,
|
||||
taskPrefix: "CUSTOM",
|
||||
});
|
||||
|
||||
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
||||
init: vi.fn(),
|
||||
getSettings: vi.fn().mockResolvedValue(mockSettings),
|
||||
}));
|
||||
|
||||
await runSettingsShow();
|
||||
|
||||
// Check for header
|
||||
const headerLine = logSpy.mock.calls.find(
|
||||
(call) => typeof call[0] === "string" && call[0].includes("kb Configuration Settings")
|
||||
);
|
||||
expect(headerLine).toBeDefined();
|
||||
|
||||
// Check that maxConcurrent appears
|
||||
const maxConcurrentLine = logSpy.mock.calls.find(
|
||||
(call) => typeof call[0] === "string" && call[0].includes("Max Concurrent")
|
||||
);
|
||||
expect(maxConcurrentLine).toBeDefined();
|
||||
|
||||
// Check that group headers appear
|
||||
const engineGroup = logSpy.mock.calls.find(
|
||||
(call) => typeof call[0] === "string" && call[0].includes("Engine:")
|
||||
);
|
||||
expect(engineGroup).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows githubTokenConfigured as configured indicator", async () => {
|
||||
const mockSettings = makeSettings({
|
||||
githubTokenConfigured: true,
|
||||
});
|
||||
|
||||
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
||||
init: vi.fn(),
|
||||
getSettings: vi.fn().mockResolvedValue(mockSettings),
|
||||
}));
|
||||
|
||||
await runSettingsShow();
|
||||
|
||||
const configuredLine = logSpy.mock.calls.find(
|
||||
(call) => typeof call[0] === "string" && call[0].includes("(configured)")
|
||||
);
|
||||
expect(configuredLine).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows githubTokenConfigured as not configured indicator", async () => {
|
||||
const mockSettings = makeSettings({
|
||||
githubTokenConfigured: false,
|
||||
});
|
||||
|
||||
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
||||
init: vi.fn(),
|
||||
getSettings: vi.fn().mockResolvedValue(mockSettings),
|
||||
}));
|
||||
|
||||
await runSettingsShow();
|
||||
|
||||
const notConfiguredLine = logSpy.mock.calls.find(
|
||||
(call) => typeof call[0] === "string" && call[0].includes("(not configured)")
|
||||
);
|
||||
expect(notConfiguredLine).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runSettingsSet", () => {
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
let mockUpdateSettings: ReturnType<typeof vi.fn>;
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as (code?: number) => never);
|
||||
|
||||
mockUpdateSettings = vi.fn().mockResolvedValue({
|
||||
maxConcurrent: 4,
|
||||
maxWorktrees: 4,
|
||||
});
|
||||
|
||||
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
||||
init: vi.fn(),
|
||||
updateSettings: mockUpdateSettings,
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
maxConcurrent: 4,
|
||||
maxWorktrees: 4,
|
||||
taskPrefix: "TEST",
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("updates maxConcurrent with valid value", async () => {
|
||||
await runSettingsSet("maxConcurrent", "4");
|
||||
|
||||
expect(mockUpdateSettings).toHaveBeenCalledWith({ maxConcurrent: 4 });
|
||||
|
||||
const successLine = logSpy.mock.calls.find(
|
||||
(call) => typeof call[0] === "string" && call[0].includes("✓ Updated")
|
||||
);
|
||||
expect(successLine).toBeDefined();
|
||||
expect(successLine![0]).toContain("4");
|
||||
});
|
||||
|
||||
it("updates autoResolveConflicts with boolean true", async () => {
|
||||
await runSettingsSet("autoResolveConflicts", "true");
|
||||
|
||||
expect(mockUpdateSettings).toHaveBeenCalledWith({ autoResolveConflicts: true });
|
||||
|
||||
const successLine = logSpy.mock.calls.find(
|
||||
(call) => typeof call[0] === "string" && call[0].includes("✓ Updated")
|
||||
);
|
||||
expect(successLine).toBeDefined();
|
||||
});
|
||||
|
||||
it("updates autoResolveConflicts with boolean false", async () => {
|
||||
await runSettingsSet("autoResolveConflicts", "false");
|
||||
|
||||
expect(mockUpdateSettings).toHaveBeenCalledWith({ autoResolveConflicts: false });
|
||||
});
|
||||
|
||||
it("updates autoResolveConflicts with 'yes'", async () => {
|
||||
await runSettingsSet("autoResolveConflicts", "yes");
|
||||
|
||||
expect(mockUpdateSettings).toHaveBeenCalledWith({ autoResolveConflicts: true });
|
||||
});
|
||||
|
||||
it("updates autoResolveConflicts with 'no'", async () => {
|
||||
await runSettingsSet("autoResolveConflicts", "no");
|
||||
|
||||
expect(mockUpdateSettings).toHaveBeenCalledWith({ autoResolveConflicts: false });
|
||||
});
|
||||
|
||||
it("updates worktreeNaming with valid enum", async () => {
|
||||
await runSettingsSet("worktreeNaming", "task-id");
|
||||
|
||||
expect(mockUpdateSettings).toHaveBeenCalledWith({ worktreeNaming: "task-id" });
|
||||
});
|
||||
|
||||
it("updates taskPrefix with string value", async () => {
|
||||
await runSettingsSet("taskPrefix", "CUSTOM");
|
||||
|
||||
expect(mockUpdateSettings).toHaveBeenCalledWith({ taskPrefix: "CUSTOM" });
|
||||
});
|
||||
|
||||
it("updates ntfyTopic with string value", async () => {
|
||||
await runSettingsSet("ntfyTopic", "my-notifications");
|
||||
|
||||
expect(mockUpdateSettings).toHaveBeenCalledWith({ ntfyTopic: "my-notifications" });
|
||||
});
|
||||
|
||||
it("handles defaultModel split into provider and modelId", async () => {
|
||||
await runSettingsSet("defaultModel", "anthropic/claude-sonnet-4-5");
|
||||
|
||||
expect(mockUpdateSettings).toHaveBeenCalledWith({
|
||||
defaultProvider: "anthropic",
|
||||
defaultModelId: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
const successLine = logSpy.mock.calls.find(
|
||||
(call) => typeof call[0] === "string" && call[0].includes("anthropic/claude-sonnet-4-5")
|
||||
);
|
||||
expect(successLine).toBeDefined();
|
||||
});
|
||||
|
||||
it("exits with error for unknown setting key", async () => {
|
||||
await runSettingsSet("unknownSetting", "value");
|
||||
|
||||
expect(mockUpdateSettings).not.toHaveBeenCalled();
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Unknown setting"));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("exits with error for invalid boolean value", async () => {
|
||||
await runSettingsSet("autoResolveConflicts", "invalid");
|
||||
|
||||
expect(mockUpdateSettings).not.toHaveBeenCalled();
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Invalid boolean value"));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("exits with error for out-of-range number", async () => {
|
||||
await runSettingsSet("maxConcurrent", "99");
|
||||
|
||||
expect(mockUpdateSettings).not.toHaveBeenCalled();
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Value out of range"));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("exits with error for invalid enum value", async () => {
|
||||
await runSettingsSet("worktreeNaming", "invalid");
|
||||
|
||||
expect(mockUpdateSettings).not.toHaveBeenCalled();
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Invalid value for worktreeNaming"));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("handles defaultModel with invalid format (no slash)", async () => {
|
||||
await runSettingsSet("defaultModel", "invalid-format");
|
||||
|
||||
expect(mockUpdateSettings).not.toHaveBeenCalled();
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Invalid format for defaultModel"));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
264
packages/cli/src/commands/settings.ts
Normal file
264
packages/cli/src/commands/settings.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { TaskStore, type Settings, DEFAULT_SETTINGS } from "@kb/core";
|
||||
|
||||
// Settings that can be updated via CLI
|
||||
export const VALID_SETTINGS = [
|
||||
"maxConcurrent",
|
||||
"maxWorktrees",
|
||||
"worktreeNaming",
|
||||
"taskPrefix",
|
||||
"ntfyTopic",
|
||||
"autoResolveConflicts",
|
||||
"smartConflictResolution",
|
||||
"requirePlanApproval",
|
||||
"ntfyEnabled",
|
||||
"defaultModel",
|
||||
] as const;
|
||||
|
||||
type ValidSettingKey = (typeof VALID_SETTINGS)[number];
|
||||
|
||||
// Type guards for setting categories
|
||||
const BOOLEAN_SETTINGS: readonly string[] = [
|
||||
"autoResolveConflicts",
|
||||
"smartConflictResolution",
|
||||
"requirePlanApproval",
|
||||
"ntfyEnabled",
|
||||
];
|
||||
|
||||
const NUMBER_SETTINGS: readonly string[] = ["maxConcurrent", "maxWorktrees"];
|
||||
|
||||
const ENUM_SETTINGS: Record<string, readonly string[]> = {
|
||||
worktreeNaming: ["random", "task-id", "task-title"],
|
||||
};
|
||||
|
||||
const STRING_SETTINGS: readonly string[] = ["taskPrefix", "ntfyTopic", "defaultModel"];
|
||||
|
||||
// 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 getStore(): Promise<TaskStore> {
|
||||
const store = new TaskStore(process.cwd());
|
||||
await store.init();
|
||||
return store;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)) {
|
||||
// Allow empty string to clear the value (will be stored as empty string, merged with default)
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// Fallback for any other settings - treat as string
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a setting value for display
|
||||
*/
|
||||
function formatSettingValue(
|
||||
key: keyof Settings,
|
||||
value: unknown,
|
||||
settings: Settings
|
||||
): string {
|
||||
// Special case for githubTokenConfigured - show as indicator
|
||||
if (key === "githubTokenConfigured") {
|
||||
return value ? "(configured)" : "(not configured)";
|
||||
}
|
||||
|
||||
// Handle arrays
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0 ? `[${value.join(", ")}]` : "[]";
|
||||
}
|
||||
|
||||
// Handle undefined
|
||||
if (value === undefined) {
|
||||
return "(not set)";
|
||||
}
|
||||
|
||||
// Handle booleans
|
||||
if (typeof value === "boolean") {
|
||||
return value ? "true" : "false";
|
||||
}
|
||||
|
||||
// Handle numbers
|
||||
if (typeof value === "number") {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
// Handle strings
|
||||
if (typeof value === "string") {
|
||||
// Check if this is the same as default
|
||||
const defaultValue = 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: string): string {
|
||||
// Special cases
|
||||
if (key === "ntfyEnabled") return "ntfy Enabled";
|
||||
if (key === "ntfyTopic") return "ntfy Topic";
|
||||
|
||||
// Convert camelCase to space-separated words with capital first letters
|
||||
return key
|
||||
.replace(/([A-Z])/g, " $1")
|
||||
.replace(/^./, (str) => str.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Run settings show command - displays all settings
|
||||
*/
|
||||
export async function runSettingsShow(): Promise<void> {
|
||||
const store = await getStore();
|
||||
const settings = await store.getSettings();
|
||||
|
||||
console.log();
|
||||
console.log(" kb Configuration Settings");
|
||||
console.log(" " + "─".repeat(50));
|
||||
|
||||
// Define the order and grouping of settings for display
|
||||
const settingGroups = [
|
||||
{
|
||||
title: "Engine",
|
||||
keys: ["maxConcurrent", "maxWorktrees", "autoResolveConflicts", "smartConflictResolution"],
|
||||
},
|
||||
{
|
||||
title: "Worktrees",
|
||||
keys: ["worktreeNaming", "recycleWorktrees"],
|
||||
},
|
||||
{
|
||||
title: "Tasks",
|
||||
keys: ["taskPrefix", "requirePlanApproval", "includeTaskIdInCommit"],
|
||||
},
|
||||
{
|
||||
title: "Notifications",
|
||||
keys: ["ntfyEnabled", "ntfyTopic"],
|
||||
},
|
||||
{
|
||||
title: "GitHub",
|
||||
keys: ["githubTokenConfigured"],
|
||||
},
|
||||
{
|
||||
title: "AI Model",
|
||||
keys: ["defaultProvider", "defaultModelId", "defaultThinkingLevel"],
|
||||
},
|
||||
];
|
||||
|
||||
for (const group of settingGroups) {
|
||||
// Check if any setting in this group has a value
|
||||
const hasValues = group.keys.some((key) => settings[key as keyof Settings] !== undefined);
|
||||
if (!hasValues) continue;
|
||||
|
||||
console.log();
|
||||
console.log(` ${group.title}:`);
|
||||
|
||||
for (const key of group.keys) {
|
||||
const value = settings[key as keyof Settings];
|
||||
const label = getSettingLabel(key);
|
||||
const formattedValue = formatSettingValue(key as keyof Settings, value, settings);
|
||||
console.log(` ${label.padEnd(25)} ${formattedValue}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run settings set command - updates a single setting
|
||||
*/
|
||||
export async function runSettingsSet(key: string, value: string): Promise<void> {
|
||||
// Validate the setting key is allowed
|
||||
if (!VALID_SETTINGS.includes(key as ValidSettingKey)) {
|
||||
console.error(`Error: Unknown setting "${key}"`);
|
||||
console.error(`Valid settings: ${VALID_SETTINGS.join(", ")}`);
|
||||
process.exit(1);
|
||||
return; // Required for tests where process.exit is mocked
|
||||
}
|
||||
|
||||
const store = await getStore();
|
||||
|
||||
try {
|
||||
const parsedValue = parseValue(key as ValidSettingKey, value);
|
||||
|
||||
// Special handling for defaultModel - splits into provider and modelId
|
||||
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; // Required for tests where process.exit is mocked
|
||||
}
|
||||
const [provider, modelId] = parts;
|
||||
await store.updateSettings({ defaultProvider: provider, defaultModelId: modelId });
|
||||
console.log();
|
||||
console.log(` ✓ Updated default model to ${provider}/${modelId}`);
|
||||
console.log();
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal single-setting update
|
||||
const patch: Partial<Settings> = { [key]: parsedValue };
|
||||
await store.updateSettings(patch);
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ Updated ${getSettingLabel(key)} to ${formatSettingValue(key as keyof Settings, parsedValue, await store.getSettings())}`);
|
||||
console.log();
|
||||
} catch (err: any) {
|
||||
console.error(`Error: ${err.message}`);
|
||||
process.exit(1);
|
||||
return; // Required for tests where process.exit is mocked
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user