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 { spawn } from "child_process";
import * as os from "os";
import * as pty from "node-pty";
import { ClaudeUsage } from "../routes/claude/types.js"; 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 * This approach doesn't require any API keys - it relies on the user
* having already authenticated via `claude login`. * 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 { export class ClaudeUsageService {
private claudeBinary = "claude"; private claudeBinary = "claude";
@@ -19,7 +21,10 @@ export class ClaudeUsageService {
*/ */
async isAvailable(): Promise<boolean> { async isAvailable(): Promise<boolean> {
return new Promise((resolve) => { 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) => { proc.on("close", (code) => {
resolve(code === 0); resolve(code === 0);
}); });
@@ -39,90 +44,87 @@ export class ClaudeUsageService {
/** /**
* Execute the claude /usage command and return the output * 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> { private executeClaudeUsageCommand(): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let stdout = ""; let output = "";
let stderr = "";
let settled = false; let settled = false;
let hasSeenUsageData = false;
// Use a simple working directory (home or tmp) // Use home directory as working directory
const workingDirectory = process.env.HOME || "/tmp"; 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 // Determine shell based on platform
// Wait for "Current session" header, then wait for full output before exiting const isWindows = os.platform() === "win32";
const expectScript = ` const shell = isWindows ? "cmd.exe" : (process.env.SHELL || "/bin/bash");
set timeout 20 const shellArgs = isWindows ? ["/c", "claude", "/usage"] : ["-c", "claude /usage"];
spawn claude /usage
expect {
"Current session" {
sleep 2
send "\\x1b"
}
"Esc to cancel" {
sleep 3
send "\\x1b"
}
timeout {}
eof {}
}
expect eof
`;
const proc = spawn("expect", ["-c", expectScript], { const ptyProcess = pty.spawn(shell, shellArgs, {
name: "xterm-256color",
cols: 120,
rows: 30,
cwd: workingDirectory, cwd: workingDirectory,
env: { env: {
...process.env, ...process.env,
TERM: "xterm-256color", TERM: "xterm-256color",
}, } as Record<string, string>,
}); });
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
if (!settled) { if (!settled) {
settled = true; settled = true;
proc.kill(); ptyProcess.kill();
reject(new Error("Command timed out")); reject(new Error("Command timed out"));
} }
}, this.timeout); }, this.timeout);
proc.stdout.on("data", (data) => { // Collect output
stdout += data.toString(); 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) => { ptyProcess.onExit(({ exitCode }) => {
stderr += data.toString();
});
proc.on("close", (code) => {
clearTimeout(timeoutId); clearTimeout(timeoutId);
if (settled) return; if (settled) return;
settled = true; settled = true;
// Check for authentication errors in output // Check for authentication errors in output
if (stdout.includes("token_expired") || stdout.includes("authentication_error") || if (output.includes("token_expired") || output.includes("authentication_error")) {
stderr.includes("token_expired") || stderr.includes("authentication_error")) {
reject(new Error("Authentication required - please run 'claude login'")); reject(new Error("Authentication required - please run 'claude login'"));
return; return;
} }
// Even if exit code is non-zero, we might have useful output // Even if exit code is non-zero, we might have useful output
if (stdout.trim()) { if (output.trim()) {
resolve(stdout); resolve(output);
} else if (code !== 0) { } else if (exitCode !== 0) {
reject(new Error(stderr || `Command exited with code ${code}`)); reject(new Error(`Command exited with code ${exitCode}`));
} else { } else {
reject(new Error("No output from claude command")); 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}`));
}
});
}); });
} }