First version
This commit is contained in:
+156
@@ -0,0 +1,156 @@
|
||||
//
|
||||
// cli.js — locating the Claude Code executable and building its environment.
|
||||
//
|
||||
|
||||
const { conf, log, warn, runCommand } = require("./util.js");
|
||||
|
||||
const KEYCHAIN_SERVICE = "unsupervised.claudenova";
|
||||
const KEYCHAIN_ACCOUNT = "ANTHROPIC_API_KEY";
|
||||
|
||||
// Standard install locations, in the order Claude Code's own installer prefers.
|
||||
const CANDIDATE_PATHS = [
|
||||
"/.local/bin/claude",
|
||||
"/.claude/local/claude",
|
||||
];
|
||||
|
||||
const CANDIDATE_ABSOLUTE = [
|
||||
"/opt/homebrew/bin/claude",
|
||||
"/usr/local/bin/claude",
|
||||
"/usr/bin/claude",
|
||||
];
|
||||
|
||||
let cachedBinary = null;
|
||||
|
||||
function isExecutable(path) {
|
||||
try {
|
||||
return nova.fs.access(path, nova.fs.X_OK);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Ask the user's login shell where `claude` lives — catches nvm, mise, asdf, etc. */
|
||||
async function resolveViaLoginShell() {
|
||||
const shell = nova.environment["SHELL"] || "/bin/zsh";
|
||||
const result = await runCommand(shell, ["-l", "-c", "command -v claude"], { timeout: 5000 });
|
||||
if (result.status === 0) {
|
||||
const path = result.stdout.trim().split("\n").pop();
|
||||
if (path && path.startsWith("/") && isExecutable(path)) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the `claude` executable.
|
||||
* Resolution order: configured path, home-relative installs, system paths, login shell.
|
||||
* The result is cached until `forgetBinary()` is called.
|
||||
*/
|
||||
async function resolveBinary() {
|
||||
if (cachedBinary) return cachedBinary;
|
||||
|
||||
const configured = conf("claudenova.binaryPath", "").trim();
|
||||
if (configured) {
|
||||
if (isExecutable(configured)) {
|
||||
cachedBinary = configured;
|
||||
return cachedBinary;
|
||||
}
|
||||
throw new Error(
|
||||
`The configured Claude Code executable is not runnable: ${configured}`
|
||||
);
|
||||
}
|
||||
|
||||
const home = nova.environment["HOME"] || "";
|
||||
const candidates = CANDIDATE_PATHS.map((p) => home + p).concat(CANDIDATE_ABSOLUTE);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (isExecutable(candidate)) {
|
||||
cachedBinary = candidate;
|
||||
log("resolved claude binary:", candidate);
|
||||
return cachedBinary;
|
||||
}
|
||||
}
|
||||
|
||||
const fromShell = await resolveViaLoginShell();
|
||||
if (fromShell) {
|
||||
cachedBinary = fromShell;
|
||||
log("resolved claude binary via login shell:", fromShell);
|
||||
return cachedBinary;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Claude Code was not found. Install it from https://claude.com/product/claude-code, " +
|
||||
"or set the executable path in the extension's settings."
|
||||
);
|
||||
}
|
||||
|
||||
function forgetBinary() {
|
||||
cachedBinary = null;
|
||||
}
|
||||
|
||||
/** The API key the user stored in the keychain, if any. */
|
||||
function storedApiKey() {
|
||||
try {
|
||||
return nova.credentials.getPassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT);
|
||||
} catch (err) {
|
||||
warn("could not read the keychain:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function setStoredApiKey(key) {
|
||||
nova.credentials.setPassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, key);
|
||||
}
|
||||
|
||||
function clearStoredApiKey() {
|
||||
try {
|
||||
nova.credentials.removePassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT);
|
||||
} catch (err) {
|
||||
warn("could not clear the keychain entry:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Environment for spawned `claude` processes: Nova's task environment, plus a
|
||||
* stored API key when the user supplied one instead of signing in.
|
||||
*/
|
||||
function environment(extra = {}) {
|
||||
const env = Object.assign({}, nova.environment, extra);
|
||||
|
||||
const key = storedApiKey();
|
||||
if (key) {
|
||||
env["ANTHROPIC_API_KEY"] = key;
|
||||
}
|
||||
|
||||
// Nova's environment can be sparse; make sure the CLI can find its own tooling.
|
||||
if (!env["PATH"]) {
|
||||
env["PATH"] = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin";
|
||||
}
|
||||
if (!env["HOME"] && nova.environment["HOME"]) {
|
||||
env["HOME"] = nova.environment["HOME"];
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
async function version() {
|
||||
const binary = await resolveBinary();
|
||||
const result = await runCommand(binary, ["--version"], {
|
||||
env: environment(),
|
||||
timeout: 10000,
|
||||
});
|
||||
return result.status === 0 ? result.stdout.trim() : null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resolveBinary,
|
||||
forgetBinary,
|
||||
environment,
|
||||
version,
|
||||
storedApiKey,
|
||||
setStoredApiKey,
|
||||
clearStoredApiKey,
|
||||
KEYCHAIN_SERVICE,
|
||||
KEYCHAIN_ACCOUNT,
|
||||
};
|
||||
Reference in New Issue
Block a user