148 lines
4.6 KiB
JavaScript
148 lines
4.6 KiB
JavaScript
//
|
|
// launch.js — building the command that starts or resumes a session.
|
|
//
|
|
// Nova exposes no way for an extension to open its built-in terminal or type
|
|
// into one (no terminal object in the API; `newTerminal:` in the app is an
|
|
// Objective-C menu selector with no JS bridge). So the extension prepares the
|
|
// command and puts it on the clipboard; you open Nova's terminal and paste.
|
|
//
|
|
// The upside of launching a plain CLI session is that it is a completely
|
|
// ordinary one: stored under ~/.claude/projects, resumable from any terminal,
|
|
// and eligible for Remote Control.
|
|
//
|
|
|
|
const { conf, log, warn } = require("./util.js");
|
|
const cli = require("./cli.js");
|
|
|
|
/** Quote a value for a POSIX shell. */
|
|
function shellQuote(value) {
|
|
const text = String(value);
|
|
if (/^[A-Za-z0-9_./:@%+=-]+$/.test(text)) return text;
|
|
return "'" + text.replace(/'/g, `'\\''`) + "'";
|
|
}
|
|
|
|
/**
|
|
* Flags shared by new and resumed sessions, taken from settings so a launched
|
|
* session matches what the extension is configured for.
|
|
*/
|
|
function configuredFlags() {
|
|
const flags = [];
|
|
|
|
const model = conf("claudenova.model", "");
|
|
if (model) flags.push("--model", model);
|
|
|
|
const effort = conf("claudenova.effort", "");
|
|
if (effort) flags.push("--effort", effort);
|
|
|
|
const mode = conf("claudenova.permissionMode", "");
|
|
if (mode) flags.push("--permission-mode", mode);
|
|
|
|
const extra = String(conf("claudenova.extraArgs", "")).trim();
|
|
if (extra) {
|
|
for (const piece of extra.split(/\s+/)) flags.push(piece);
|
|
}
|
|
|
|
return flags;
|
|
}
|
|
|
|
/**
|
|
* Build a launch command line.
|
|
*
|
|
* @param options.resume session id to resume
|
|
* @param options.remoteControl enable Remote Control for the session
|
|
* @param options.cd prefix with a `cd` into the workspace
|
|
* @param options.binary executable path (defaults to the resolved one)
|
|
*/
|
|
function buildCommand(options = {}) {
|
|
const parts = [];
|
|
|
|
if (options.cd !== false && nova.workspace.path) {
|
|
parts.push("cd", shellQuote(nova.workspace.path), "&&");
|
|
}
|
|
|
|
// `claude` on PATH reads better than an absolute path; fall back to the
|
|
// resolved binary when the shell would not find it.
|
|
parts.push(options.binary ? shellQuote(options.binary) : "claude");
|
|
|
|
if (options.resume) {
|
|
parts.push("--resume", shellQuote(options.resume));
|
|
}
|
|
|
|
if (options.remoteControl) {
|
|
parts.push("--remote-control");
|
|
}
|
|
|
|
for (const flag of configuredFlags()) {
|
|
parts.push(shellQuote(flag));
|
|
}
|
|
|
|
return parts.join(" ");
|
|
}
|
|
|
|
/**
|
|
* Resolve which executable spelling to use: bare `claude` when the login shell
|
|
* can find it, otherwise the absolute path we resolved.
|
|
*/
|
|
async function preferredBinary() {
|
|
try {
|
|
const resolved = await cli.resolveBinary();
|
|
const home = nova.environment["HOME"] || "";
|
|
|
|
// The standard install is on PATH for interactive shells; anything
|
|
// unusual (a configured path, a version manager shim) is safer spelled
|
|
// out in full.
|
|
if (resolved === nova.path.join(home, ".local/bin/claude")) return null;
|
|
if (resolved === "/opt/homebrew/bin/claude") return null;
|
|
if (resolved === "/usr/local/bin/claude") return null;
|
|
return resolved;
|
|
} catch (err) {
|
|
log("falling back to bare `claude`:", err.message || err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Put a launch command on the clipboard and tell the user it is ready.
|
|
* Resolves with the command.
|
|
*/
|
|
async function copyCommand(options = {}) {
|
|
const binary = await preferredBinary();
|
|
const command = buildCommand(Object.assign({ binary: binary }, options));
|
|
|
|
try {
|
|
await nova.clipboard.writeText(command);
|
|
} catch (err) {
|
|
warn("could not write to the clipboard:", err);
|
|
nova.workspace.showErrorMessage(
|
|
"Could not copy the command. Run this in a terminal:\n\n" + command
|
|
);
|
|
return command;
|
|
}
|
|
|
|
const request = new NotificationRequest("claudenova-launch");
|
|
request.title = options.resume ? "Resume command copied" : "Launch command copied";
|
|
request.body =
|
|
command + "\n\nOpen a terminal and paste it.";
|
|
request.actions = ["OK", "Show Command"];
|
|
|
|
try {
|
|
const response = await nova.notifications.add(request);
|
|
if (response && response.actionIdx === 1) {
|
|
nova.workspace.showInformativeMessage(command);
|
|
}
|
|
} catch (err) {
|
|
// Notifications are a courtesy; the clipboard already has the command.
|
|
log("could not post the launch notification:", err);
|
|
}
|
|
|
|
return command;
|
|
}
|
|
|
|
module.exports = {
|
|
buildCommand,
|
|
copyCommand,
|
|
configuredFlags,
|
|
shellQuote,
|
|
preferredBinary,
|
|
};
|