Skip to content

Commit 13002b8

Browse files
committed
change error handling to pass non-zero exit code
- also stop passing back message that was redundant and useless - and make error logic slightly more consistent (albeit still sloppy)
1 parent 716664e commit 13002b8

File tree

7 files changed

+149
-83
lines changed

7 files changed

+149
-83
lines changed

TODO.md

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,5 @@
11
## TODOs
22

3-
- Rename `cwd` to increase usage by Claude and other models?
4-
- `workdir`, `workDir`, `workingDirectory` ??
5-
- Explicit return code on errors only (non-zero)
6-
- do not show when zero
7-
- separate content item?
8-
"Exit code: -1"
9-
or {"name": "exit code", text: "-1" }?
10-
- add `timeout` optional param
11-
12-
133
- Every time Claude runs a python script, `python` is used as the interpreter. Which fails every time.
144
- Thankfully, Claude retries with `python3` and uses that for the rest of the chat.
155
- Hence the idea to have some memory concept across chats! Very selective memory and very minimal.

src/exec-utils.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,26 @@ import { exec, ExecOptions } from "child_process";
22
import { ObjectEncodingOptions } from "fs";
33

44
type ExecResult = {
5-
// FYI leave this type for now as a declaration of the expected shape of the result for BOTH success and failure (errors)
6-
// do not switch to using ExecException b/c that only applies to failures
5+
// this is basically ExecException except I want my own type for it...
6+
// b/c I want this to represent all results
7+
// ... by the way throws put stdout/stderr on the error "result" object
8+
// hence I am replicating that here and in my promise reject calls
79
stdout: string;
810
stderr: string;
911

10-
// TODO dear GOD... wes why the F did you call this message? error_message would've been better (at a minimum)
11-
// message is the error message from the child process, not sure I like this naming
12-
// - perhaps worth pushing the error logic out of messagesFor back into catch block above
13-
message?: string;
12+
// ONLY on errors:
13+
message?: string; // FYI redundant b/c message ~= `Command failed: ${cmd}\n${stderr}\n`
14+
code?: number;
15+
killed?: boolean;
16+
signal?: NodeJS.Signals | undefined;
17+
cmd?: string; // FYI redundant
1418
};
1519

