From ccf65416abc7379bac25ab2a42e7d82ed4bc32b9 Mon Sep 17 00:00:00 2001 From: unknown Date: Sat, 28 Mar 2026 13:56:15 -0700 Subject: [PATCH] Add configurable defaults via .thinktank/config.json and config command - Config file at .thinktank/config.json with defaults for all flags - CLI flags override config which overrides built-in defaults - thinktank config set/get/list subcommands for managing defaults - 26 new tests for config loading, merging, and CLI integration Generated by thinktank Opus (5 agents, 2 pass, Agent #3 recommended). Closes #81 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli.ts | 49 ++++++++-- src/commands/config.test.ts | 80 ++++++++++++++++ src/commands/config.ts | 59 ++++++++++++ src/utils/config.test.ts | 182 ++++++++++++++++++++++++++++++++++++ src/utils/config.ts | 145 ++++++++++++++++++++++++++++ 5 files changed, 509 insertions(+), 6 deletions(-) create mode 100644 src/commands/config.test.ts create mode 100644 src/commands/config.ts create mode 100644 src/utils/config.test.ts create mode 100644 src/utils/config.ts diff --git a/src/cli.ts b/src/cli.ts index 02be2b6..adad376 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,9 +4,11 @@ import { Command } from "commander"; import { apply } from "./commands/apply.js"; import { clean } from "./commands/clean.js"; import { compare } from "./commands/compare.js"; +import { type ConfigAction, config } from "./commands/config.js"; import { list } from "./commands/list.js"; import { run } from "./commands/run.js"; import { stats } from "./commands/stats.js"; +import { loadConfig } from "./utils/config.js"; import { resolvePrompt } from "./utils/prompt.js"; const program = new Command(); @@ -18,18 +20,28 @@ program ) .version("0.1.0"); +const cfg = loadConfig(); + program .command("run") .description("Run a task with N parallel AI coding agents") .argument("[prompt]", "The coding task to perform") - .option("-n, --attempts ", "Number of parallel attempts", "3") + .option("-n, --attempts ", "Number of parallel attempts", String(cfg.attempts)) .option("-f, --file ", "Read prompt from a file (avoids shell expansion issues)") .option("-t, --test-cmd ", "Test command to verify results (e.g., 'npm test')") - .option("--test-timeout ", "Timeout for test command in seconds", "120") - .option("--timeout ", "Timeout per agent in seconds", "300") - .option("--model ", "Claude model to use", "sonnet") - .option("-r, --runner ", "AI coding tool to use (default: claude-code)") - .option("--threshold ", "Convergence clustering similarity threshold (0.0-1.0)", "0.3") + .option( + "--test-timeout ", + "Timeout for test command in seconds", + String(cfg.testTimeout), + ) + .option("--timeout ", "Timeout per agent in seconds", String(cfg.timeout)) + .option("--model ", "Claude model to use", cfg.model) + .option("-r, --runner ", "AI coding tool to use", cfg.runner) + .option( + "--threshold ", + "Convergence clustering similarity threshold (0.0-1.0)", + String(cfg.threshold), + ) .option("--verbose", "Show detailed output from each agent") .action(async (promptArg: string | undefined, opts) => { const prompt = resolvePrompt(promptArg, opts.file); @@ -126,4 +138,29 @@ program await stats(); }); +const configCmd = program + .command("config") + .description("View and update thinktank configuration (.thinktank/config.json)"); + +configCmd + .command("set ") + .description("Set a config value") + .action((key: string, value: string) => { + config("set", key, value); + }); + +configCmd + .command("get ") + .description("Get a config value") + .action((key: string) => { + config("get", key); + }); + +configCmd + .command("list") + .description("List all config values") + .action(() => { + config("list"); + }); + program.parse(); diff --git a/src/commands/config.test.ts b/src/commands/config.test.ts new file mode 100644 index 0000000..d52ebec --- /dev/null +++ b/src/commands/config.test.ts @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, it } from "node:test"; +import { config } from "./config.js"; + +const CONFIG_DIR = ".thinktank"; +const CONFIG_FILE = join(CONFIG_DIR, "config.json"); + +describe("config command", () => { + let originalCwd: string; + let tmpDir: string; + let logs: string[]; + let originalLog: typeof console.log; + + beforeEach(() => { + originalCwd = process.cwd(); + tmpDir = join( + process.env.TEMP || process.env.TMPDIR || "/tmp", + `thinktank-config-cmd-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + mkdirSync(tmpDir, { recursive: true }); + process.chdir(tmpDir); + + logs = []; + originalLog = console.log; + console.log = (...args: unknown[]) => { + logs.push(args.join(" ")); + }; + }); + + afterEach(() => { + console.log = originalLog; + process.chdir(originalCwd); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + describe("set", () => { + it("creates config file and sets value", () => { + config("set", "attempts", "5"); + const raw = readFileSync(CONFIG_FILE, "utf-8"); + const parsed = JSON.parse(raw); + assert.equal(parsed.attempts, 5); + assert.ok(logs.some((l) => l.includes("Set") && l.includes("attempts"))); + }); + }); + + describe("get", () => { + it("shows default value when not configured", () => { + config("get", "attempts"); + assert.ok(logs.some((l) => l.includes("attempts") && l.includes("3"))); + }); + + it("shows configured value after set", () => { + config("set", "attempts", "7"); + logs = []; + config("get", "attempts"); + assert.ok(logs.some((l) => l.includes("attempts") && l.includes("7"))); + }); + }); + + describe("list", () => { + it("shows all config keys with defaults", () => { + config("list"); + assert.ok(logs.some((l) => l.includes("attempts"))); + assert.ok(logs.some((l) => l.includes("model"))); + assert.ok(logs.some((l) => l.includes("timeout"))); + assert.ok(logs.some((l) => l.includes("runner"))); + assert.ok(logs.some((l) => l.includes("threshold"))); + assert.ok(logs.some((l) => l.includes("testTimeout"))); + }); + + it("shows custom values after set", () => { + mkdirSync(CONFIG_DIR, { recursive: true }); + writeFileSync(CONFIG_FILE, JSON.stringify({ attempts: 10 })); + config("list"); + assert.ok(logs.some((l) => l.includes("attempts") && l.includes("10"))); + }); + }); +}); diff --git a/src/commands/config.ts b/src/commands/config.ts new file mode 100644 index 0000000..b9a269b --- /dev/null +++ b/src/commands/config.ts @@ -0,0 +1,59 @@ +import pc from "picocolors"; +import { + BUILT_IN_DEFAULTS, + getConfigValue, + loadConfig, + loadFileConfig, + setConfigValue, +} from "../utils/config.js"; + +export type ConfigAction = "set" | "get" | "list"; + +export function config(action: ConfigAction, key?: string, value?: string): void { + switch (action) { + case "set": { + if (!key || value === undefined) { + console.error("Usage: thinktank config set "); + process.exit(1); + } + const error = setConfigValue(key, value); + if (error) { + console.error(` Error: ${error}`); + process.exit(1); + } + console.log(` ${pc.green("✓")} Set ${pc.bold(key)} = ${pc.cyan(value)}`); + break; + } + + case "get": { + if (!key) { + console.error("Usage: thinktank config get "); + process.exit(1); + } + const result = getConfigValue(key); + if (typeof result === "string") { + console.error(` Error: ${result}`); + process.exit(1); + } + const sourceLabel = result.source === "config" ? pc.green("config") : pc.dim("default"); + console.log(` ${pc.bold(key)} = ${pc.cyan(result.value)} (${sourceLabel})`); + break; + } + + case "list": { + const resolved = loadConfig(); + const fileConfig = loadFileConfig(); + console.log(); + console.log(pc.bold(" thinktank configuration")); + console.log(); + for (const key of Object.keys(BUILT_IN_DEFAULTS) as Array) { + const value = String(resolved[key]); + const isCustom = key in fileConfig; + const sourceLabel = isCustom ? pc.green("config") : pc.dim("default"); + console.log(` ${pc.bold(key.padEnd(14))} ${pc.cyan(value.padEnd(14))} ${sourceLabel}`); + } + console.log(); + break; + } + } +} diff --git a/src/utils/config.test.ts b/src/utils/config.test.ts new file mode 100644 index 0000000..25a4395 --- /dev/null +++ b/src/utils/config.test.ts @@ -0,0 +1,182 @@ +import assert from "node:assert/strict"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, it } from "node:test"; +import { + BUILT_IN_DEFAULTS, + getConfigValue, + isValidConfigKey, + loadConfig, + loadFileConfig, + setConfigValue, +} from "./config.js"; + +const CONFIG_DIR = ".thinktank"; +const CONFIG_FILE = join(CONFIG_DIR, "config.json"); + +describe("config", () => { + let originalCwd: string; + let tmpDir: string; + + beforeEach(() => { + originalCwd = process.cwd(); + tmpDir = join( + process.env.TEMP || process.env.TMPDIR || "/tmp", + `thinktank-config-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + mkdirSync(tmpDir, { recursive: true }); + process.chdir(tmpDir); + }); + + afterEach(() => { + process.chdir(originalCwd); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + describe("BUILT_IN_DEFAULTS", () => { + it("has expected default values", () => { + assert.equal(BUILT_IN_DEFAULTS.attempts, 3); + assert.equal(BUILT_IN_DEFAULTS.model, "sonnet"); + assert.equal(BUILT_IN_DEFAULTS.timeout, 300); + assert.equal(BUILT_IN_DEFAULTS.runner, "claude-code"); + assert.equal(BUILT_IN_DEFAULTS.threshold, 0.3); + assert.equal(BUILT_IN_DEFAULTS.testTimeout, 120); + }); + }); + + describe("isValidConfigKey", () => { + it("returns true for valid keys", () => { + assert.equal(isValidConfigKey("attempts"), true); + assert.equal(isValidConfigKey("model"), true); + assert.equal(isValidConfigKey("timeout"), true); + assert.equal(isValidConfigKey("runner"), true); + assert.equal(isValidConfigKey("threshold"), true); + assert.equal(isValidConfigKey("testTimeout"), true); + }); + + it("returns false for invalid keys", () => { + assert.equal(isValidConfigKey("invalid"), false); + assert.equal(isValidConfigKey(""), false); + assert.equal(isValidConfigKey("ATTEMPTS"), false); + }); + }); + + describe("loadFileConfig", () => { + it("returns empty object when no config file exists", () => { + const result = loadFileConfig(); + assert.deepEqual(result, {}); + }); + + it("loads config from .thinktank/config.json", () => { + mkdirSync(CONFIG_DIR, { recursive: true }); + writeFileSync(CONFIG_FILE, JSON.stringify({ attempts: 5, model: "opus" })); + const result = loadFileConfig(); + assert.equal(result.attempts, 5); + assert.equal(result.model, "opus"); + }); + + it("ignores unknown keys in config file", () => { + mkdirSync(CONFIG_DIR, { recursive: true }); + writeFileSync(CONFIG_FILE, JSON.stringify({ attempts: 5, unknownKey: "value" })); + const result = loadFileConfig(); + assert.equal(result.attempts, 5); + assert.equal((result as Record).unknownKey, undefined); + }); + + it("returns empty object for invalid JSON", () => { + mkdirSync(CONFIG_DIR, { recursive: true }); + writeFileSync(CONFIG_FILE, "not json{{{"); + const result = loadFileConfig(); + assert.deepEqual(result, {}); + }); + }); + + describe("loadConfig", () => { + it("returns built-in defaults when no config file exists", () => { + const result = loadConfig(); + assert.deepEqual(result, BUILT_IN_DEFAULTS); + }); + + it("merges file config with defaults", () => { + mkdirSync(CONFIG_DIR, { recursive: true }); + writeFileSync(CONFIG_FILE, JSON.stringify({ attempts: 7 })); + const result = loadConfig(); + assert.equal(result.attempts, 7); + assert.equal(result.model, "sonnet"); + assert.equal(result.timeout, 300); + }); + }); + + describe("setConfigValue", () => { + it("sets a numeric value", () => { + const error = setConfigValue("attempts", "5"); + assert.equal(error, null); + const config = loadFileConfig(); + assert.equal(config.attempts, 5); + }); + + it("sets a string value", () => { + const error = setConfigValue("model", "opus"); + assert.equal(error, null); + const config = loadFileConfig(); + assert.equal(config.model, "opus"); + }); + + it("preserves existing values when setting new ones", () => { + setConfigValue("attempts", "5"); + setConfigValue("model", "opus"); + const config = loadFileConfig(); + assert.equal(config.attempts, 5); + assert.equal(config.model, "opus"); + }); + + it("returns error for unknown key", () => { + const error = setConfigValue("bogus", "123"); + assert.ok(error); + assert.match(error, /Unknown config key/); + }); + + it("returns error for non-numeric value on numeric key", () => { + const error = setConfigValue("attempts", "abc"); + assert.ok(error); + assert.match(error, /must be a number/); + }); + + it("returns error for out-of-range attempts", () => { + const error = setConfigValue("attempts", "25"); + assert.ok(error); + assert.match(error, /attempts must be an integer between 1 and 20/); + }); + + it("returns error for out-of-range timeout", () => { + const error = setConfigValue("timeout", "5"); + assert.ok(error); + assert.match(error, /timeout must be an integer between 10 and 600/); + }); + + it("returns error for out-of-range threshold", () => { + const error = setConfigValue("threshold", "1.5"); + assert.ok(error); + assert.match(error, /threshold must be a number between 0.0 and 1.0/); + }); + }); + + describe("getConfigValue", () => { + it("returns default value when no config file exists", () => { + const result = getConfigValue("attempts"); + assert.deepEqual(result, { value: "3", source: "default" }); + }); + + it("returns config value when set", () => { + setConfigValue("attempts", "7"); + const result = getConfigValue("attempts"); + assert.deepEqual(result, { value: "7", source: "config" }); + }); + + it("returns error for unknown key", () => { + const result = getConfigValue("bogus"); + assert.equal(typeof result, "string"); + assert.match(result as string, /Unknown config key/); + }); + }); +}); diff --git a/src/utils/config.ts b/src/utils/config.ts new file mode 100644 index 0000000..2a790bc --- /dev/null +++ b/src/utils/config.ts @@ -0,0 +1,145 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +export interface Config { + attempts: number; + model: string; + timeout: number; + runner: string; + threshold: number; + testTimeout: number; +} + +export const BUILT_IN_DEFAULTS: Config = { + attempts: 3, + model: "sonnet", + timeout: 300, + runner: "claude-code", + threshold: 0.3, + testTimeout: 120, +}; + +const CONFIG_DIR = ".thinktank"; +const CONFIG_FILE = "config.json"; + +function configPath(): string { + return join(CONFIG_DIR, CONFIG_FILE); +} + +export function isValidConfigKey(key: string): key is keyof Config { + return key in BUILT_IN_DEFAULTS; +} + +function parseValue(key: keyof Config, raw: string): Config[keyof Config] { + const defaults = BUILT_IN_DEFAULTS; + if (typeof defaults[key] === "number") { + const num = Number(raw); + if (Number.isNaN(num)) { + throw new Error(`Value for "${key}" must be a number, got "${raw}"`); + } + return num; + } + return raw; +} + +function validateConfig(partial: Partial): string | null { + if (partial.attempts !== undefined) { + if (!Number.isInteger(partial.attempts) || partial.attempts < 1 || partial.attempts > 20) { + return "attempts must be an integer between 1 and 20"; + } + } + if (partial.timeout !== undefined) { + if (!Number.isInteger(partial.timeout) || partial.timeout < 10 || partial.timeout > 600) { + return "timeout must be an integer between 10 and 600"; + } + } + if (partial.testTimeout !== undefined) { + if ( + !Number.isInteger(partial.testTimeout) || + partial.testTimeout < 10 || + partial.testTimeout > 600 + ) { + return "testTimeout must be an integer between 10 and 600"; + } + } + if (partial.threshold !== undefined) { + if (typeof partial.threshold !== "number" || partial.threshold < 0 || partial.threshold > 1) { + return "threshold must be a number between 0.0 and 1.0"; + } + } + if (partial.model !== undefined) { + if (typeof partial.model !== "string" || partial.model.length === 0) { + return "model must be a non-empty string"; + } + } + if (partial.runner !== undefined) { + if (typeof partial.runner !== "string" || partial.runner.length === 0) { + return "runner must be a non-empty string"; + } + } + return null; +} + +export function loadFileConfig(): Partial { + try { + const raw = readFileSync(configPath(), "utf-8"); + const parsed = JSON.parse(raw) as Record; + const result: Partial = {}; + for (const key of Object.keys(BUILT_IN_DEFAULTS) as Array) { + if (key in parsed) { + (result as Record)[key] = parsed[key]; + } + } + return result; + } catch { + return {}; + } +} + +export function loadConfig(): Config { + const fileConfig = loadFileConfig(); + return { ...BUILT_IN_DEFAULTS, ...fileConfig }; +} + +export function saveFileConfig(partial: Partial): void { + mkdirSync(CONFIG_DIR, { recursive: true }); + writeFileSync(configPath(), JSON.stringify(partial, null, 2) + "\n", { mode: 0o600 }); +} + +export function setConfigValue(key: string, rawValue: string): string | null { + if (!isValidConfigKey(key)) { + return `Unknown config key "${key}". Valid keys: ${Object.keys(BUILT_IN_DEFAULTS).join(", ")}`; + } + + let value: Config[keyof Config]; + try { + value = parseValue(key, rawValue); + } catch (e) { + return (e as Error).message; + } + const partial: Partial = { [key]: value }; + + const error = validateConfig(partial); + if (error) { + return error; + } + + const existing = loadFileConfig(); + const merged = { ...existing, ...partial }; + saveFileConfig(merged); + return null; +} + +export function getConfigValue( + key: string, +): { value: string; source: "config" | "default" } | string { + if (!isValidConfigKey(key)) { + return `Unknown config key "${key}". Valid keys: ${Object.keys(BUILT_IN_DEFAULTS).join(", ")}`; + } + + const fileConfig = loadFileConfig(); + if (key in fileConfig) { + return { value: String(fileConfig[key]), source: "config" }; + } + return { value: String(BUILT_IN_DEFAULTS[key]), source: "default" }; +}