mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 09:13:08 +00:00
feat: add Windows support using node-pty while keeping expect for macOS
Platform-specific implementations: - macOS: Uses 'expect' command (unchanged, working) - Windows: Uses node-pty for PTY support Also fixes 'which' vs 'where' for checking Claude CLI availability.
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,18 +10,22 @@ 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.
|
* Platform-specific implementations:
|
||||||
|
* - macOS: Uses 'expect' command for PTY
|
||||||
|
* - Windows: Uses node-pty for PTY
|
||||||
*/
|
*/
|
||||||
export class ClaudeUsageService {
|
export class ClaudeUsageService {
|
||||||
private claudeBinary = "claude";
|
private claudeBinary = "claude";
|
||||||
private timeout = 30000; // 30 second timeout
|
private timeout = 30000; // 30 second timeout
|
||||||
|
private isWindows = os.platform() === "win32";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if Claude CLI is available on the system
|
* Check if Claude CLI is available on the system
|
||||||
*/
|
*/
|
||||||
async isAvailable(): Promise<boolean> {
|
async isAvailable(): Promise<boolean> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const proc = spawn("which", [this.claudeBinary]);
|
const checkCmd = this.isWindows ? "where" : "which";
|
||||||
|
const proc = spawn(checkCmd, [this.claudeBinary]);
|
||||||
proc.on("close", (code) => {
|
proc.on("close", (code) => {
|
||||||
resolve(code === 0);
|
resolve(code === 0);
|
||||||
});
|
});
|
||||||
@@ -39,9 +45,19 @@ 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 platform-specific PTY implementation
|
||||||
*/
|
*/
|
||||||
private executeClaudeUsageCommand(): Promise<string> {
|
private executeClaudeUsageCommand(): Promise<string> {
|
||||||
|
if (this.isWindows) {
|
||||||
|
return this.executeClaudeUsageCommandWindows();
|
||||||
|
}
|
||||||
|
return this.executeClaudeUsageCommandMac();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* macOS implementation using 'expect' command
|
||||||
|
*/
|
||||||
|
private executeClaudeUsageCommandMac(): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let stdout = "";
|
let stdout = "";
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
@@ -126,6 +142,82 @@ export class ClaudeUsageService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Windows implementation using node-pty
|
||||||
|
*/
|
||||||
|
private executeClaudeUsageCommandWindows(): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let output = "";
|
||||||
|
let settled = false;
|
||||||
|
let hasSeenUsageData = false;
|
||||||
|
|
||||||
|
const workingDirectory = process.env.USERPROFILE || os.homedir() || "C:\\";
|
||||||
|
|
||||||
|
const ptyProcess = pty.spawn("cmd.exe", ["/c", "claude", "/usage"], {
|
||||||
|
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;
|
||||||
|
ptyProcess.kill();
|
||||||
|
reject(new Error("Command timed out"));
|
||||||
|
}
|
||||||
|
}, this.timeout);
|
||||||
|
|
||||||
|
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 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ptyProcess.onExit(({ exitCode }) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
|
||||||
|
// Check for authentication errors in output
|
||||||
|
if (output.includes("token_expired") || output.includes("authentication_error")) {
|
||||||
|
reject(new Error("Authentication required - please run 'claude login'"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strip ANSI escape codes from text
|
* Strip ANSI escape codes from text
|
||||||
*/
|
*/
|
||||||
@@ -165,12 +257,12 @@ export class ClaudeUsageService {
|
|||||||
const weeklyData = this.parseSection(lines, "Current week (all models)", "weekly");
|
const weeklyData = this.parseSection(lines, "Current week (all models)", "weekly");
|
||||||
|
|
||||||
// Parse Sonnet/Opus usage - try different labels
|
// Parse Sonnet/Opus usage - try different labels
|
||||||
let opusData = this.parseSection(lines, "Current week (Sonnet only)", "opus");
|
let sonnetData = this.parseSection(lines, "Current week (Sonnet only)", "sonnet");
|
||||||
if (opusData.percentage === 0) {
|
if (sonnetData.percentage === 0) {
|
||||||
opusData = this.parseSection(lines, "Current week (Sonnet)", "opus");
|
sonnetData = this.parseSection(lines, "Current week (Sonnet)", "sonnet");
|
||||||
}
|
}
|
||||||
if (opusData.percentage === 0) {
|
if (sonnetData.percentage === 0) {
|
||||||
opusData = this.parseSection(lines, "Current week (Opus)", "opus");
|
sonnetData = this.parseSection(lines, "Current week (Opus)", "sonnet");
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -186,9 +278,9 @@ export class ClaudeUsageService {
|
|||||||
weeklyResetTime: weeklyData.resetTime,
|
weeklyResetTime: weeklyData.resetTime,
|
||||||
weeklyResetText: weeklyData.resetText,
|
weeklyResetText: weeklyData.resetText,
|
||||||
|
|
||||||
opusWeeklyTokensUsed: 0, // Not available from CLI
|
sonnetWeeklyTokensUsed: 0, // Not available from CLI
|
||||||
opusWeeklyPercentage: opusData.percentage,
|
sonnetWeeklyPercentage: sonnetData.percentage,
|
||||||
opusResetText: opusData.resetText,
|
sonnetResetText: sonnetData.resetText,
|
||||||
|
|
||||||
costUsed: null, // Not available from CLI
|
costUsed: null, // Not available from CLI
|
||||||
costLimit: null,
|
costLimit: null,
|
||||||
|
|||||||
Reference in New Issue
Block a user