1620
/**
1721
* Executes a file with the given arguments, piping input to stdin.
1822
* @param {string} interpreter - The file to execute.
1923
* @param {string} stdin - The string to pipe to stdin.
20-
* @returns {Promise<ExecResult>} A promise that resolves with the stdout and stderr of the command. `message` is provided on a failure to explain the error.
24+
* @returns {Promise<ExecResult>}
2125
*/
2226
function execFileWithInput(
2327
interpreter: string,
@@ -35,8 +39,13 @@ function execFileWithInput(
3539
return new Promise((resolve, reject) => {
3640
const child = exec(interpreter, options, (error, stdout, stderr) => {
3741
if (error) {
38-
reject({ message: error.message, stdout, stderr });
42+
// console.log("execFileWithInput ERROR:", error);
43+
// mirror ExecException used by throws
44+
error.stdout = stdout;
45+
error.stderr = stderr;
46+
reject(error);
3947
} else {
48+
// I assume RC==0 else would trigger error?
4049
resolve({ stdout, stderr });
4150
}
4251
});
@@ -68,7 +77,11 @@ async function fishWorkaround(
6877
exec(command, options, (error, stdout, stderr) => {
6978
// I like this style of error vs success handling! it's beautiful-est (prommises are underrated)
7079
if (error) {
71-
reject({ message: error.message, stdout, stderr });
80+
// console.log("fishWorkaround ERROR:", error);
81+
// mirror ExecException used by throws
82+
error.stdout = stdout;
83+
error.stderr = stderr;
84+
reject(error);
7285
} else {
7386
resolve({ stdout, stderr });
7487
}

src/messages.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,40 @@ import { TextContent } from "@modelcontextprotocol/sdk/types.js";
55
* Converts an ExecResult into an array of TextContent messages.
66
*/
77
export function messagesFor(result: ExecResult): TextContent[] {
8-
// TODO! RETURN CODE!!! add as RETURN_CODE and number type
98
const messages: TextContent[] = [];
10-
if (result.message) {
9+
10+
if (result.code !== undefined) {
1111
messages.push({
1212
type: "text",
13-
text: result.message,
14-
name: "ERROR",
13+
text: `${result.code}`,
14+
name: "EXIT_CODE",
1515
});
1616
}
17+
18+
// PRN any situation where I want to pass .message and/or .cmd?
19+
// maybe on errors I should? that way there's a chance to make sure the command was as intended
20+
// and maybe include message when it doesn't contain stderr?
21+
// FYI if I put these back, start with tests first
22+
23+
// PRN use a test to add these, sleep 10s maybe and then kill that process?
24+
// definitely could be useful to know if a command was killed
25+
// make sure signal is not null, which is what's used when no signal killed the process
26+
// if (result.signal) {
27+
// messages.push({
28+
// type: "text",
29+
// text: `Signal: ${result.signal}`,
30+
// name: "SIGNAL",
31+
// });
32+
// }
33+
// if (!!result.killed) {
34+
// // killed == true is the only time to include this
35+
// messages.push({
36+
// type: "text",
37+
// text: "Process was killed",
38+
// name: "KILLED",
39+
// });
40+
// }
41+
1742
if (result.stdout) {
1843
messages.push({
1944
type: "text",

src/run-command.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ import { ObjectEncodingOptions } from "node:fs";
99
const execAsync = promisify(exec);
1010

1111
async function execute(command: string, stdin: string, options: ExecOptions) {
12+
// PRN merge calls to exec into one single paradigm with conditional STDIN handled in one spot?
13+
// right now no STDIN => exec directly and let it throw to catch failures
14+
// w/ STDIN => you manually glue together callbacks + promises (i.e. reject)
15+
// feels sloppy to say the least, notably the error handling with ExecExeption error that has stdin/stderr on it
1216
if (!stdin) {
1317
return await execAsync(command, options);
1418
}
@@ -18,9 +22,9 @@ async function execute(command: string, stdin: string, options: ExecOptions) {
1822
/**
1923
* Executes a command and returns the result as CallToolResult.
2024
*/
21-
export async function runCommand(
22-
args: Record<string, unknown> | undefined
23-
): Promise<CallToolResult> {
25+
export type RunCommandArgs = Record<string, unknown> | undefined;
26+
export async function runCommand(args: RunCommandArgs): Promise<CallToolResult> {
27+
2428
const command = args?.command as string;
2529
if (!command) {
2630
const message = "Command is required, current value: " + command;
@@ -43,6 +47,24 @@ export async function runCommand(
4347
content: messagesFor(result),
4448
};
4549
} catch (error) {
50+
// PRN do I want to differentiate non-command related error (i.e. if messagesFor blows up
51+
// or presumably if smth else goes wrong with the node code in exec that isn't command related
52+
// if so, write a test first
53+
54+
// console.log("ERROR_runCommand", error);
55+
// ExecException (error + stdout/stderr) merged
56+
// - IIUC this happens on uncaught failures
57+
// - but if you catch an exec() promise failure (or use exec's callback) => you get separated values: error, stdout, stderr
58+
// - which is why I mirror this response type in my reject(error) calls
59+
//
60+
// 'error' example:
61+
// code: 127,
62+
// killed: false,
63+
// signal: null,
64+
// cmd: 'nonexistentcommand',
65+
// stdout: '',
66+
// stderr: '/bin/sh: nonexistentcommand: command not found\n'
67+
4668
const response = {
4769
isError: true,
4870
content: messagesFor(error as ExecResult),

tests/integration/exec-utils.test.ts

Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
import { execFileWithInput } from "../../src/exec-utils.js";
22

3-
// FYI Claude generated most of these, by dog fooding the run_command/script tools!
4-
// I am going to keep asking Claude to add new tests to see how I feel about that workflow
3+
// FYI these tests are largely to make sure I understand how exec works
4+
// + my changes to exec (i.e. reject promise on failure in STDIN path)
55

66
describe("execFileWithInput integration tests", () => {
7-
// ok, impressive choice of "seam" to add testing of the most critical part, executing the command!
8-
// this is EXACTLY what I had in mind and didn't even tell Claude I wanted.
97

108
test("should execute a simple bash command", async () => {
119
const result = await execFileWithInput(
@@ -16,6 +14,7 @@ describe("execFileWithInput integration tests", () => {
1614
// console.log(result);
1715
expect(result.stdout).toBe("Hello World\n");
1816
expect(result.stderr).toBe("");
17+
expect(result.code).toBeUndefined();
1918
});
2019

2120
test("should handle command errors properly in bash", async () => {
@@ -25,8 +24,11 @@ describe("execFileWithInput integration tests", () => {
2524
} catch (result: any) {
2625
// FYI catch is so you can run assertions on the failed result, given the promise is rejected, it's then thrown here
2726
// console.log(result);
28-
expect(result.stderr).toContain("bash: line 1: nonexistentcommand: command not found");
29-
expect(result.message).toContain("Command failed: bash\nbash: line 1: nonexistentcommand: command not found\n");
27+
const expected_stderr = "bash: line 1: nonexistentcommand: command not found";
28+
expect(result.stderr).toContain(expected_stderr);
29+
const expected_message = "Command failed: bash\n" + expected_stderr + "\n";
30+
expect(result.message).toContain(expected_message);
31+
expect(result.code).toBe(127);
3032
}
3133
});
3234

@@ -39,6 +41,7 @@ describe("execFileWithInput integration tests", () => {
3941
// console.log(result);
4042
expect(result.stdout).toBe("Hello from Fish\n");
4143
expect(result.stderr).toBe("");
44+
expect(result.code).toBeUndefined();
4245
});
4346

4447
// TODO make sure to cover the fish workaround logic, in all its edge cases and then can leave those tests when I remove that or just nuke them
@@ -48,8 +51,17 @@ describe("execFileWithInput integration tests", () => {
4851
fail("Should have thrown an error");
4952
} catch (result: any) {
5053
// console.log(result);
51-
expect(result.stderr).toContain("fish: Unknown command: totallynonexistentcommand\nfish: \ntotallynonexistentcommand\n^~~~~~~~~~~~~~~~~~~~~~~~^");
52-
expect(result.message).toBeTruthy();
54+
55+
const expected_stderr = "fish: Unknown command: totallynonexistentcommand\nfish: \ntotallynonexistentcommand\n^~~~~~~~~~~~~~~~~~~~~~~~^";
56+
expect(result.stderr).toContain(expected_stderr);
57+
// TODO! this is why I don't think I should return error.message... or at least not in many cases
58+
// OR strip off the stderr overlap?
59+
const expected_message = 'Command failed: fish -c "echo dG90YWxseW5vbmV4aXN0ZW50Y29tbWFuZA== | base64 -d | fish"' +
60+
"\n" + expected_stderr;
61+
expect(result.message).toContain(expected_message);
62+
expect(result.code).toBe(127);
63+
expect(result.killed).toBe(false);
64+
expect(result.signal).toBeNull();
5365
}
5466
});
5567

@@ -62,6 +74,7 @@ describe("execFileWithInput integration tests", () => {
6274
// console.log(result);
6375
expect(result.stdout).toBe("Hello from Zsh\n");
6476
expect(result.stderr).toBe("");
77+
expect(result.code).toBeUndefined();
6578
});
6679

6780
test("should handle command errors properly in zsh", async () => {
@@ -70,10 +83,13 @@ describe("execFileWithInput integration tests", () => {
7083
fail("Should have thrown an error");
7184
} catch (result: any) {
7285
// console.log(result);
73-
// TODO why am I not reporting the exit code?! ==> 127 here (and in other cases above for missing command)
74-
expect(result.stderr).toContain("zsh: command not found: completelynonexistentcommand");
75-
// TODO why am I bothering to return message... it seems to just duplicate STDERR?!
76-
expect(result.message).toBeTruthy();
86+
const expected_stderr = "zsh: command not found: completelynonexistentcommand";
87+
expect(result.stderr).toContain(expected_stderr);
88+
const expected_message = "Command failed: zsh\n" + expected_stderr + "\n";
89+
expect(result.message).toBe(expected_message);
90+
expect(result.code).toBe(127);
91+
expect(result.killed).toBe(false);
92+
expect(result.signal).toBeNull();
7793
}
7894
});
7995

@@ -85,15 +101,14 @@ describe("execFileWithInput integration tests", () => {
85101
done
86102
`;
87103
const result = await execFileWithInput("zsh", stdin, {});
88-
//expect(lines[0]).toContain('Line 1 from Zsh');
89-
//expect(lines[1]).toContain('Number 1');
90-
//expect(lines[2]).toContain('Number 2');
91-
//expect(lines[3]).toContain('Number 3');
104+
// console.log(result);
92105
expect(result.stdout).toContain(`Line 1 from Zsh
93106
Number 1
94107
Number 2
95108
Number 3
96109
`);
110+
expect(result.stderr).toBe("");
111+
expect(result.code).toBeUndefined();
97112
});
98113

99114
test("should respect working directory option", async () => {
@@ -102,7 +117,10 @@ Number 3
102117
// TODO make sure cwd is not already / in the test?
103118
// PRN use multiple paths would be another way around checking cwd of test runner
104119
const result = await execFileWithInput("bash", "pwd", { cwd: "/" });
105-
expect(result.stdout.trim()).toBe("/");
120+
// console.log(result);
121+
expect(result.stdout).toBe("/\n");
122+
expect(result.stderr).toBe("");
123+
expect(result.code).toBeUndefined();
106124
});
107125

108126
test("should handle bash multiline scripts", async () => {
@@ -113,9 +131,12 @@ Number 3
113131
`;
114132
const result = await execFileWithInput("bash", stdin, {});
115133
// validate all of output:
134+
// console.log(result);
116135
expect(result.stdout).toContain(`Line 1
117136
Line 2
118137
Line 3`);
138+
expect(result.stderr).toBe("");
139+
expect(result.code).toBeUndefined();
119140
});
120141
});
121142

tests/integration/helpers.ts

Lines changed: 0 additions & 26 deletions
This file was deleted.

0 commit comments

Comments
 (0)