Skip to content

Commit f896b4c

Browse files
that-github-userunknownclaude
authored
Add --output-format diff to output just the recommended agent's diff (#149)
Enables piping: thinktank run "fix bug" --output-format diff | git apply Or saving: thinktank run "fix bug" --output-format diff > fix.patch Closes #138 Co-authored-by: unknown <that-github-user@github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e059b30 commit f896b4c

6 files changed

Lines changed: 87 additions & 26 deletions

File tree

src/cli.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,37 @@ describe("CLI model validation", () => {
4242
assert.equal(isKnownModel("unknown"), false);
4343
});
4444
});
45+
46+
describe("CLI --no-timeout flag", () => {
47+
function parseTimeout(optsTimeout: string | false): number {
48+
// Mirrors cli.ts logic: --no-timeout sets opts.timeout to false
49+
return optsTimeout === false ? 0 : parseInt(optsTimeout as string, 10);
50+
}
51+
52+
function validateTimeout(optsTimeout: string | false): string | null {
53+
const timeout = parseTimeout(optsTimeout);
54+
if (optsTimeout !== false && (Number.isNaN(timeout) || timeout < 10 || timeout > 1800)) {
55+
return "Error: --timeout must be a number between 10 and 1800 seconds";
56+
}
57+
return null;
58+
}
59+
60+
it("--no-timeout sets timeout to 0", () => {
61+
assert.equal(parseTimeout(false), 0);
62+
});
63+
64+
it("--no-timeout passes validation", () => {
65+
assert.equal(validateTimeout(false), null);
66+
});
67+
68+
it("normal timeout values still validate", () => {
69+
assert.equal(validateTimeout("300"), null);
70+
assert.equal(parseTimeout("300"), 300);
71+
});
72+
73+
it("invalid timeout values are rejected", () => {
74+
assert.notEqual(validateTimeout("5"), null);
75+
assert.notEqual(validateTimeout("abc"), null);
76+
assert.notEqual(validateTimeout("9999"), null);
77+
});
78+
});

src/cli.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ program
3737
String(cfg.testTimeout),
3838
)
3939
.option("--timeout <seconds>", "Timeout per agent in seconds", String(cfg.timeout))
40+
.option("--no-timeout", "Disable agent timeout entirely")
4041
.option("--model <model>", "Claude model to use", cfg.model)
4142
.option("-r, --runner <name>", "AI coding tool to use", cfg.runner)
4243
.option(
@@ -46,7 +47,7 @@ program
4647
)
4748
.option("--scoring <method>", "Scoring method: copeland (default) or weighted", "copeland")
4849
.option("--no-color", "Disable colored output")
49-
.option("--output-format <format>", "Output format: text (default) or json", "text")
50+
.option("--output-format <format>", "Output format: text (default), json, or diff", "text")
5051
.option("--verbose", "Show detailed output from each agent")
5152
.option("--whitespace-insensitive", "Ignore whitespace differences in convergence comparison")
5253
.option("--retry", "Re-run only failed/timed-out agents from the last run")
@@ -57,8 +58,9 @@ program
5758
process.exit(1);
5859
}
5960

60-
const timeout = parseInt(opts.timeout, 10);
61-
if (Number.isNaN(timeout) || timeout < 10 || timeout > 1800) {
61+
// --no-timeout: commander sets opts.timeout to false
62+
const timeout = opts.timeout === false ? 0 : parseInt(opts.timeout, 10);
63+
if (opts.timeout !== false && (Number.isNaN(timeout) || timeout < 10 || timeout > 1800)) {
6264
console.error("Error: --timeout must be a number between 10 and 1800 seconds");
6365
process.exit(1);
6466
}
@@ -80,7 +82,7 @@ program
8082
process.env.NO_COLOR = "1";
8183
}
8284

