refactor: use node-pty instead of expect for cross-platform support

Replace Unix-only 'expect' command with node-pty library which works
on Windows, macOS, and Linux. Also fixes 'which' command to use 'where'
on Windows for checking if Claude CLI is available.
This commit is contained in:
Mohamad Yahia
2025-12-21 08:12:22 +04:00
parent 6150926a75
commit 5e789c2817

View File

@@ -1,4 +1,6 @@
import { spawn } from "child_process";
import * as os from "os";
import * as pty from "node-pty";
import { ClaudeUsage } from "../routes/claude/types.js";
/**
@@ -8,7 +10,7 @@ import { ClaudeUsage } from "../routes/claude/types.js";
* This approach doesn't require any API keys - it relies on the user
* having already authenticated via `claude login`.
*
* Based on ClaudeBar's implementation approach.
* Uses node-pty for cross-platform PTY support (Windows, macOS, Linux).
*/
export class ClaudeUsageService {
private claudeBinary = "claude";
@@ -19,7 +21,10 @@ export class ClaudeUsageService {
*/
async isAvailable(): Promise<boolean> {
return new Promise((resolve) => {
const proc = spawn("which", [this.claudeBinary]);
const isWindows = os.platform() === "win32";
const checkCmd = isWindows ? "where" : "which";
const proc = spawn(checkCmd, [this.claudeBinary]);
proc.on("close", (code) => {
resolve(code === 0);
});
@@ -39,90 +44,87 @@ export class ClaudeUsageService {
/**
* Execute the claude /usage command and return the output
* Uses 'expect' to provide a pseudo-TTY since claude requires one
* Uses node-pty to provide a pseudo-TTY (cross-platform)
*/
private executeClaudeUsageCommand(): Promise<string> {
return new Promise((resolve, reject) => {
let stdout = "";
let stderr = "";
let output = "";
let settled = false;
let hasSeenUsageData = false;
// Use a simple working directory (home or tmp)
const workingDirectory = process.env.HOME || "/tmp";
// Use home directory as working directory
const workingDirectory = os.homedir() || (os.platform() === "win32" ? process.env.USERPROFILE : "/tmp") || "/tmp";
// Use 'expect' with an inline script to run claude /usage with a PTY
// Wait for "Current session" header, then wait for full output before exiting
const expectScript = `
set timeout 20
spawn claude /usage
expect {
"Current session" {
sleep 2
send "\\x1b"
}
"Esc to cancel" {
sleep 3
send "\\x1b"
}
timeout {}
eof {}
}
expect eof
`;
// Determine shell based on platform
const isWindows = os.platform() === "win32";
const shell = isWindows ? "cmd.exe" : (process.env.SHELL || "/bin/bash");
const shellArgs = isWindows ? ["/c", "claude", "/usage"] : ["-c", "claude /usage"];
const proc = spawn("expect", ["-c", expectScript], {
const ptyProcess = pty.spawn(shell, shellArgs, {
name: "xterm-256color",
cols: 120,
rows: 30,
cwd: workingDirectory,
env: {
...process.env,
TERM: "xterm-256color",
},
} as Record<string, string>,
});
const timeoutId = setTimeout(() => {
if (!settled) {
settled = true;
proc.kill();
ptyProcess.kill();
reject(new Error("Command timed out"));
}
}, this.timeout);
proc.stdout.on("data", (data) => {
stdout += data.toString();
// Collect output
ptyProcess.onData((data) => {
output += data;
// Check if we've seen the usage data (look for "Current session")
if (!hasSeenUsageData && output.includes("Current session")) {
hasSeenUsageData = true;
// Wait a bit for full output, then send escape to exit
setTimeout(() => {
if (!settled) {
ptyProcess.write("\x1b"); // Send escape key
}
}, 2000);
}
// Fallback: if we see "Esc to cancel" but haven't seen usage data yet
if (!hasSeenUsageData && output.includes("Esc to cancel")) {
setTimeout(() => {
if (!settled) {
ptyProcess.write("\x1b"); // Send escape key
}
}, 3000);
}
});
proc.stderr.on("data", (data) => {
stderr += data.toString();
});
proc.on("close", (code) => {
ptyProcess.onExit(({ exitCode }) => {
clearTimeout(timeoutId);
if (settled) return;
settled = true;
// Check for authentication errors in output
if (stdout.includes("token_expired") || stdout.includes("authentication_error") ||
stderr.includes("token_expired") || stderr.includes("authentication_error")) {
if (output.includes("token_expired") || output.includes("authentication_error")) {
reject(new Error("Authentication required - please run 'claude login'"));
return;
}
// Even if exit code is non-zero, we might have useful output
if (stdout.trim()) {
resolve(stdout);
} else if (code !== 0) {
reject(new Error(stderr || `Command exited with code ${code}`));
if (output.trim()) {
resolve(output);
} else if (exitCode !== 0) {
reject(new Error(`Command exited with code ${exitCode}`));
} else {
reject(new Error("No output from claude command"));
}
});
proc.on("error", (err) => {
clearTimeout(timeoutId);
if (!settled) {
settled = true;
reject(new Error(`Failed to execute claude: ${err.message}`));
}
});
});
}