mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 21:03:08 +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 { 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}`));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user