/** * Dynamic discovery of the Obsidian CLI command catalog. * * Obsidian 1.13+ exposes `obsidian __completions`, which returns a JSON map of * every available command with its usage, description and flags. The catalog is * dynamic: internal plugins (sync, bases, daily notes, templates...) add their * own commands, so we always prefer live discovery and fall back to a bundled * snapshot when the app is not reachable. */ import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { matchesAny, type ObsidianCliConfig } from "./config.ts"; import { runCli, type ExecLike as RunnerExecLike } from "./runner.ts"; export type Risk = "read" | "write" | "danger"; export interface CliFlag { /** Placeholder value, e.g. "", or undefined for boolean flags. */ value?: string; description: string; required?: boolean; } export interface CliCommand { name: string; usage: string; description: string; flags: Record; risk: Risk; } /** Commands whose `format` flag includes `json` — used by the JSON-output * optimisation in preferJson mode. Populated from live catalog flags. */ export function supportsJson(cmd: CliCommand): boolean { const f = cmd.flags["format"]; if (!f?.value) return false; return f.value.split("|").some((v) => v.trim() === "json"); } export interface Catalog { commands: Map; source: "live" | "fallback"; fetchedAt: number; } /** Commands that only read vault state. Safe to expose without confirmation. */ const READ_COMMANDS = new Set([ "aliases", "backlinks", "base:query", "base:views", "bases", "commands", "daily:path", "daily:read", "deadends", "diff", "file", "files", "folder", "folders", "help", "history", "history:list", "history:read", "hotkey", "hotkeys", "links", "orphans", "outline", "plugin", "plugins", "plugins:enabled", "properties", "property:read", "random:read", "read", "recents", "search", "search:context", "snippets", "snippets:enabled", "sync:deleted", "sync:history", "sync:read", "sync:status", "tabs", "tag", "tags", "tasks", "template:read", "templates", "theme", "themes", "unresolved", "vault", "vaults", "version", "workspace", ]); /** Commands that mutate vault content but are ordinary user operations. */ const WRITE_COMMANDS = new Set([ "append", "base:create", "create", "daily:append", "daily:prepend", "delete", "history:restore", "move", "prepend", "property:remove", "property:set", "rename", "sync:restore", "task", "template:insert", ]); /** * Everything else (open/tab:open/reload/restart/plugin:enable/eval/...) either * drives the UI, the app lifecycle, or executes arbitrary things → "danger". * Unknown future commands default to "write" (conservative: hidden in read-only * mode, confirmed in all mode). */ function classify(name: string): Risk { if (READ_COMMANDS.has(name)) return "read"; if (WRITE_COMMANDS.has(name)) return "write"; if (name.startsWith("__")) return "danger"; // __completions, __files // Known-dangerous families even if names drift between versions. if (/^(dev|devtools|eval|command$|restart|reload|open|tab:|sync$|.*:(enable|disable|install|uninstall|set|restrict))/.test(name)) { return "danger"; } return "write"; } export interface CatalogDecision { allowed: boolean; reason?: string; risk: Risk; } /** Decide whether a command may run under the given config. */ export function decide(config: ObsidianCliConfig, cmd: CliCommand | undefined, name: string): CatalogDecision { const risk = cmd?.risk ?? "write"; if (matchesAny(config.exclude, name)) { return { allowed: false, reason: `Blocked by obsidianCli.exclude`, risk }; } switch (config.permissionMode) { case "read-only": if (risk !== "read") { return { allowed: false, reason: `obsidianCli.permissionMode is "read-only" and "${name}" is a ${risk} command`, risk, }; } return { allowed: true, risk }; case "custom": if (!matchesAny(config.include, name)) { return { allowed: false, reason: `"${name}" does not match obsidianCli.include (custom mode)`, risk, }; } return { allowed: true, risk }; case "all": return { allowed: true, risk }; } } function parseCompletions(json: string): Map { const raw = JSON.parse(json) as Record< string, { usage?: string; description?: string; flags?: Record } >; const commands = new Map(); for (const [name, info] of Object.entries(raw)) { if (name.startsWith("__")) continue; commands.set(name, { name, usage: info.usage ?? "", description: info.description ?? "", flags: info.flags ?? {}, risk: classify(name), }); } return commands; } function fallbackPath(): string { const here = dirname(fileURLToPath(import.meta.url)); return join(here, "fallback-catalog.json"); } export function loadFallbackCatalog(): Catalog { try { const json = readFileSync(fallbackPath(), "utf8"); return { commands: parseCompletions(json), source: "fallback", fetchedAt: Date.now() }; } catch { return { commands: new Map(), source: "fallback", fetchedAt: Date.now() }; } } /** Live discovery via `obsidian __completions`. Falls back to the bundled snapshot. */ export async function discoverCatalog( pi: RunnerExecLike, config: ObsidianCliConfig, vaultOverride?: string, ): Promise { const vault = vaultOverride ?? config.vault; try { const result = await runCli(pi, config, "__completions", [], { vault, timeoutMs: Math.min(config.timeoutMs, 20_000), }); if (!result.isError && result.stdout.trim().startsWith("{")) { const commands = parseCompletions(result.stdout); if (commands.size > 0) { return { commands, source: "live", fetchedAt: Date.now() }; } } } catch { // fall through to snapshot } return loadFallbackCatalog(); }