mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
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:
@@ -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}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user