Files
automaker/apps/app/electron/services/claude-cli-detector.js

722 lines
21 KiB
JavaScript

const { execSync, spawn } = require("child_process");
const fs = require("fs");
const path = require("path");
const os = require("os");
let runPtyCommand = null;
try {
({ runPtyCommand } = require("./pty-runner"));
} catch (error) {
console.warn(
"[ClaudeCliDetector] node-pty unavailable, will fall back to external terminal:",
error?.message || error
);
}
const ANSI_REGEX =
// eslint-disable-next-line no-control-regex
/\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b[@-_]|\u001b\][^\u0007]*\u0007/g;
const stripAnsi = (text = "") => text.replace(ANSI_REGEX, "");
/**
* Claude CLI Detector
*
* Authentication options:
* 1. OAuth Token (Subscription): User runs `claude setup-token` and provides the token to the app
* 2. API Key (Pay-per-use): User provides their Anthropic API key directly
*/
class ClaudeCliDetector {
/**
* Check if Claude Code CLI is installed and accessible
* @returns {Object} { installed: boolean, path: string|null, version: string|null, method: 'cli'|'none' }
*/
/**
* Try to get updated PATH from shell config files
* This helps detect CLI installations that modify shell config but haven't updated the current process PATH
*/
static getUpdatedPathFromShellConfig() {
const homeDir = os.homedir();
const shell = process.env.SHELL || "/bin/bash";
const shellName = path.basename(shell);
const configFiles = [];
if (shellName.includes("zsh")) {
configFiles.push(path.join(homeDir, ".zshrc"));
configFiles.push(path.join(homeDir, ".zshenv"));
configFiles.push(path.join(homeDir, ".zprofile"));
} else if (shellName.includes("bash")) {
configFiles.push(path.join(homeDir, ".bashrc"));
configFiles.push(path.join(homeDir, ".bash_profile"));
configFiles.push(path.join(homeDir, ".profile"));
}
const commonPaths = [
path.join(homeDir, ".local", "bin"),
path.join(homeDir, ".cargo", "bin"),
"/usr/local/bin",
"/opt/homebrew/bin",
path.join(homeDir, "bin"),
];
for (const configFile of configFiles) {
if (fs.existsSync(configFile)) {
try {
const content = fs.readFileSync(configFile, "utf-8");
const pathMatches = content.match(
/export\s+PATH=["']?([^"'\n]+)["']?/g
);
if (pathMatches) {
for (const match of pathMatches) {
const pathValue = match
.replace(/export\s+PATH=["']?/, "")
.replace(/["']?$/, "");
const paths = pathValue
.split(":")
.filter((p) => p && !p.includes("$"));
commonPaths.push(...paths);
}
}
} catch (error) {
// Ignore errors reading config files
}
}
}
return [...new Set(commonPaths)];
}
static detectClaudeInstallation() {
try {
// Check if 'claude' command is in PATH (Unix)
if (process.platform !== "win32") {
try {
const claudePath = execSync("which claude 2>/dev/null", {
encoding: "utf-8",
}).trim();
if (claudePath) {
const version = this.getClaudeVersion(claudePath);
return {
installed: true,
path: claudePath,
version: version,
method: "cli",
};
}
} catch (error) {
// CLI not in PATH
}
}
// Check Windows path
if (process.platform === "win32") {
try {
const claudePath = execSync("where claude 2>nul", {
encoding: "utf-8",
})
.trim()
.split("\n")[0];
if (claudePath) {
const version = this.getClaudeVersion(claudePath);
return {
installed: true,
path: claudePath,
version: version,
method: "cli",
};
}
} catch (error) {
// Not found on Windows
}
}
// Check for local installation
const localClaudePath = path.join(
os.homedir(),
".claude",
"local",
"claude"
);
if (fs.existsSync(localClaudePath)) {
const version = this.getClaudeVersion(localClaudePath);
return {
installed: true,
path: localClaudePath,
version: version,
method: "cli-local",
};
}
// Check common installation locations
const commonPaths = this.getUpdatedPathFromShellConfig();
const binaryNames = ["claude", "claude-code"];
for (const basePath of commonPaths) {
for (const binaryName of binaryNames) {
const claudePath = path.join(basePath, binaryName);
if (fs.existsSync(claudePath)) {
try {
const version = this.getClaudeVersion(claudePath);
return {
installed: true,
path: claudePath,
version: version,
method: "cli",
};
} catch (error) {
// File exists but can't get version
}
}
}
}
// Try to source shell config and check PATH again (Unix)
if (process.platform !== "win32") {
try {
const shell = process.env.SHELL || "/bin/bash";
const shellName = path.basename(shell);
const homeDir = os.homedir();
let sourceCmd = "";
if (shellName.includes("zsh")) {
sourceCmd = `source ${homeDir}/.zshrc 2>/dev/null && which claude`;
} else if (shellName.includes("bash")) {
sourceCmd = `source ${homeDir}/.bashrc 2>/dev/null && which claude`;
}
if (sourceCmd) {
const claudePath = execSync(`bash -c "${sourceCmd}"`, {
encoding: "utf-8",
timeout: 2000,
}).trim();
if (claudePath && claudePath.startsWith("/")) {
const version = this.getClaudeVersion(claudePath);
return {
installed: true,
path: claudePath,
version: version,
method: "cli",
};
}
}
} catch (error) {
// Failed to source shell config
}
}
return {
installed: false,
path: null,
version: null,
method: "none",
};
} catch (error) {
return {
installed: false,
path: null,
version: null,
method: "none",
error: error.message,
};
}
}
/**
* Get Claude CLI version
* @param {string} claudePath Path to claude executable
* @returns {string|null} Version string or null
*/
static getClaudeVersion(claudePath) {
try {
const version = execSync(`"${claudePath}" --version 2>/dev/null`, {
encoding: "utf-8",
timeout: 5000,
}).trim();
return version || null;
} catch (error) {
return null;
}
}
/**
* Get authentication status
* Checks for:
* 1. OAuth token stored in app's credentials (from `claude setup-token`)
* 2. API key stored in app's credentials
* 3. API key in environment variable
*
* @param {string} appCredentialsPath Path to app's credentials.json
* @returns {Object} Authentication status
*/
static getAuthStatus(appCredentialsPath) {
const envApiKey = process.env.ANTHROPIC_API_KEY;
const envOAuthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN;
let storedOAuthToken = null;
let storedApiKey = null;
if (appCredentialsPath && fs.existsSync(appCredentialsPath)) {
try {
const content = fs.readFileSync(appCredentialsPath, "utf-8");
const credentials = JSON.parse(content);
storedOAuthToken = credentials.anthropic_oauth_token || null;
storedApiKey =
credentials.anthropic || credentials.anthropic_api_key || null;
} catch (error) {
// Ignore credential read errors
}
}
// Authentication priority (highest to lowest):
// 1. Environment OAuth Token (CLAUDE_CODE_OAUTH_TOKEN)
// 2. Stored OAuth Token (from credentials file)
// 3. Stored API Key (from credentials file)
// 4. Environment API Key (ANTHROPIC_API_KEY)
let authenticated = false;
let method = "none";
if (envOAuthToken) {
authenticated = true;
method = "oauth_token_env";
} else if (storedOAuthToken) {
authenticated = true;
method = "oauth_token";
} else if (storedApiKey) {
authenticated = true;
method = "api_key";
} else if (envApiKey) {
authenticated = true;
method = "api_key_env";
}
return {
authenticated,
method,
hasStoredOAuthToken: !!storedOAuthToken,
hasStoredApiKey: !!storedApiKey,
hasEnvApiKey: !!envApiKey,
hasEnvOAuthToken: !!envOAuthToken,
};
}
/**
* Get installation info (installation status only, no auth)
* @returns {Object} Installation info with status property
*/
static getInstallationInfo() {
const installation = this.detectClaudeInstallation();
return {
status: installation.installed ? "installed" : "not_installed",
installed: installation.installed,
path: installation.path,
version: installation.version,
method: installation.method,
};
}
/**
* Get full status including installation and auth
* @param {string} appCredentialsPath Path to app's credentials.json
* @returns {Object} Full status
*/
static getFullStatus(appCredentialsPath) {
const installation = this.detectClaudeInstallation();
const auth = this.getAuthStatus(appCredentialsPath);
return {
success: true,
status: installation.installed ? "installed" : "not_installed",
installed: installation.installed,
path: installation.path,
version: installation.version,
method: installation.method,
auth,
};
}
/**
* Get installation info and recommendations
* @returns {Object} Installation status and recommendations
*/
static getInstallationInfo() {
const detection = this.detectClaudeInstallation();
if (detection.installed) {
return {
status: 'installed',
method: detection.method,
version: detection.version,
path: detection.path,
recommendation: 'Claude Code CLI is ready for ultrathink'
};
}
return {
status: 'not_installed',
recommendation: 'Install Claude Code CLI for optimal ultrathink performance',
installCommands: this.getInstallCommands()
};
}
/**
* Get installation commands for different platforms
* @returns {Object} Installation commands
*/
static getInstallCommands() {
return {
macos: "curl -fsSL https://claude.ai/install.sh | bash",
windows: "irm https://claude.ai/install.ps1 | iex",
linux: "curl -fsSL https://claude.ai/install.sh | bash",
};
}
/**
* Install Claude CLI using the official script
* @param {Function} onProgress Callback for progress updates
* @returns {Promise<Object>} Installation result
*/
static async installCli(onProgress) {
return new Promise((resolve, reject) => {
const platform = process.platform;
let command, args;
if (platform === "win32") {
command = "powershell";
args = ["-Command", "irm https://claude.ai/install.ps1 | iex"];
} else {
command = "bash";
args = ["-c", "curl -fsSL https://claude.ai/install.sh | bash"];
}
console.log("[ClaudeCliDetector] Installing Claude CLI...");
const proc = spawn(command, args, {
stdio: ["pipe", "pipe", "pipe"],
shell: false,
});
let output = "";
let errorOutput = "";
proc.stdout.on("data", (data) => {
const text = data.toString();
output += text;
if (onProgress) {
onProgress({ type: "stdout", data: text });
}
});
proc.stderr.on("data", (data) => {
const text = data.toString();
errorOutput += text;
if (onProgress) {
onProgress({ type: "stderr", data: text });
}
});
proc.on("close", (code) => {
if (code === 0) {
console.log(
"[ClaudeCliDetector] Installation completed successfully"
);
resolve({
success: true,
output,
message: "Claude CLI installed successfully",
});
} else {
console.error(
"[ClaudeCliDetector] Installation failed with code:",
code
);
reject({
success: false,
error: errorOutput || `Installation failed with code ${code}`,
output,
});
}
});
proc.on("error", (error) => {
console.error("[ClaudeCliDetector] Installation error:", error);
reject({
success: false,
error: error.message,
output,
});
});
});
}
/**
* Get instructions for setup-token command
* @returns {Object} Setup token instructions
*/
static getSetupTokenInstructions() {
const detection = this.detectClaudeInstallation();
if (!detection.installed) {
return {
success: false,
error: "Claude CLI is not installed. Please install it first.",
installCommands: this.getInstallCommands(),
};
}
return {
success: true,
command: "claude setup-token",
instructions: [
"1. Open your terminal",
"2. Run: claude setup-token",
"3. Follow the prompts to authenticate",
"4. Copy the token that is displayed",
"5. Paste the token in the field below",
],
note: "This token is from your Claude subscription and allows you to use Claude without API charges.",
};
}
/**
* Extract OAuth token from command output
* Tries multiple patterns to find the token
* @param {string} output The command output
* @returns {string|null} Extracted token or null
*/
static extractTokenFromOutput(output) {
// Pattern 1: CLAUDE_CODE_OAUTH_TOKEN=<token> or CLAUDE_CODE_OAUTH_TOKEN: <token>
const envMatch = output.match(
/CLAUDE_CODE_OAUTH_TOKEN[=:]\s*["']?([a-zA-Z0-9_\-\.]+)["']?/i
);
if (envMatch) return envMatch[1];
// Pattern 2: "Token: <token>" or "token: <token>"
const tokenLabelMatch = output.match(
/\btoken[:\s]+["']?([a-zA-Z0-9_\-\.]{40,})["']?/i
);
if (tokenLabelMatch) return tokenLabelMatch[1];
// Pattern 3: Look for token after success/authenticated message
const successMatch = output.match(
/(?:success|authenticated|generated|token is)[^\n]*\n\s*([a-zA-Z0-9_\-\.]{40,})/i
);
if (successMatch) return successMatch[1];
// Pattern 4: Standalone long alphanumeric string on its own line (last resort)
// This catches tokens that are printed on their own line
const lines = output.split("\n");
for (const line of lines) {
const trimmed = line.trim();
// Token should be 40+ chars, alphanumeric with possible hyphens/underscores/dots
if (/^[a-zA-Z0-9_\-\.]{40,}$/.test(trimmed)) {
return trimmed;
}
}
return null;
}
/**
* Run claude setup-token command to generate OAuth token
* Opens an external terminal window since Claude CLI requires TTY for its Ink-based UI
* @param {Function} onProgress Callback for progress updates
* @returns {Promise<Object>} Result indicating terminal was opened
*/
static async runSetupToken(onProgress) {
const detection = this.detectClaudeInstallation();
if (!detection.installed) {
throw {
success: false,
error: "Claude CLI is not installed. Please install it first.",
requiresManualAuth: false,
};
}
const claudePath = detection.path;
const platform = process.platform;
const preferPty =
(platform === "win32" ||
platform === "darwin" ||
process.env.CLAUDE_AUTH_FORCE_PTY === "1") &&
process.env.CLAUDE_AUTH_DISABLE_PTY !== "1";
const send = (data) => {
if (onProgress && data) {
onProgress({ type: "stdout", data });
}
};
if (preferPty && runPtyCommand) {
try {
send("Starting in-app terminal session for Claude auth...\n");
send("If your browser opens, complete sign-in and return here.\n\n");
const ptyResult = await runPtyCommand(claudePath, ["setup-token"], {
cols: 120,
rows: 30,
onData: (chunk) => send(chunk),
env: {
FORCE_COLOR: "1",
},
});
const cleanedOutput = stripAnsi(ptyResult.output || "");
const token = this.extractTokenFromOutput(cleanedOutput);
if (ptyResult.success && token) {
send("\nCaptured token automatically.\n");
return {
success: true,
token,
requiresManualAuth: false,
terminalOpened: false,
};
}
if (ptyResult.success && !token) {
send(
"\nCLI completed but token was not detected automatically. You can copy it above or retry.\n"
);
return {
success: true,
requiresManualAuth: true,
terminalOpened: false,
error: "Could not capture token automatically",
output: cleanedOutput,
};
}
send(
`\nClaude CLI exited with code ${ptyResult.exitCode}. Falling back to manual copy.\n`
);
return {
success: false,
error: `Claude CLI exited with code ${ptyResult.exitCode}`,
requiresManualAuth: true,
output: cleanedOutput,
};
} catch (error) {
console.error("[ClaudeCliDetector] PTY auth failed, falling back:", error);
send(
`In-app terminal failed (${error?.message || "unknown error"}). Falling back to external terminal...\n`
);
}
}
// Fallback: external terminal window
if (preferPty && !runPtyCommand) {
send("In-app terminal unavailable (node-pty not loaded).");
} else if (!preferPty) {
send("Using system terminal for authentication on this platform.");
}
send("Opening system terminal for authentication...\n");
// Helper function to check if a command exists asynchronously
const commandExists = (cmd) => {
return new Promise((resolve) => {
require("child_process").exec(
`which ${cmd}`,
{ timeout: 1000 },
(error) => {
resolve(!error);
}
);
});
};
// For Linux, find available terminal first (async)
let linuxTerminal = null;
if (platform !== "win32" && platform !== "darwin") {
const terminals = [
["gnome-terminal", ["--", claudePath, "setup-token"]],
["konsole", ["-e", claudePath, "setup-token"]],
["xterm", ["-e", claudePath, "setup-token"]],
["x-terminal-emulator", ["-e", `${claudePath} setup-token`]],
];
for (const [term, termArgs] of terminals) {
const exists = await commandExists(term);
if (exists) {
linuxTerminal = { command: term, args: termArgs };
break;
}
}
}
return new Promise((resolve, reject) => {
// Open command in external terminal since Claude CLI requires TTY
let command, args;
if (platform === "win32") {
// Windows: Open new cmd window that stays open
command = "cmd";
args = ["/c", "start", "cmd", "/k", `"${claudePath}" setup-token`];
} else if (platform === "darwin") {
// macOS: Open Terminal.app
command = "osascript";
args = [
"-e",
`tell application "Terminal" to do script "${claudePath} setup-token"`,
"-e",
'tell application "Terminal" to activate',
];
} else {
// Linux: Use the terminal we found earlier
if (!linuxTerminal) {
reject({
success: false,
error:
"Could not find a terminal emulator. Please run 'claude setup-token' manually in your terminal.",
requiresManualAuth: true,
});
return;
}
command = linuxTerminal.command;
args = linuxTerminal.args;
}
console.log(
"[ClaudeCliDetector] Spawning terminal:",
command,
args.join(" ")
);
const proc = spawn(command, args, {
detached: true,
stdio: "ignore",
shell: platform === "win32",
});
proc.unref();
proc.on("error", (error) => {
console.error("[ClaudeCliDetector] Failed to open terminal:", error);
reject({
success: false,
error: `Failed to open terminal: ${error.message}`,
requiresManualAuth: true,
});
});
// Give the terminal a moment to open
setTimeout(() => {
send("Terminal window opened!\n\n");
send("1. Complete the sign-in in your browser\n");
send("2. Copy the token from the terminal\n");
send("3. Paste it below\n");
// Resolve with manual auth required since we can't capture from external terminal
resolve({
success: true,
requiresManualAuth: true,
terminalOpened: true,
message:
"Terminal opened. Complete authentication and paste the token below.",
});
}, 500);
});
}
}
module.exports = ClaudeCliDetector;