Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 43 additions & 6 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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>", "Number of parallel attempts", "3")
.option("-n, --attempts <number>", "Number of parallel attempts", String(cfg.attempts))
.option("-f, --file <path>", "Read prompt from a file (avoids shell expansion issues)")
.option("-t, --test-cmd <command>", "Test command to verify results (e.g., 'npm test')")
.option("--test-timeout <seconds>", "Timeout for test command in seconds", "120")
.option("--timeout <seconds>", "Timeout per agent in seconds", "300")
.option("--model <model>", "Claude model to use", "sonnet")
.option("-r, --runner <name>", "AI coding tool to use (default: claude-code)")
.option("--threshold <number>", "Convergence clustering similarity threshold (0.0-1.0)", "0.3")
.option(
"--test-timeout <seconds>",
"Timeout for test command in seconds",
String(cfg.testTimeout),
)
.option("--timeout <seconds>", "Timeout per agent in seconds", String(cfg.timeout))
.option("--model <model>", "Claude model to use", cfg.model)
.option("-r, --runner <name>", "AI coding tool to use", cfg.runner)
.option(
"--threshold <number>",
"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);
Expand Down Expand Up @@ -126,4 +138,29 @@ program
await stats();
});

const configCmd = program
.command("config")
.description("View and update thinktank configuration (.thinktank/config.json)");

configCmd
.command("set <key> <value>")
.description("Set a config value")
.action((key: string, value: string) => {
config("set", key, value);
});

configCmd
.command("get <key>")
.description("Get a config value")
.action((key: string) => {
config("get", key);
});

configCmd
.command("list")
.description("List all config values")
.action(() => {
config("list");
});

program.parse();
80 changes: 80 additions & 0 deletions src/commands/config.test.ts
Original file line number Diff line number Diff line change
@@ -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")));
});
});
});
59 changes: 59 additions & 0 deletions src/commands/config.ts
Original file line number Diff line number Diff line change
@@ -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 <key> <value>");
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 <key>");
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<keyof typeof BUILT_IN_DEFAULTS>) {
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;
}
}
}
Loading
Loading