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 875c758110
commit 1b532091ee
4 changed files with 726 additions and 0 deletions

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