/** * Execution layer for the Obsidian CLI. * * The obsidian binary talks to the running app over a local socket and always * exits 0, even on failure, so errors are detected from the output text. * All arguments are passed as argv (no shell), so values with spaces are safe. */ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import type { ObsidianCliConfig } from "./config.ts"; const execFileAsync = promisify(execFile); // Serialize all CLI invocations. Obsidian's single-instance lock is not // concurrency-safe for multiple simultaneous launches, which can cause extra // Obsidian instances to spawn. We run one command at a time. let cliQueue: Promise = Promise.resolve(); // Cache the warmup result to avoid redundant warmup calls on sequential // eval-based tool invocations (obsidian_tasks, obsidian_dataview_*, etc.). let lastWarmupOk = false; let lastWarmupTime = 0; const WARMUP_TTL_MS = 10_000; // skip warmup if the last one was < 10s ago export interface RunResult { stdout: string; stderr: string; durationMs: number; isError: boolean; errorMessage?: string; } export interface ExecLike { exec( command: string, args: string[], options?: { timeout?: number; signal?: AbortSignal }, ): Promise<{ stdout: string; stderr: string; code: number; killed: boolean }>; } const ERROR_PREFIXES = ["Error:", "error:"]; const ERROR_SNIPPETS = [ "Unable to connect to main process", "Command line interface is not enabled", "Vault not found", "Unknown command", "not found.", ]; export function looksLikeError(text: string): string | undefined { const trimmed = text.trim(); for (const prefix of ERROR_PREFIXES) { if (trimmed.startsWith(prefix)) return trimmed.split("\n")[0]; } for (const snippet of ERROR_SNIPPETS) { if (trimmed.includes(snippet)) return trimmed.split("\n")[0]; } return undefined; } async function countObsidianProcesses(): Promise { try { const results = await Promise.all([ execFileAsync("pgrep", ["-x", "Obsidian"], { timeout: 2000 }), execFileAsync("pgrep", ["-x", "obsidian"], { timeout: 2000 }), ]); return new Set(results.flatMap(({ stdout }) => stdout.trim().split(/\n/).filter(Boolean))).size; } catch { try { const { stdout } = await execFileAsync("pgrep", ["-f", "[Oo]bsidian"], { timeout: 2000 }); return new Set(stdout.trim().split(/\n/).filter(Boolean)).size; } catch { return 0; } } } /** Probe the CLI with a lightweight command, retrying if the main process is * still warming up. This avoids spawning a second Obsidian instance when the * first command races with app startup. */ async function ensureObsidianResponsive( pi: ExecLike, config: ObsidianCliConfig, signal?: AbortSignal, ): Promise { const attempts = 3; const delayMs = 400; const timeout = Math.min(5000, config.timeoutMs); let lastResult: RunResult | undefined; for (let i = 0; i < attempts; i++) { if (i > 0) { await new Promise((resolve) => setTimeout(resolve, delayMs)); if (signal?.aborted) break; } lastResult = await runCliRaw(pi, config, "version", [], { timeoutMs: timeout, signal }); if (!lastResult.isError) return lastResult; const msg = (lastResult.errorMessage ?? "").toLowerCase(); // Only retry on connection errors; config errors should fail fast. if (!msg.includes("unable to connect") && !msg.includes("not enabled") && !msg.includes("timed out")) { return lastResult; } } return lastResult ?? { stdout: "", stderr: "", durationMs: 0, isError: true, errorMessage: "Obsidian CLI did not respond" }; } async function runCliRaw( pi: ExecLike, config: ObsidianCliConfig, command: string, args: string[], options?: { vault?: string; signal?: AbortSignal; timeoutMs?: number }, ): Promise { const argv: string[] = []; const vault = options?.vault ?? config.vault; if (vault) argv.push(`vault=${vault}`); argv.push(command, ...args); const started = Date.now(); const timeout = options?.timeoutMs ?? config.timeoutMs; let result: { stdout: string; stderr: string; code: number; killed: boolean }; try { result = await pi.exec(config.binary, argv, { timeout, signal: options?.signal }); } catch (err) { return { stdout: "", stderr: "", durationMs: Date.now() - started, isError: true, errorMessage: `Failed to spawn "${config.binary}": ${err instanceof Error ? err.message : String(err)}`, }; } const durationMs = Date.now() - started; if (result.killed) { return { stdout: result.stdout, stderr: result.stderr, durationMs, isError: true, errorMessage: `obsidian ${command} timed out after ${timeout}ms (is the app running with CLI enabled?)`, }; } const out = result.stdout.trimEnd(); const textError = looksLikeError(out) ?? looksLikeError(result.stderr); if (textError) { return { stdout: out, stderr: result.stderr, durationMs, isError: true, errorMessage: textError }; } if (result.code !== 0) { return { stdout: out, stderr: result.stderr, durationMs, isError: true, errorMessage: `obsidian exited with code ${result.code}: ${result.stderr.trim() || out.split("\n")[0]}`, }; } return { stdout: out, stderr: result.stderr, durationMs, isError: false }; } export function runCli( pi: ExecLike, config: ObsidianCliConfig, command: string, args: string[], options?: { vault?: string; signal?: AbortSignal; timeoutMs?: number; skipWarmup?: boolean }, ): Promise { const task = cliQueue.catch(() => {}).then(async () => { // Do not let the CLI launch the Obsidian GUI implicitly. `autoLaunch` keeps // the previous behavior for users who explicitly opt into it. const processCount = await countObsidianProcesses(); if (processCount === 0 && !config.autoLaunch) { lastWarmupOk = false; lastWarmupTime = 0; return { stdout: "", stderr: "", durationMs: 0, isError: true, errorMessage: "Obsidian is not running. Open Obsidian before using obsidian_* tools. To avoid repeated launches, the CLI no longer starts the app automatically.", }; } // Warm up the CLI connection so the real command does not race with the // Obsidian main process and accidentally spawn a second instance. This is // only reached after the pre-flight confirms that Obsidian is already // running (unless autoLaunch is explicitly enabled). // Skip warmup if it was done recently (avoids redundant 400ms×3 delay // on sequential eval-based tool calls). if (!options?.skipWarmup && command !== "version") { const sinceLast = Date.now() - lastWarmupTime; if (!lastWarmupOk || sinceLast > WARMUP_TTL_MS) { const warmup = await ensureObsidianResponsive(pi, config, options?.signal); if (warmup.isError) { lastWarmupOk = false; return { ...warmup, errorMessage: `Obsidian CLI not ready: ${warmup.errorMessage}. Wait until Obsidian has finished launching, or enable Settings → General → Advanced → Command line interface.`, }; } lastWarmupOk = true; lastWarmupTime = Date.now(); } } const beforePids = await countObsidianProcesses(); const result = await runCliRaw(pi, config, command, args, options); const afterPids = await countObsidianProcesses(); if (!result.isError && afterPids > beforePids) { return { ...result, isError: true, errorMessage: `obsidian ${command} caused a new Obsidian process to start. This usually means the CLI could not reach the existing instance and created a new one. Close the extra Obsidian window and ensure only one instance is running.`, }; } return result; }); cliQueue = task.catch(() => {}); return task; } /** Build CLI argv tokens from a params object: booleans → bare flags, rest → key=value. */ export function paramsToArgs(params: Record, skip?: Set): string[] { const args: string[] = []; for (const [key, value] of Object.entries(params)) { if (skip?.has(key)) continue; if (value === undefined || value === null) continue; if (key.startsWith("_")) { // Positional argument (e.g. help ) → bare token. args.push(String(value)); } else if (typeof value === "boolean") { if (value) args.push(key); } else if (Array.isArray(value)) { for (const item of value) args.push(String(item)); } else { args.push(`${key}=${String(value)}`); } } return args; }