@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
93 lines (92 loc) • 3.31 kB
JavaScript
/**
* Hand a file to the user's own editor.
*
* `$EDITOR` is a command line, not a binary path. Plenty of people set it to
* something carrying arguments — `code --wait`, `nvim -u NONE`, and on this
* machine `omarchy-launch-editor --inline` — so passing it straight to
* `execFileSync` looks for a binary with spaces in its name and fails with
* ENOENT. Git treats `core.editor` as a command line for the same reason, and
* so do we: split it, append the path, run the first word.
*
* Splitting rather than `shell: true` keeps a file path from ever being
* interpreted by a shell.
*/
import { execFileSync } from "child_process";
import { resolveExecCommand } from "../utils-helpers.js";
import { errorMessage } from "../utils.js";
import { debugLog } from "../shared.js";
/**
* Split a command line into a command and its arguments, honouring single and
* double quotes so a quoted path with spaces survives. Backslash escapes are
* deliberately not handled: `$EDITOR` values that need them are vanishingly
* rare, and guessing wrong is worse than not trying.
*/
export function splitCommandLine(raw) {
const parts = [];
let current = "";
let quote = null;
let has = false;
for (const ch of raw) {
if (quote) {
if (ch === quote)
quote = null;
else
current += ch;
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
has = true;
continue;
}
if (/\s/.test(ch)) {
if (has || current) {
parts.push(current);
current = "";
has = false;
}
continue;
}
current += ch;
}
if (has || current)
parts.push(current);
return parts;
}
/** The editor to use, from `$EDITOR`, then `$VISUAL`, then a sensible default. */
export function resolveEditorCommand(env = process.env) {
const raw = (env.EDITOR || env.VISUAL || "").trim();
const parts = raw ? splitCommandLine(raw) : [];
if (!parts.length) {
// Nothing configured. `vi` is the one editor POSIX requires to exist.
return { command: "vi", args: [] };
}
const [command, ...args] = parts;
return command ? { command, args } : null;
}
/**
* Run the editor on `filePath` and wait for it to exit. The child inherits the
* terminal, so the caller must have released it first.
*
* Returns a result rather than throwing or exiting: inside the shell an editor
* that will not start is a message on the status line, not the end of the
* session.
*/
export function openInEditor(filePath, env = process.env) {
const editor = resolveEditorCommand(env);
if (!editor)
return { ok: false, command: "", error: "No editor configured. Set $EDITOR." };
const resolved = resolveExecCommand(editor.command);
try {
execFileSync(resolved.command, [...editor.args, filePath], {
stdio: "inherit",
shell: resolved.shell,
});
return { ok: true, command: editor.command };
}
catch (err) {
const error = errorMessage(err);
debugLog(`openInEditor: ${editor.command}: ${error}`);
return { ok: false, command: editor.command, error };
}
}