/** * obsidian-cli — dynamic pi wrapper around the Obsidian CLI (obsidian 1.13+). * * - Discovers the live command catalog via `obsidian __completions` * (falls back to a bundled snapshot when the app is unreachable). * - Permissions are enforced by the extension (not the prompt) from the * `obsidianCli` section of pi's main settings.json: * { * "obsidianCli": { * "binary": "obsidian", * "autoLaunch": false, * "vault": "my-vault", * "permissionMode": "read-only" | "all" | "custom", * "include": ["read", "search", "files"], * "exclude": ["eval", "dev:*"], * "exposeReadOnlyTools": true, * "confirmDestructive": true, * "timeoutMs": 30000 * } * } * - Registers a general `obsidian` tool plus one dedicated read-only tool per * command (obsidian_read, obsidian_search, ...) when exposeReadOnlyTools. * Dedicated tools are registered synchronously at load time from the bundled * fallback catalog (augmented by live discovery later), so they are active * from the very first turn, and carry a promptSnippet each (configurable via * promptSnippets) so they appear in the system prompt "Available tools" * section exactly like read/bash/edit/write. * - /obsidian opens an interactive TUI browser; /obsidian [args] runs a * command inline; /obsidian-config shows the effective configuration. */ import { readFileSync } from "node:fs"; import { CONFIG_DIR_NAME, type ExtensionAPI, type ExtensionContext, type Theme, } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; import { Type, type TSchema } from "typebox"; import { loadConfig, type ContextMode, type ObsidianCliConfig, type PermissionMode } from "./config.ts"; import { decide, discoverCatalog, loadFallbackCatalog, supportsJson, type Catalog, type CliCommand, } from "./catalog.ts"; import { paramsToArgs, runCli } from "./runner.ts"; import { formatCliOutput, isJson, RISK_BADGE, summarizeOutput, truncateForLlm } from "./render.ts"; import { ObsidianBrowser } from "./interactive.ts"; import type { CliDetails } from "./tools/types.ts"; import { registerIndividualTools as registerCatalogTools } from "./tools/catalog-tools.ts"; import { registerFixedTools } from "./tools/fixed-tools.ts"; import { injectContextDefaults, isBlockedOpenPath, parseWikilink, requiresConfirmation, toolNameFor, } from "./tools/policy.ts"; import { effectiveConfig as composeEffectiveConfig } from "./tools/state.ts"; const ENTRY_TYPE = "obsidian-cli-output"; // Active-file cache: populated by obsidian_active_file tool, consumed by // contextual defaults when contextMode === "active-tab". let activeFileCache: { path: string; name: string } | null = null; export default function obsidianCliExtension(pi: ExtensionAPI) { let config: ObsidianCliConfig = loadConfig({ agentDir: process.env.PI_CODING_AGENT_DIR ?? "", cwd: process.cwd(), projectTrusted: false, configDirName: CONFIG_DIR_NAME, }); let catalog: Catalog | undefined; let catalogPromise: Promise | undefined; let sessionVault: string | undefined; let sessionMode: PermissionMode | undefined; const individualTools = new Set(); const effectiveMode = (): PermissionMode => composeEffectiveConfig(config, sessionMode, sessionVault).permissionMode; const effectiveConfig = (): ObsidianCliConfig => composeEffectiveConfig(config, sessionMode, sessionVault); // --------------------------------------------------- helpers /** Resolve a wikilink target to a vault file path using an audited fixed * script probe. * Returns the original text unchanged if eval is not permitted or * resolution fails. */ async function resolveWikilink( ctx: ExtensionContext, linkText: string, sourcePath: string | undefined, ): Promise { const cfg = effectiveConfig(); if (!cfg.resolveWikilinks) return null; // Wikilink resolution is a fixed script — it does not require generic eval. if (!cfg.allowFixedScripts) return null; try { const linkJson = JSON.stringify(linkText); const sourceJson = JSON.stringify(sourcePath ?? ""); const script = `(async () => { const linktext = ${linkJson}; const sourcePath = ${sourceJson} || ""; const dest = app.metadataCache.getFirstLinkpathDest(linktext, sourcePath); return JSON.stringify(dest ? { path: dest.path, name: dest.name } : null); })()`; const result = await executeCommand(ctx, "eval", [`code=${script}`], undefined, undefined, { fixedScript: true, }); if (result.error) return null; const parsed = JSON.parse(result.stdout ?? "null"); return parsed?.path ?? null; } catch { return null; } } /** Build a compact metadata header line for a command result. */ function buildMetadata(details: CliDetails, resultCount?: number): string { const parts: string[] = []; if (resultCount !== undefined && resultCount > 0) parts.push(`${resultCount} results`); if (details.lineCount !== undefined && details.wordCount !== undefined) { parts.push(`${details.lineCount} lines`, `${details.wordCount} words`); } if (details.activeFile) { const label = details.defaultedFromActiveFile ? `active default: ${details.activeFile}` : `active: ${details.activeFile}`; parts.push(label); } if (details.vault) parts.push(`vault: ${details.vault}`); parts.push(`${details.durationMs ?? 0}ms`); const header = `◆ obsidian ${details.command} ${details.args.join(" ")}`.trimEnd(); return `${header} (${parts.join(" · ")})`; } async function ensureCatalog(): Promise { if (catalog) return catalog; if (!catalogPromise) { catalogPromise = discoverCatalog(pi, effectiveConfig(), sessionVault).then((c) => { catalog = c; catalogPromise = undefined; registerIndividualTools(c); // augment with live-only commands return c; }); } return catalogPromise; } // Per-tool promptGuidelines: one concise sentence per tool telling the // model when to use it *instead of* a built-in (read/bash/grep/ls/find). // The general "vault exclusive" and "no echo" rules live on the `obsidian` // tool (always active), so they are always present without being repeated // 52 times. Only the top 5 tools repeat the vault-exclusive rule for // redundancy in case the general tool is deactivated. const GUIDELINE_VAULT_ONLY = "Access the Obsidian vault EXCLUSIVELY through obsidian_* tools. Never use read, write, edit, bash, grep, ls, or find for vault operations."; const GUIDELINE_NO_ECHO = "Do NOT repeat or echo the tool's output verbatim in your response. Add only interpretation, insights, or direct answers."; const TOOL_GUIDELINES: Record = { // --- vault exploration --- files: ["Use obsidian_files instead of bash ls/find to list vault contents. Returns structured output with metadata.", GUIDELINE_VAULT_ONLY], folders: ["Use obsidian_folders instead of bash ls/find to explore vault structure."], vault: ["Use obsidian_vault to get vault info (path, file count, size)."], vaults: ["Use obsidian_vaults to list known vaults."], // --- file reading --- read: ["Use obsidian_read instead of the read tool to read vault files. It returns vault-aware content with wikilinks and properties resolved.", GUIDELINE_NO_ECHO], file: ["Use obsidian_file to get metadata about a specific vault file (size, dates, extension)."], outline: ["Use obsidian_outline to get a file's heading structure — the read tool cannot provide this.", GUIDELINE_NO_ECHO], // --- search --- search: ["Use obsidian_search instead of bash grep/rg to search the vault. Results include vault-aware context and match locations.", GUIDELINE_NO_ECHO], "search:context": ["Use obsidian_search_context for searches where you need the surrounding lines of each match."], // --- tasks & tags --- tasks: ["Use obsidian_tasks to list tasks across the vault. It understands Obsidian task markers ([ ], [x], [/], priorities, due dates) — grep cannot parse these.", GUIDELINE_NO_ECHO], tags: ["Use obsidian_tags to list tags with counts and usage patterns across the vault.", GUIDELINE_NO_ECHO], tag: ["Use obsidian_tag to get details and backlinks for a specific tag."], // --- properties --- properties: ["Use obsidian_properties to list frontmatter properties across the vault.", GUIDELINE_NO_ECHO], "property:read": ["Use obsidian_property_read to read a specific frontmatter property value from a file."], // --- links & graph --- links: ["Use obsidian_links to list outgoing wikilinks from a file — manual [[wikilink]] parsing is error-prone.", GUIDELINE_NO_ECHO], backlinks: ["Use obsidian_backlinks to list incoming links (backlinks) to a file.", GUIDELINE_NO_ECHO], orphans: ["Use obsidian_orphans to find files with no incoming links."], deadends: ["Use obsidian_deadends to find files with no outgoing links."], unresolved: ["Use obsidian_unresolved to find broken wikilinks across the vault."], // --- aliases --- aliases: ["Use obsidian_aliases to list note aliases in the vault."], // --- bases --- bases: ["Use obsidian_bases to list Base files in the vault.", GUIDELINE_NO_ECHO], "base:query": ["Use obsidian_base_query to run queries against an Obsidian Base.", GUIDELINE_NO_ECHO], "base:views": ["Use obsidian_base_views to list views in a Base file."], // --- daily notes --- daily: ["Use obsidian_daily to read or create daily notes. It fixes the double-slash bug and correctly processes Templater templates.", GUIDELINE_NO_ECHO], "daily:read": ["Prefer obsidian_daily over daily:read — obsidian_daily fixes the double-slash bug in daily note paths and ensures Templater templates are processed."], "daily:path": ["Use obsidian_daily:path to get the daily note path for a given date."], // --- history --- history: ["Use obsidian_history to view file history versions."], "history:list": ["Use obsidian_history:list to list files with history."], "history:read": ["Use obsidian_history:read to read a specific history version."], // --- sync --- "sync:status": ["Use obsidian_sync_status to check Obsidian Sync status."], "sync:history": ["Use obsidian_sync_history to list sync version history for a file."], "sync:read": ["Use obsidian_sync_read to read a specific sync version."], "sync:deleted": ["Use obsidian_sync_deleted to list deleted files in sync."], // --- workspace --- workspace: ["Use obsidian_workspace to view the workspace tree (open panels, tabs)."], tabs: ["Use obsidian_tabs to list open tabs in Obsidian."], recents: ["Use obsidian_recents to list recently opened files."], // --- plugins & config --- plugins: ["Use obsidian_plugins to list installed plugins.", GUIDELINE_NO_ECHO], "plugins:enabled": ["Use obsidian_plugins_enabled to list enabled plugins."], plugin: ["Use obsidian_plugin to get info about a specific plugin."], themes: ["Use obsidian_themes to list installed themes."], theme: ["Use obsidian_theme to get info about a specific theme."], hotkeys: ["Use obsidian_hotkeys to list keyboard shortcuts.", GUIDELINE_NO_ECHO], hotkey: ["Use obsidian_hotkey to get the shortcut for a specific command."], commands: ["Use obsidian_commands to list available Obsidian commands."], snippets: ["Use obsidian_snippets to list CSS snippets."], "snippets:enabled": ["Use obsidian_snippets_enabled to list enabled CSS snippets."], // --- misc --- folder: ["Use obsidian_folder to get info about a vault folder."], "random:read": ["Use obsidian_random_read to read a random note from the vault."], version: ["Use obsidian_version to get the Obsidian app version."], help: ["Use obsidian_help to list available CLI commands."], "template:read": ["Use obsidian_template_read to read a template's content."], templates: ["Use obsidian_templates to list templates."], // --- dataview --- dataview_query: ["Use obsidian_dataview_query to run Dataview DQL queries (LIST, TABLE, TASK, etc.) against the vault. Returns structured results with headers and typed values. Call sequentially, not in parallel with other obsidian_* eval tools. Requires the Dataview plugin."], dataviewjs_run: ["Use obsidian_dataviewjs_run to execute DataviewJS code against the vault. The code receives `dv` (Dataview API) and runs in a controlled context. Returns captured outputs from dv.list(), dv.table(), dv.taskList(), etc. Call sequentially, not in parallel. Requires the Dataview plugin."], // --- tasks --- tasks_plugin: ["Use obsidian_tasks_query to query the Obsidian Tasks plugin cache. Returns tasks with due dates, priorities, urgency scores, recurrence rules, and scheduling info. Filter by path, due date (overdue/today/upcoming), status, and priority. Call sequentially (not in parallel with other obsidian_* eval tools) to avoid CLI queue timeouts. Requires the Tasks plugin."], }; // Register dedicated read-only tools synchronously at load time from the // bundled fallback catalog. This makes them active from the first turn of // every session (no async discovery race); live discovery later adds any // commands the snapshot does not know about. registerIndividualTools(loadFallbackCatalog()); function updateStatus(ctx: ExtensionContext): void { if (!config.statusBar || !ctx.hasUI) return; const vault = sessionVault ?? config.vault ?? "active"; const src = catalog ? catalog.source : "…"; ctx.ui.setStatus("obsidian-cli", `◆ obs:${vault} · ${effectiveMode()} · ${src}`); } // ------------------------------------------------------------ execution async function executeCommand( ctx: ExtensionContext, command: string, args: string[], vaultOverride?: string, signal?: AbortSignal, options?: { confirm?: boolean; configOverride?: ObsidianCliConfig; defaultedFromActiveFile?: boolean; /** Audited wrapper: skip decide()/exclude for `eval` when allowFixedScripts. */ fixedScript?: boolean; }, ): Promise { const cat = await ensureCatalog(); const cmd = cat.commands.get(command); const cfg = options?.configOverride ?? effectiveConfig(); if (!cmd) { throw new Error( `Unknown obsidian command "${command}". Known commands: ${[...cat.commands.keys()].slice(0, 40).join(", ")}…`, ); } if (options?.fixedScript) { if (command !== "eval") { throw new Error(`fixedScript is only valid for eval (got "${command}")`); } if (!cfg.allowFixedScripts) { throw new Error( `obsidianCli.allowFixedScripts is false. Set it to true to run audited plugin wrappers (Excalidraw, Dataview, Tasks, active-file). This does not enable generic eval.`, ); } } const decision = options?.fixedScript ? { allowed: true, risk: cmd.risk } : decide(cfg, cmd, command); if (!decision.allowed) { throw new Error( `${decision.reason}. Adjust obsidianCli in settings.json to allow it (current mode: ${cfg.permissionMode}).`, ); } // Guard: obsidian open hangs indefinitely on .svg files because // Obsidian has no native tab view for SVG. Reject early with a // clear error so the user doesn't wait for the timeout. if (isBlockedOpenPath(command, args)) { throw new Error( `obsidian ${command} does not support .svg files — Obsidian has no tab view for SVG. Open the file in a browser or an editor instead.`, ); } const shouldConfirm = requiresConfirmation(options, decision.risk, cfg); if (shouldConfirm) { if (!ctx.hasUI || ctx.mode !== "tui") { throw new Error( `"${command}" is classified as ${decision.risk} and obsidianCli.confirmDestructive is enabled, but no UI is available to confirm. Refusing to run.`, ); } const ok = await ctx.ui.confirm( `obsidian ${command} (${decision.risk})`, `Run "${[command, ...args].join(" ")}" on vault "${vaultOverride ?? sessionVault ?? cfg.vault ?? "active"}"?`, ); if (!ok) throw new Error(`Cancelled by user: obsidian ${command}`); } let execArgs = args; if (cfg.preferJson && supportsJson(cmd) && !args.some((a) => a.startsWith("format="))) { execArgs = [...args, "format=json"]; } const openCommands = command === "open" || command === "tab:open"; const runOptions: { vault?: string; signal?: AbortSignal; timeoutMs?: number } = { vault: vaultOverride, signal, }; if (openCommands) { runOptions.timeoutMs = Math.min(cfg.timeoutMs, 8000); } const result = await runCli(pi, cfg, command, execArgs, runOptions); if (result.isError) { throw new Error(result.errorMessage ?? `obsidian ${command} failed`); } const formatted = formatCliOutput(result.stdout); const resultCount = formatted.source === "json" || formatted.source === "tsv" ? formatted.lines.length : undefined; const contentText = formatted.lines.join("\n"); const truncated = truncateForLlm(contentText, cfg.maxOutputBytes); let lineCount: number | undefined; let wordCount: number | undefined; if ((command === "read" || command === "daily:read" || command === "daily") && truncated.text) { const rawLines = truncated.text.split("\n"); lineCount = rawLines.length; wordCount = truncated.text.trim().split(/\s+/).filter(Boolean).length; } return { command, args: execArgs, vault: vaultOverride ?? sessionVault ?? cfg.vault, risk: decision.risk, durationMs: result.durationMs, stdout: truncated.text, totalBytes: truncated.totalBytes, outputTruncated: truncated.truncated, resultCount, activeFile: activeFileCache?.path, defaultedFromActiveFile: options?.defaultedFromActiveFile, lineCount, wordCount, }; } function toToolResult(details: CliDetails) { // Build the metadata header from CliDetails fields and prepend it to // the clean stdout so the LLM sees it, while renderResult/renderEntry // use only the clean content (no visual redundancy in the TUI). const metadata = buildMetadata(details, details.resultCount); let text = metadata + "\n\n" + (details.stdout ?? ""); if (details.outputTruncated) { text += `\n\n[Output truncated to ${(details.stdout?.length ?? 0)} of ${details.totalBytes} bytes. Raise obsidianCli.maxOutputBytes if needed.]`; } if (details.stdout?.trim().length === 0 && !details.error) text = metadata + "\n\n(no output)"; return { content: [{ type: "text" as const, text }], details }; } // ------------------------------------------------------------ rendering function makeRenderCall() { return (args: { command?: string; args?: string[]; vault?: string }, theme: Theme, context: any) => { const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); const cmdName = args.command ?? context.args?.command ?? ""; const tokens: string[] = args.args ?? context.args?.args ?? []; let line = theme.fg("toolTitle", theme.bold("◆ obsidian ")); line += theme.fg("accent", cmdName); if (tokens.length > 0) line += theme.fg("dim", ` ${tokens.join(" ")}`); text.setText(line); return text; }; } function makeRenderResult() { return (result: { details?: CliDetails }, options: { expanded: boolean; isPartial: boolean }, theme: Theme, context: any) => { const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); if (options.isPartial) { text.setText(theme.fg("warning", "⠋ running…")); return text; } const d = result.details; if (!d) { text.setText(theme.fg("dim", "(no details)")); return text; } if (d.error) { text.setText(theme.fg("error", `✗ ${d.error}`)); return text; } const badge = RISK_BADGE[d.risk]; const badgeColor = d.risk === "read" ? "success" : d.risk === "write" ? "warning" : "error"; let out = theme.fg(badgeColor, `${badge.icon} ${badge.label}`) + theme.fg("dim", ` · ${d.durationMs ?? 0}ms`); if (d.resultCount !== undefined && d.resultCount > 0) out += theme.fg("dim", ` · ${d.resultCount} results`); if (d.lineCount !== undefined && d.wordCount !== undefined) out += theme.fg("dim", ` · ${d.lineCount} lines, ${d.wordCount} words`); if (d.activeFile) out += theme.fg("dim", ` · active: ${d.activeFile}`); if (d.vault) out += theme.fg("dim", ` · vault: ${d.vault}`); if (d.stdout && d.stdout.trim().length > 0) { const summary = summarizeOutput(d.stdout, options.expanded ? 200 : 8); out += `\n${summary.lines.join("\n")}`; if (summary.truncated) out += `\n${theme.fg("dim", `… ${summary.total - summary.lines.length} more lines (expand to see more)`)}`; } else { out += theme.fg("dim", " · no output"); } text.setText(out); return text; }; } // ------------------------------------------------------------ tools function registerIndividualTools(cat: Catalog): void { registerCatalogTools({ pi, cat, config, effectiveConfig, activeFile: () => activeFileCache, individualTools, toolNameFor, injectContextDefaults, resolveWikilink, executeCommand, toToolResult, makeRenderResult, guidelines: TOOL_GUIDELINES, }); } // General-purpose tool, governed by the same permission layer. pi.registerTool({ name: "obsidian", label: "Obsidian CLI", description: "Run any allowed Obsidian CLI command against a vault (read, search, files, tasks, tags, properties, bases, and—if enabled by obsidianCli config—writes like create/append/move). Pass tokens as `key=value` or bare `flag`. Permissions are enforced from settings.json obsidianCli (mode, include/exclude); disallowed commands fail with an explanation.", promptSnippet: "Run Obsidian CLI commands (vault notes, search, tasks, tags, properties)", promptGuidelines: [ "Access the Obsidian vault EXCLUSIVELY through obsidian_* tools. Never use read, write, edit, bash, grep, ls, find, or any filesystem tool for vault operations. obsidian_* tools return vault-aware metadata (wikilinks, properties, aliases, backlinks, tasks, tags) that raw file access cannot provide and cannot parse correctly.", "If the obsidian tool reports a command is blocked by obsidianCli configuration, inform the user which setting to change instead of working around it.", "Prefer dedicated obsidian_* tools (obsidian_read, obsidian_search, obsidian_files, obsidian_tasks, obsidian_tags, etc.) over the generic obsidian tool. Dedicated tools have typed parameters, return structured JSON/table output, and support contextual defaults (active file, vault).", "When an obsidian_* tool returns, its output is already formatted with aligned columns and metadata headers. Do NOT repeat or echo the tool's output verbatim in your response. Add only interpretation, insights, or direct answers to the user's question. If the output is short or self-explanatory, a brief acknowledgment is sufficient — do not re-display the data.", ], parameters: Type.Object({ command: Type.String({ description: "Obsidian CLI command name, e.g. read, search, files, tasks" }), args: Type.Optional( Type.Array(Type.String(), { description: "CLI tokens: bare flags (\"total\") or key=value pairs (\"path=Notes/a.md\")", }), ), vault: Type.Optional(Type.String({ description: "Vault name or id (defaults to configured/active vault)" })), }), async execute(_id, params, signal, _onUpdate, ctx) { const details = await executeCommand(ctx, params.command, params.args ?? [], params.vault, signal); return toToolResult(details); }, renderCall: makeRenderCall(), renderResult: makeRenderResult(), }); // ── Named tool profiles (obsidianCli.toolProfiles) ── // Profiles are deliberately registered only after session_start has loaded // the trusted project configuration. Registering them at factory load would // expose global/project-sensitive tools before trust and effective settings // are known (P1-F). const registeredProfileTools = new Set(); function registerProfileTools(): void { for (const [profileName, profile] of Object.entries(config.toolProfiles ?? {})) { const toolName = `obsidian_${profileName}`; if (registeredProfileTools.has(toolName)) continue; const profileCfg: ObsidianCliConfig = { ...config, permissionMode: profile.permissionMode, include: profile.include ?? config.include, exclude: [...config.exclude, ...(profile.exclude ?? [])].filter((v, i, a) => a.indexOf(v) === i), confirmDestructive: profile.confirmDestructive ?? config.confirmDestructive, }; const snippet = profile.permissionMode === "read-only" ? `Obsidian vault (${profileName}): Read-only vault access — ${profileName} scoped` : profile.permissionMode === "all" ? `Obsidian vault (${profileName}): Full vault access — ${profileName} scoped` : `Obsidian vault (${profileName}): Custom vault access — ${profileName} scoped (include: ${(profile.include ?? []).join(", ") || "none"})`; pi.registerTool({ name: toolName, label: `Obsidian CLI (${profileName})`, description: `Run Obsidian CLI commands scoped to the "${profileName}" permission profile ` + `(mode: ${profile.permissionMode}${profile.include ? ", include: " + profile.include.join(", ") : ""}). ` + `Pass tokens as \`key=value\` or bare \`flag\`. Disallowed commands fail with an explanation.`, promptSnippet: snippet, promptGuidelines: [ `Access the Obsidian vault through obsidian_${profileName}. Never use read, write, edit, bash, grep, ls, or find for vault operations.`, `If a command is blocked, inform the user that the "${profileName}" profile denies it — adjust obsidianCli.toolProfiles in settings.json if needed.`, ], parameters: Type.Object({ command: Type.String({ description: "Obsidian CLI command name, e.g. read, search, files, tasks" }), args: Type.Optional( Type.Array(Type.String(), { description: "CLI tokens: bare flags (\"total\") or key=value pairs (\"path=Notes/a.md\")", }), ), vault: Type.Optional(Type.String({ description: "Vault name or id (defaults to configured/active vault)" })), }), async execute(_id, params, signal, _onUpdate, ctx) { const details = await executeCommand(ctx, params.command, params.args ?? [], params.vault, signal, { configOverride: profileCfg, }); return toToolResult(details); }, renderCall: makeRenderCall(), renderResult: makeRenderResult(), }); registeredProfileTools.add(toolName); } } registerFixedTools({ pi, config, effectiveConfig, getActiveFile: () => activeFileCache, setActiveFile: (file) => { activeFileCache = file; }, executeCommand, toToolResult, makeRenderResult, parseWikilink, runCli, decide, summarizeOutput, guidelines: TOOL_GUIDELINES, getSessionVault: () => sessionVault, }); // ------------------------------------------------------------ entries pi.registerEntryRenderer(ENTRY_TYPE, (entry, { expanded }, theme) => { const d = entry.data as CliDetails | undefined; if (!d) return new Text(theme.fg("dim", "(empty obsidian result)"), 0, 0); let out = theme.fg("accent", theme.bold(`◆ obsidian ${d.command}`)); if (d.args?.length) out += theme.fg("dim", ` ${d.args.join(" ")}`); out += theme.fg("dim", ` (${d.durationMs ?? 0}ms)`); if (d.error) { out += `\n${theme.fg("error", `✗ ${d.error}`)}`; } else if (d.stdout?.trim()) { const summary = summarizeOutput(d.stdout, expanded ? 200 : 12); out += `\n${summary.lines.join("\n")}`; if (summary.truncated) out += `\n${theme.fg("dim", `… ${summary.total - summary.lines.length} more lines`)}`; } else { out += `\n${theme.fg("dim", "(no output)")}`; } return new Text(out, 1, 0); }); // ------------------------------------------------------------ commands pi.registerCommand("obsidian", { description: "Open the interactive Obsidian CLI browser, or run `/obsidian [args]` inline", getArgumentCompletions: (prefix: string) => { if (!catalog) return null; const cfg = effectiveConfig(); const items = [...catalog.commands.values()] .filter((c) => decide(cfg, c, c.name).allowed) .map((c) => ({ value: c.name, label: c.name, description: c.description })) .filter((i) => i.value.startsWith(prefix)); return items.length > 0 ? items : null; }, handler: async (args, ctx) => { const trimmed = args.trim(); // Inline execution: /obsidian read path=Notes/a.md if (trimmed.length > 0) { const [command, ...tokens] = trimmed.split(/\s+/); try { const details = await executeCommand(ctx, command, tokens, undefined, undefined); pi.appendEntry(ENTRY_TYPE, details); } catch (err) { pi.appendEntry(ENTRY_TYPE, { command, args: tokens, risk: "read", error: err instanceof Error ? err.message : String(err), } satisfies CliDetails); } return; } // Interactive browser (TUI only). if (ctx.mode !== "tui") { ctx.ui.notify("/obsidian without arguments requires TUI mode. Use /obsidian [args].", "warning"); return; } await ensureCatalog(); if (!catalog || catalog.commands.size === 0) { ctx.ui.notify( "Could not discover Obsidian commands. Is Obsidian running with the CLI enabled (Settings → General → Advanced)?", "error", ); return; } await ctx.ui.custom((tui, theme, _kb, done) => { const browser = new ObsidianBrowser({ config, catalog: catalog!, host: pi, theme: theme as unknown as any, vault: sessionVault ?? config.vault, permissionMode: effectiveMode(), executeCommand: async (command, args, vault) => { const details = await executeCommand(ctx, command, args, vault); return { stdout: details.stdout, durationMs: details.durationMs }; }, refreshCatalog: async () => { catalog = await discoverCatalog(pi, effectiveConfig(), sessionVault); registerIndividualTools(catalog); updateStatus(ctx); return catalog; }, onVaultChange: (v) => { sessionVault = v; catalog = undefined; // catalog may differ per vault (plugins) void ensureCatalog().then(() => updateStatus(ctx)); }, onModeChange: (m) => { sessionMode = m; updateStatus(ctx); }, requestRender: () => tui.requestRender(), done, }); return { render: (width: number) => browser.render(width), handleInput: (data: string) => { browser.handleInput(data); tui.requestRender(); }, invalidate: () => browser.invalidate(), }; }); updateStatus(ctx); }, }); pi.registerCommand("obsidian-config", { description: "Show effective obsidianCli configuration and command counts", handler: async (_args, ctx) => { const cfg = effectiveConfig(); const cat = await ensureCatalog(); const counts = { read: 0, write: 0, danger: 0, blocked: 0 }; for (const cmd of cat.commands.values()) { const d = decide(cfg, cmd, cmd.name); if (!d.allowed) counts.blocked++; else counts[cmd.risk]++; } pi.appendEntry(ENTRY_TYPE, { command: "config", args: [], risk: "read", stdout: [ `permissionMode\t${cfg.permissionMode}${sessionMode ? " (session override)" : ""}`, `vault\t${sessionVault ?? cfg.vault ?? "(active)"}`, `binary\t${cfg.binary}`, `catalog\t${cat.source} · ${cat.commands.size} commands`, `exclude\t${cfg.exclude.join(", ") || "-"}`, `include\t${cfg.include.join(", ") || "-"}`, `exposeReadOnlyTools\t${cfg.exposeReadOnlyTools} (${individualTools.size} tools)`, `promptSnippets\t${cfg.promptSnippets}`, `preferJson\t${cfg.preferJson}`, `contextMode\t${cfg.contextMode}${activeFileCache ? ` (active: ${activeFileCache.path})` : ""}`, `resolveWikilinks\t${cfg.resolveWikilinks}`, `allowFixedScripts\t${cfg.allowFixedScripts}`, `allowDataviewJs\t${cfg.allowDataviewJs}`, `confirmDestructive\t${cfg.confirmDestructive}`, `timeoutMs\t${cfg.timeoutMs}`, `maxOutputBytes\t${cfg.maxOutputBytes}`, `allowed\tread:${counts.read} write:${counts.write} danger:${counts.danger}`, `blocked\t${counts.blocked}`, `toolProfiles\t${cfg.toolProfiles ? Object.keys(cfg.toolProfiles).join(", ") : "-"}`, ].join("\n"), } satisfies CliDetails); }, }); pi.registerCommand("obsidian-reload", { description: "Rediscover the Obsidian CLI command catalog", handler: async (_args, ctx) => { catalog = undefined; catalogPromise = undefined; const cat = await ensureCatalog(); updateStatus(ctx); ctx.ui.notify(`Obsidian catalog: ${cat.commands.size} commands (${cat.source})`, "info"); }, }); // ------------------------------------------------------------ lifecycle pi.on("session_start", async (_event, ctx) => { config = loadConfig({ agentDir: process.env.PI_CODING_AGENT_DIR ?? "", cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted(), configDirName: CONFIG_DIR_NAME, }); registerProfileTools(); sessionVault = undefined; sessionMode = undefined; catalog = undefined; catalogPromise = undefined; activeFileCache = null; updateStatus(ctx); // Warm the catalog in the background; tools resolve it lazily too. // A closed app is expected with autoLaunch disabled: use the bundled // fallback and report a warning instead of leaving an unhandled rejection. void ensureCatalog() .then((cat) => { updateStatus(ctx); if (cat.source === "fallback") { ctx.ui.notify( "Could not query the Obsidian catalog. Using the bundled catalog; open Obsidian before using obsidian_* tools.", "warning", ); } }) .catch((err) => { updateStatus(ctx); ctx.ui.notify( `Could not load the Obsidian catalog: ${err instanceof Error ? err.message : String(err)}`, "warning", ); }); }); }