From 5e789c281721603f1518caa836b079cfdc0f7c4e Mon Sep 17 00:00:00 2001 From: Mohamad Yahia Date: Sun, 21 Dec 2025 08:12:22 +0400 Subject: [PATCH] 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. --- .../src/services/claude-usage-service.ts | 102 +++++++++--------- 1 file changed, 52 insertions(+), 50 deletions(-) diff --git a/apps/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts index a164a46d..8eaa630d 100644 --- a/apps/server/src/services/claude-usage-service.ts +++ b/apps/server/src/services/claude-usage-service.ts @@ -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 { 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 { 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, }); 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}`)); - } - }); }); }