Skip to content
Open
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
14 changes: 7 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
},
"devDependencies": {
"@commander-js/extra-typings": "^11.0.0",
"@types/node": "^20.5.9",
"@types/node": "^20.6.0",
"typescript": "^5.2.2"
}
}
7 changes: 4 additions & 3 deletions src/commands/do.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
getRelevantExecutables,
shellCommandFromToolCall,
} from "../llm";
import { runCommand } from "../runCommand";
import { PersistentShell } from "../runCommand";
import {
logAssistant,
logCommand,
Expand All @@ -32,6 +32,7 @@ export const doCommand = new Command("do")
)
.option("--path", "Get a list of executables available on PATH")
.action(async (instruction, opts) => {
const shell = new PersistentShell();
const messages: ChatMessage[] = [];

if (!instruction) {
Expand Down Expand Up @@ -59,7 +60,7 @@ export const doCommand = new Command("do")
console.log("Getting environment information...");
const GET_PATH_EXECUTABLES_COMMAND =
"echo $PATH | tr ':' '\n' | xargs -I {} ls {} 2>/dev/null | sort -u | tr '\n' ','";
const pathExecutables = await runCommand({
const pathExecutables = await shell.executeCommand({
command: GET_PATH_EXECUTABLES_COMMAND,
args: [],
explanation: "Retrieving all executables in your PATH",
Expand Down Expand Up @@ -143,7 +144,7 @@ export const doCommand = new Command("do")
const shellCommand = shellCommandFromToolCall(toolCall);
logCommand(`${shellCommand.command} ${shellCommand.args.join(" ")}`);
logExplanation(shellCommand.explanation);
const shellCommandOutput = await runCommand(shellCommand);
const shellCommandOutput = await shell.executeCommand(shellCommand);
logCommandOutput(shellCommandOutput);
const toolResponse =
chatMessageFromShellCommandOutput(shellCommandOutput);
Expand Down
5 changes: 3 additions & 2 deletions src/ctags.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { ShellCommandOutput } from "./llm";
import { runCommand } from "./runCommand";
import { PersistentShell } from "./runCommand";

const runCtagCommand = (): Promise<ShellCommandOutput> => {
return runCommand({
const shell = new PersistentShell();
return shell.executeCommand({
command: "ctags --fields=+S --output-format=json -R .",
args: [],
explanation: "Generate ctags for the current project",
Expand Down
108 changes: 88 additions & 20 deletions src/runCommand.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,97 @@
import { spawn } from "child_process";
import { ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import { ShellCommand, ShellCommandOutput } from "./llm";
import { randomString } from "./utils/rand";

export async function runCommand(
cmd: ShellCommand
): Promise<ShellCommandOutput> {
return new Promise((resolve, reject) => {
const command = spawn(`${cmd.command} ${cmd.args.join(" ")}`, {
shell: true,
export class PersistentShell {
process: ChildProcessWithoutNullStreams;
stdout: string;
stderr: string;
listeners: Map<string, () => void>;

constructor(shell?: string) {
this.process = this.createShellProcess(shell);
this.stdout = "";
this.stderr = "";
this.listeners = new Map();

this.process.stdout.on("data", (data) => {
data = data.toString();
this.stdout += data;
this.listeners.forEach((listener) => listener());
});
this.process.stderr.on("data", (data) => {
data = data.toString();
this.stderr += data;
this.listeners.forEach((listener) => listener());
});

const commandOutput: ShellCommandOutput = {
stdout: "",
stderr: "",
exitCode: null,
};
this.process.on("exit", (exitCode) => {
console.log(`shell exit code ${exitCode}`);
});

command.stdout.on("data", (output) => {
commandOutput.stdout += output.toString();
this.process.on("error", (err) => {
console.error(`shell error: ${err}`);
});
command.stderr.on("data", (output) => {
commandOutput.stderr += output.toString();
this.process.on("message", (msg) => {
console.error(`shell message: ${msg}`);
});
command.on("close", (code) => {
commandOutput.exitCode = code;
resolve(commandOutput);
this.process.on("disconnect", () => {
console.log("shell disconnected");
});
}

createShellProcess(shell?: string) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shell here isn't used

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh wait it's used further down. think the code could be slightly clearer (and not go through the process.platform checks if that work's going to be ignored anyway)

let shellFile = "/bin/sh";
if (process.platform === "win32") {
shellFile = process.env.comspec || "cmd.exe";
} else if (process.platform === "android") {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what

shellFile = "/system/bin/sh";
}

shellFile = shell || shellFile;

return spawn(shellFile, {
shell: false,
});
}

executeCommand(cmd: ShellCommand): Promise<ShellCommandOutput> {
const uniqueCommandId = `end_command_${randomString(16)}`;

return new Promise((resolve, reject) => {
// This listener runs on every chunk received to either stdout or stderr
const onChunk = () => {
// Check the entire stdout for the unique end command id
if (this.stdout.includes(uniqueCommandId)) {
const splitStdout = this.stdout.split("\n");
while (splitStdout.pop() !== uniqueCommandId) {}
const exitCode = parseInt(splitStdout.pop() || "0");
const commandStdout = splitStdout.join("\n");

// Remove the end command id from the stdout
this.stdout = this.stdout.replace(uniqueCommandId, "");

// Remove the listener for this command
if (!this.listeners.delete(uniqueCommandId)) {
throw new Error(
`removing listener for command ${uniqueCommandId} failed`
);
}

resolve({
stdout: commandStdout,
stderr: this.stderr,
exitCode,
});
}
};
this.listeners.set(uniqueCommandId, onChunk);

// Write the main command, and the exit code retrieval, and the unique end command id
// to stdin on the shell.
this.process.stdin.write(`${cmd.command} ${cmd.args.join(" ")}\n`);
this.process.stdin.write(`echo $?\n`);
this.process.stdin.write(`echo ${uniqueCommandId}\n`);
});
});
}
}
5 changes: 5 additions & 0 deletions src/utils/rand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import crypto from "crypto";

export function randomString(length: number) {
return crypto.randomBytes(length).toString("hex").slice(0, length);
}