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:
gsxdsm
2026-03-30 17:35:03 -07:00
parent bbbb668044
commit 0815785e02
4 changed files with 726 additions and 0 deletions

View File

@@ -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);

View 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);
});
});

View 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
}
}