83-
const validFormats = ["text", "json"];
85+
const validFormats = ["text", "json", "diff"];
8486
if (!validFormats.includes(opts.outputFormat)) {
8587
console.error(`Error: --output-format must be one of: ${validFormats.join(", ")}`);
8688
process.exit(1);

src/commands/run.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,9 @@ export async function retry(opts: RunOptions): Promise<void> {
306306
// Display results
307307
if (opts.outputFormat === "json") {
308308
console.log(JSON.stringify(result));
309+
} else if (opts.outputFormat === "diff") {
310+
const recAgent = result.agents.find((a) => a.id === result.recommended);
311+
if (recAgent?.diff) process.stdout.write(recAgent.diff);
309312
} else {
310313
displayResults(result);
311314
displayApplyInstructions(result);
@@ -506,6 +509,9 @@ export async function run(opts: RunOptions): Promise<void> {
506509
// Display results
507510
if (opts.outputFormat === "json") {
508511
console.log(JSON.stringify(result));
512+
} else if (opts.outputFormat === "diff") {
513+
const recAgent = result.agents.find((a) => a.id === result.recommended);
514+
if (recAgent?.diff) process.stdout.write(recAgent.diff);
509515
} else {
510516
displayResults(result);
511517
displayApplyInstructions(result);

src/runners/claude-code.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,20 @@ describe("claude-code runner", () => {
3030
});
3131
}
3232
});
33+
34+
describe("timeout=0 skips timer", () => {
35+
it("does not call setTimeout when timeout is 0", () => {
36+
// Mirrors the runner logic: timer is only created when timeout > 0
37+
const timeout = 0;
38+
const timer = timeout > 0 ? setTimeout(() => {}, timeout * 1000) : null;
39+
assert.equal(timer, null);
40+
});
41+
42+
it("creates timer when timeout is positive", () => {
43+
const timeout = 300;
44+
const timer = timeout > 0 ? setTimeout(() => {}, timeout * 1000) : null;
45+
assert.notEqual(timer, null);
46+
if (timer) clearTimeout(timer);
47+
});
48+
});
3349
});

src/runners/claude-code.ts

Lines changed: 24 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -82,28 +82,31 @@ export const claudeCodeRunner: Runner = {
8282
error += data.toString();
8383
});
8484

85-
const timer = setTimeout(() => {
86-
if (!settled) {
87-
settled = true;
88-
child.kill("SIGTERM");
89-
resolve({
90-
id,
91-
worktree: opts.worktreePath,
92-
status: "timeout",
93-
exitCode: -1,
94-
duration: Date.now() - start,
95-
output,
96-
error: `Timed out after ${opts.timeout}s`,
97-
diff: "",
98-
filesChanged: [],
99-
linesAdded: 0,
100-
linesRemoved: 0,
101-
});
102-
}
103-
}, opts.timeout * 1000);
85+
const timer =
86+
opts.timeout > 0
87+
? setTimeout(() => {
88+
if (!settled) {
89+
settled = true;
90+
child.kill("SIGTERM");
91+
resolve({
92+
id,
93+
worktree: opts.worktreePath,
94+
status: "timeout",
95+
exitCode: -1,
96+
duration: Date.now() - start,
97+
output,
98+
error: `Timed out after ${opts.timeout}s`,
99+
diff: "",
100+
filesChanged: [],
101+
linesAdded: 0,
102+
linesRemoved: 0,
103+
});
104+
}
105+
}, opts.timeout * 1000)
106+
: null;
104107

105108
child.on("close", async (code) => {
106-
clearTimeout(timer);
109+
if (timer) clearTimeout(timer);
107110
if (settled) return;
108111
settled = true;
109112

@@ -136,7 +139,7 @@ export const claudeCodeRunner: Runner = {
136139
});
137140

138141
child.on("error", (err) => {
139-
clearTimeout(timer);
142+
if (timer) clearTimeout(timer);
140143
if (settled) return;
141144
settled = true;
142145

src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export interface RunOptions {
99
verbose: boolean;
1010
runner?: string;
1111
scoring: "weighted" | "copeland";
12-
outputFormat: "text" | "json";
12+
outputFormat: "text" | "json" | "diff";
1313
retry?: boolean;
1414
whitespaceInsensitive?: boolean;
1515
}

0 commit comments

Comments
 (0)