import { readFileSync } from "node:fs"; import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; import { Type } from "typebox"; import type { ObsidianCliConfig } from "../config.ts"; import type { CliDetails } from "./types.ts"; import type { Catalog } from "../catalog.ts"; export function registerFixedTools(deps: { pi: ExtensionAPI; config: ObsidianCliConfig; effectiveConfig: () => ObsidianCliConfig; getActiveFile: () => { path: string; name: string } | null; setActiveFile: (file: { path: string; name: string } | null) => void; executeCommand: (ctx: ExtensionContext, command: string, args: string[], vault?: string, signal?: AbortSignal, options?: Record) => Promise; toToolResult: (details: CliDetails) => any; makeRenderResult: () => any; parseWikilink: (raw: string) => string | null; runCli: (...args: any[]) => Promise; decide: (...args: any[]) => any; summarizeOutput: (...args: any[]) => any; guidelines: Record; getSessionVault: () => string | undefined; }): void { const { pi, config, effectiveConfig, executeCommand, toToolResult, makeRenderResult, runCli, decide, summarizeOutput, parseWikilink, guidelines } = deps; const getSessionVault = deps.getSessionVault; let activeFileCache = deps.getActiveFile(); const setActiveFile = (file: { path: string; name: string } | null) => { activeFileCache = file; deps.setActiveFile(file); }; // Dedicated tool for exporting Excalidraw drawings to SVG/PNG via the // Excalidraw plugin's internal API. This is an audited, fixed-purpose // wrapper around the `eval` command; it requires allowFixedScripts but does // not expose generic eval through the obsidian tool. pi.registerTool({ name: "obsidian_excalidraw_export", label: "Excalidraw export", description: "Export an Excalidraw drawing to SVG or PNG using the Excalidraw plugin API. Accepts .excalidraw.md, .mindmap.md, and .excalidraw source files. Saves the output alongside the source and returns its vault path (outputPath). Does not overwrite an existing output file. Uses the audited fixed-script permission.", promptSnippet: "Export an Excalidraw drawing (.excalidraw.md, .mindmap.md, .excalidraw) to SVG or PNG — auto-saved alongside the source, returns outputPath", promptGuidelines: [ "Use obsidian_excalidraw_export to convert .excalidraw.md, .mindmap.md, or .excalidraw drawings to SVG or PNG. The output is saved next to the source and its vault path is returned as outputPath. The tool will refuse to overwrite an existing file.", ], parameters: Type.Object({ file: Type.String({ description: "Path to the source file inside the vault (.excalidraw.md, .mindmap.md, or .excalidraw)" }), format: Type.Optional( Type.Union([Type.Literal("svg"), Type.Literal("png")], { description: "Export format", default: "svg", }), ), includeData: Type.Optional(Type.Boolean({ description: "Include the SVG/PNG payload in the agent result (default: false)" })), vault: Type.Optional(Type.String({ description: "Vault name or id (defaults to configured/active vault)" })), }), async execute(_id, params, signal, _onUpdate, ctx) { const filePath = JSON.stringify(params.file); const format = JSON.stringify(params.format ?? "svg"); const includeData = JSON.stringify(params.includeData === true); const script = `(async () => { const filePath = ${filePath}; const format = ${format}; const includeData = ${includeData}; const file = app.vault.getAbstractFileByPath(filePath); if (!file) return JSON.stringify({ success: false, error: "File not found: " + filePath }); // Derive output path alongside the source file. const outExt = format === "svg" ? "svg" : "png"; let outputPath; if (filePath.endsWith(".excalidraw.md")) { outputPath = filePath.replace(/\.excalidraw\.md$/, ".excalidraw." + outExt); } else if (filePath.endsWith(".mindmap.md")) { outputPath = filePath.replace(/\.mindmap\.md$/, ".mindmap." + outExt); } else if (filePath.endsWith(".excalidraw")) { outputPath = filePath.replace(/\.excalidraw$/, ".excalidraw." + outExt); } else { // Strip existing extension (.md, .json, etc.) before adding new one const base = filePath.replace(/\.[^.]+$/, ""); outputPath = base + "." + outExt; } // Never overwrite existing files. if (app.vault.getAbstractFileByPath(outputPath)) { return JSON.stringify({ success: false, error: "Output file already exists: " + outputPath }); } const ea = window.ExcalidrawAutomate.getAPI(); if (!ea) return JSON.stringify({ success: false, error: "ExcalidrawAutomate API is not available" }); // === Parse Excalidraw scene JSON from multiple file formats === const raw = await app.vault.read(file); let jsonSrc = null; // Strategy 1: Native Obsidian Excalidraw format (.excalidraw.md) // Look for "## Drawing" heading followed by a JSON code fence const drawingRegex = /## Drawing\\s*\\r?\\n\`\`\`(?:json)?\\s*\\r?\\n([\\s\\S]*?)\\r?\\n\`\`\`/; const drawingMatch = raw.match(drawingRegex); if (drawingMatch) { jsonSrc = drawingMatch[1]; } // Strategy 2: JSON after frontmatter with optional code fence (legacy formats) if (!jsonSrc) { let stripped = raw; // Strip YAML frontmatter if present if (stripped.startsWith("---")) { const end = stripped.indexOf("---", 3); if (end !== -1) stripped = stripped.slice(end + 3).trim(); } // Strip code fence if at start after frontmatter removal if (stripped.startsWith("\`\`\`")) { const lines = stripped.split("\\n"); lines.shift(); // remove opening fence if (lines.length && lines[lines.length - 1].trim() === "\`\`\`") lines.pop(); stripped = lines.join("\\n"); } try { JSON.parse(stripped); jsonSrc = stripped; } catch (_) { /* continue to next strategy */ } } // Strategy 3: Balanced brace extraction — find each top-level JSON // candidate by counting braces, validate with JSON.parse, and check // that scene.type === "excalidraw". Avoids greedy regex bugs. if (!jsonSrc) { const candidates = []; for (let i = 0; i < raw.length; i++) { if (raw[i] !== '{') continue; let depth = 0; let inString = false; let escape = false; let end = -1; for (let j = i; j < raw.length; j++) { const ch = raw[j]; if (escape) { escape = false; continue; } if (ch === '\\\\') { escape = true; continue; } if (ch === '"') { inString = !inString; continue; } if (inString) continue; if (ch === '{') depth++; else if (ch === '}') { depth--; if (depth === 0) { end = j; break; } } } if (end !== -1) { candidates.push(raw.slice(i, end + 1)); i = end; } } for (const cand of candidates) { try { const parsed = JSON.parse(cand); if (parsed && parsed.type === "excalidraw") { jsonSrc = cand; break; } } catch (_) { /* try next candidate */ } } } // Detect compressed drawings before giving up if (!jsonSrc && /excalidraw-plugin\\s*:\\s*parsed/.test(raw)) { return JSON.stringify({ success: false, error: "Drawing appears to be compressed. Use 'Decompress current Excalidraw file' in Obsidian first, then retry the export." }); } if (!jsonSrc) { return JSON.stringify({ success: false, error: "Could not extract Excalidraw JSON from " + filePath + ". Content preview: " + raw.slice(0, 200) }); } let scene; try { scene = JSON.parse(jsonSrc); } catch (e) { return JSON.stringify({ success: false, error: "Invalid JSON in " + filePath + ": " + e.message }); } // Validate it's actually an Excalidraw scene (guard against random JSON) if (!scene || scene.type !== "excalidraw") { return JSON.stringify({ success: false, error: "Extracted JSON is not an Excalidraw scene (missing type: excalidraw) in " + filePath }); } // Detect compressed drawings (compressed-json instead of elements array) if (scene["compressed-json"]) { return JSON.stringify({ success: false, error: "Drawing appears to be compressed. Use 'Decompress current Excalidraw file' in Obsidian first, then retry the export." }); } const elements = scene.elements; if (!elements || elements.length === 0) { return JSON.stringify({ success: false, error: "No elements found in " + filePath }); } // Write clean JSON (no frontmatter) to a temp file so createSVG can read it. // Always generate a unique path ending in .excalidraw.md, even if the // source file uses a different extension (e.g. .mindmap.md). const tmpPath = filePath.replace(/(\\.excalidraw\\.md)?$/, ".tmp-export-" + Date.now() + ".excalidraw.md"); const cleanJson = JSON.stringify(scene); await app.vault.create(tmpPath, cleanJson); let svgEl; let createSvgError = null; try { svgEl = await ea.createSVG(tmpPath, true, ea.getExportSettings(true, true)); } catch (e) { createSvgError = e; } finally { // Clean up temp file const tmpFile = app.vault.getAbstractFileByPath(tmpPath); if (tmpFile) await app.vault.delete(tmpFile); } if (createSvgError) { return JSON.stringify({ success: false, error: "createSVG failed: " + (createSvgError.message || String(createSvgError)) }); } const svgString = new XMLSerializer().serializeToString(svgEl); if (format === "svg") { await app.vault.create(outputPath, svgString); const svgBytes = new TextEncoder().encode(svgString).length; return JSON.stringify({ success: true, format: "svg", outputPath: outputPath, bytes: svgBytes, ...(includeData ? { data: svgString } : {}) }); } // PNG: render SVG to canvas, export as PNG data URL. const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d"); const viewBox = svgEl.getAttribute("viewBox") || "0 0 100 100"; const [, , w, h] = viewBox.split(" ").map(Number); canvas.width = w * 2; canvas.height = h * 2; const img = new Image(); const svgBlob = new Blob([svgString], { type: "image/svg+xml;charset=utf-8" }); const url = URL.createObjectURL(svgBlob); await new Promise((resolve, reject) => { img.onload = resolve; img.onerror = reject; img.src = url; }); ctx.drawImage(img, 0, 0, canvas.width, canvas.height); const base64 = canvas.toDataURL("image/png").split(",")[1]; URL.revokeObjectURL(url); // Persist PNG to vault as binary. const binaryString = atob(base64); const pngBytes = binaryString.length; const buffer = new Uint8Array(pngBytes); for (let i = 0; i < pngBytes; i++) { buffer[i] = binaryString.charCodeAt(i); } await app.vault.createBinary(outputPath, buffer); return JSON.stringify({ success: true, format: "png", outputPath: outputPath, bytes: pngBytes, ...(includeData ? { data: base64 } : {}) }); })()`; const details = await executeCommand(ctx, "eval", [`code=${script}`], params.vault, signal, { confirm: false, fixedScript: true }); return toToolResult(details); }, renderCall: (args: any, _theme: Theme, _context: any) => { const text = new Text("", 0, 0); const file = (args.file as string) ?? ""; const format = (args.format as string) ?? "svg"; text.setText(`◆ obsidian_excalidraw_export ${format.toUpperCase()} ${file}`); return text; }, renderResult: (result: any, options: any, theme: Theme, _context: any) => { const text = new Text("", 0, 0); if (options.isPartial) { text.setText(theme.fg("warning", "running…")); return text; } const d = result.details as CliDetails | undefined; if (!d) { text.setText(theme.fg("dim", "(no details)")); return text; } if (d.error) { text.setText(theme.fg("error", `✗ ${d.error}`)); return text; } let out = theme.fg("success", "✓ Excalidraw export") + theme.fg("dim", ` · ${d.durationMs ?? 0}ms`); if (d.vault) out += theme.fg("dim", ` · vault: ${d.vault}`); if (d.stdout) { try { const parsed = JSON.parse(d.stdout) as { success?: boolean; format?: string; data?: string; outputPath?: string; bytes?: number; error?: string }; if (parsed.success) { out += `\n${theme.fg("accent", `format: ${parsed.format}`)}`; if (parsed.outputPath) out += ` → ${parsed.outputPath}`; out += ` · ${parsed.bytes ?? 0} bytes`; } else if (parsed.error) { out += `\n${theme.fg("error", parsed.error)}`; } } catch { out += `\n${d.stdout.slice(0, 200)}`; } } text.setText(out); return text; }, }); // Tool: obsidian_excalidraw_mermaid — converts Mermaid via the native // ExcalidrawAutomate.addMermaid path, which preserves bound text labels. pi.registerTool({ name: "obsidian_excalidraw_mermaid", label: "Mermaid to Excalidraw", description: "Convert a Mermaid diagram definition to Excalidraw elements and insert them into a new or existing drawing using ExcalidrawAutomate.addMermaid and the audited fixed-script permission.", promptSnippet: "Convert Mermaid diagrams to Excalidraw drawings", promptGuidelines: [ "Use obsidian_excalidraw_mermaid to convert Mermaid diagram code into Excalidraw drawings that can be opened and edited in Obsidian's Excalidraw plugin.", "After creation, verify textCount > 0; if it is zero, treat the conversion as failed rather than accepting empty boxes.", ], parameters: Type.Object({ mermaid: Type.String({ description: "Mermaid diagram definition (the code inside the mermaid block)" }), target: Type.Optional(Type.String({ description: "Path to an existing .excalidraw.md file to insert into (creates a new file if omitted)" })), folder: Type.Optional(Type.String({ description: "Vault folder for the new drawing (default: vault root / Excalidraw settings folder)" })), name: Type.Optional(Type.String({ description: "Filename for the new drawing (default: mermaid-.excalidraw.md)" })), flowchart_curve: Type.Optional(Type.Union([Type.Literal("linear"), Type.Literal("basis")], { description: "Flowchart curve style (default: linear)" })), font_size: Type.Optional(Type.String({ description: "Base font size (default: 20px)" })), vault: Type.Optional(Type.String({ description: "Vault name or id (defaults to configured/active vault)" })), }), async execute(_id, params, signal, _onUpdate, ctx) { const mermaidCode = JSON.stringify(params.mermaid as string); const target = params.target ? JSON.stringify(params.target as string) : "null"; const folder = params.folder ? JSON.stringify(params.folder as string) : "null"; const name = params.name ? JSON.stringify(params.name as string) : "null"; const flowCurve = JSON.stringify((params.flowchart_curve as string) ?? "linear"); const fontSize = JSON.stringify((params.font_size as string) ?? "20px"); const script = `(async () => { const mermaid = ${mermaidCode}; const target = ${target}; const folder = ${folder}; const name = ${name}; const fontSizeNum = parseInt(String(${fontSize}).replace(/px$/i, ""), 10) || 20; const ea = window.ExcalidrawAutomate?.getAPI?.() || window.ExcalidrawAutomate; if (!ea || typeof ea.addMermaid !== "function") return JSON.stringify({ success: false, error: "ExcalidrawAutomate.addMermaid is not available. Is the Excalidraw plugin installed and enabled?" }); if (ea.style) { ea.style.fontSize = fontSizeNum; ea.style.roundness = ${flowCurve} === "linear" ? null : { type: 3 }; } try { ea.clear(); } catch (_) {} let ids; try { ids = await ea.addMermaid(mermaid, true); } catch (e) { return JSON.stringify({ success: false, error: "addMermaid threw: " + (e.message || String(e)) }); } if (ids == null) return JSON.stringify({ success: false, error: "addMermaid returned no elements." }); if (typeof ids === "string") return JSON.stringify({ success: false, error: "Mermaid parse error: " + ids }); const elements = typeof ea.getElements === "function" ? ea.getElements() : []; const liveElements = elements.filter((el) => !el.isDeleted); const textCount = liveElements.filter((el) => el.type === "text" && String(el.text || el.originalText || "").trim()).length; const shapeCount = liveElements.filter((el) => ["rectangle", "diamond", "ellipse"].includes(el.type)).length; const files = ea.imagesDict || {}; if (liveElements.length === 0) return JSON.stringify({ success: false, error: "Diagram produced no elements. Check the Mermaid syntax." }); if (shapeCount > 0 && textCount === 0) { try { ea.clear(); } catch (_) {} return JSON.stringify({ success: false, error: "Mermaid conversion produced shapes but zero text labels.", elements: liveElements.length, textCount, shapeCount }); } const appState = { viewBackgroundColor: "#ffffff", gridSize: null }; if (target) { // Insert into existing file: read scene, merge elements, save back const file = app.vault.getAbstractFileByPath(target); if (!file) return JSON.stringify({ success: false, error: "Target file not found: " + target }); const scene = await ea.getSceneFromFile(file); if (!scene) return JSON.stringify({ success: false, error: "Could not read Excalidraw scene from " + target }); const existingIds = new Set((scene.elements || []).map((el) => el.id)); const newElements = elements.filter((el) => !existingIds.has(el.id)); if (newElements.length === 0) return JSON.stringify({ success: true, elements: elements.length, newElements: 0, target, note: "All elements already exist in the drawing (duplicate IDs)." }); const mergedElements = [...(scene.elements || []), ...newElements]; const mergedFiles = { ...(scene.files || {}), ...(files || {}) }; // Use saveSceneToFile for proper Excalidraw native format await ea.saveSceneToFile(file, mergedElements, scene.appState || appState, mergedFiles); try { ea.clear(); } catch (_) {} return JSON.stringify({ success: true, elements: elements.length, newElements: newElements.length, textCount, shapeCount, target }); } else { // Create new file using ExcalidrawAutomate for native format const ts = Date.now(); const fileName = name || \`mermaid-\${ts}.excalidraw.md\`; let targetFolder = folder || ""; if (targetFolder) { const parent = app.vault.getAbstractFileByPath(targetFolder); if (!parent) { const parts = targetFolder.split("/"); let acc = ""; for (const part of parts) { acc = acc ? \`\${acc}/\${part}\` : part; if (!app.vault.getAbstractFileByPath(acc)) { await app.vault.createFolder(acc); } } } } const targetPath = targetFolder ? \`\${targetFolder}/\${fileName}\` : fileName; if (app.vault.getAbstractFileByPath(targetPath)) { return JSON.stringify({ success: false, error: "File already exists: " + targetPath }); } // Build scene data in Excalidraw native format and write via vault API const sceneData = { type: "excalidraw", version: 2, source: "https://marketplace.obsidian.md/plugins/excalidraw", elements, appState, files: files || {}, }; const sceneJson = JSON.stringify(sceneData); // Plain JSON (no frontmatter) — Excalidraw view opens this natively await app.vault.create(targetPath, sceneJson); try { ea.clear(); } catch (_) {} return JSON.stringify({ success: true, elements: elements.length, textCount, shapeCount, created: targetPath }); } })()`; const details = await executeCommand(ctx, "eval", [`code=${script}`], params.vault as string | undefined, signal, { confirm: false, fixedScript: true }); return toToolResult(details); }, renderCall: (args: any, _theme: Theme, _context: any) => { const text = new Text("", 0, 0); const mermaid = (args.mermaid as string) ?? ""; const preview = mermaid.split("\n")[0]?.slice(0, 60) ?? ""; text.setText(`◆ obsidian_excalidraw_mermaid ${preview}…`); return text; }, renderResult: (result: any, options: any, theme: Theme, _context: any) => { const text = new Text("", 0, 0); if (options.isPartial) return text.setText(theme.fg("warning", "running…")), text; const d = result.details as CliDetails | undefined; if (!d) return text.setText(theme.fg("dim", "(no details)")), text; if (d.error) return text.setText(theme.fg("error", `✗ ${d.error}`)), text; try { const parsed = JSON.parse(d.stdout ?? "{}"); if (parsed.success && parsed.created) { text.setText(theme.fg("success", `✓ created ${parsed.created} (${parsed.elements} elements)`)); } else if (parsed.success && parsed.target) { text.setText(theme.fg("success", `✓ ${parsed.newElements} new elements inserted into ${parsed.target}`)); } else if (parsed.error) { text.setText(theme.fg("error", `✗ ${parsed.error}`)); } else { text.setText(theme.fg("dim", d.stdout?.slice(0, 120) ?? "")); } } catch { text.setText(theme.fg("dim", d.stdout?.slice(0, 120) ?? "")); } return text; }, }); // Tool: obsidian_active_file — returns the currently open file in Obsidian. // Uses a fixed, audited eval script (like excalidraw_export) so it is safe // even when the user permits eval for other purposes. pi.registerTool({ name: "obsidian_active_file", label: "Active file", description: "Get the currently active file in Obsidian (path, name, extension) using an audited fixed script.", promptSnippet: "Obsidian vault: Get the currently open file (path, name)", promptGuidelines: [ "Use obsidian_active_file at the start of a session to discover the currently open file in Obsidian, so other obsidian_* tools can use contextual defaults (file, folder) without the user having to specify them explicitly.", ], parameters: Type.Object({ vault: Type.Optional(Type.String({ description: "Vault name or id (defaults to configured/active vault)" })), }), async execute(_id, params, signal, _onUpdate, ctx) { const script = `(async () => { const file = app.workspace.getActiveFile(); if (!file) return JSON.stringify({ path: null, name: null, extension: null }); return JSON.stringify({ path: file.path, name: file.name, extension: file.extension }); })()`; const details = await executeCommand(ctx, "eval", [`code=${script}`], params.vault as string | undefined, signal, { fixedScript: true }); // Update the session cache so contextual defaults pick up the result. try { const parsed = JSON.parse(details.stdout ?? "{}"); if (parsed.path) { setActiveFile({ path: parsed.path, name: parsed.name ?? parsed.path }); } else { setActiveFile(null); } } catch { setActiveFile(null); } return toToolResult(details); }, renderCall: (args: any, _theme: Theme, _context: any) => { const text = new Text("", 0, 0); text.setText("◆ obsidian_active_file"); return text; }, renderResult: (result: any, options: any, theme: Theme, _context: any) => { const text = new Text("", 0, 0); if (options.isPartial) return text.setText(theme.fg("warning", "running…")), text; const d = result.details as CliDetails | undefined; if (!d) return text.setText(theme.fg("dim", "(no details)")), text; if (d.error) return text.setText(theme.fg("error", `✗ ${d.error}`)), text; try { const parsed = JSON.parse(d.stdout ?? "{}"); if (parsed.path) { text.setText(theme.fg("success", `✓ active: ${parsed.path}`)); } else { text.setText(theme.fg("dim", "(no active file)")); } } catch { text.setText(theme.fg("dim", d.stdout?.slice(0, 120) ?? "")); } return text; }, }); // Tool: obsidian_resolve_link — resolves a [[wikilink]] to a vault path. pi.registerTool({ name: "obsidian_resolve_link", label: "Resolve wikilink", description: "Resolve an Obsidian [[wikilink]] to its actual vault file path. Requires `obsidianCli` to allow the `eval` command.", promptSnippet: "Obsidian vault: Resolve a [[wikilink]] to its file path", promptGuidelines: [ "Use obsidian_resolve_link to convert [[wikilinks]] to actual vault file paths before reading them with obsidian_read or passing them to other tools.", ], parameters: Type.Object({ link: Type.String({ description: "The wikilink text, e.g. 'My Note' or 'folder/Note' or '[[My Note]]'" }), source: Type.Optional(Type.String({ description: "Source file path for relative wikilink resolution" })), vault: Type.Optional(Type.String({ description: "Vault name or id (defaults to configured/active vault)" })), }), async execute(_id, params, signal, _onUpdate, ctx) { const linkText = parseWikilink(params.link as string) ?? (params.link as string); const linkJson = JSON.stringify(linkText); const sourceJson = JSON.stringify((params.source as string) ?? ""); const script = `(async () => { const linktext = ${linkJson}; const sourcePath = ${sourceJson} || ""; const dest = app.metadataCache.getFirstLinkpathDest(linktext, sourcePath); if (!dest) return JSON.stringify({ resolved: false, path: null, name: null }); return JSON.stringify({ resolved: true, path: dest.path, name: dest.name, extension: dest.extension }); })()`; const details = await executeCommand(ctx, "eval", [`code=${script}`], params.vault as string | undefined, signal, { confirm: false, fixedScript: true }); return toToolResult(details); }, renderCall: (args: any, _theme: Theme, _context: any) => { const text = new Text("", 0, 0); const link = (args.link as string) ?? ""; text.setText(`◆ obsidian_resolve_link ${link}`); return text; }, renderResult: (result: any, options: any, theme: Theme, _context: any) => { const text = new Text("", 0, 0); if (options.isPartial) return text.setText(theme.fg("warning", "running…")), text; const d = result.details as CliDetails | undefined; if (!d) return text.setText(theme.fg("dim", "(no details)")), text; if (d.error) return text.setText(theme.fg("error", `✗ ${d.error}`)), text; try { const parsed = JSON.parse(d.stdout ?? "{}"); if (parsed.resolved) { text.setText(theme.fg("success", `✓ ${parsed.name} → ${parsed.path}`)); } else { text.setText(theme.fg("warning", "✗ wikilink not resolved")); } } catch { text.setText(theme.fg("dim", d.stdout?.slice(0, 120) ?? "")); } return text; }, }); // Tool: obsidian_daily — wrapper around daily:read that fixes the // double-slash bug and handles creation via Templater when the note // does not exist yet. pi.registerTool({ name: "obsidian_daily", label: "Daily note", description: "Read today's daily note (or any date) with correct Templater-resolved content. " + "Fixes the upstream double-slash bug in daily:read. " + "When the note does not exist and autoCreate is true, creates it via the Calendar+Templater pipeline. " + "Creation uses an audited fixed script when `allowFixedScripts` is enabled.", promptSnippet: "Obsidian vault: Read or create daily notes with proper Templater processing", promptGuidelines: [ "Use obsidian_daily to read today's daily note (or any date). It correctly processes Templater templates and fixes the double-slash path bug. Use autoCreate=true to create the note if it does not exist yet.", "Prefer obsidian_daily over obsidian_daily_read — the latter is affected by the double-slash bug and may return raw template content instead of the resolved note.", ], parameters: Type.Object({ date: Type.Optional(Type.String({ description: "Date in YYYY-MM-DD format (default: today)" })), autoCreate: Type.Optional(Type.Boolean({ description: "If the daily note does not exist, create it via Templater (default: false)" })), vault: Type.Optional(Type.String({ description: "Vault name or id (defaults to configured/active vault)" })), }), async execute(_id, params, signal, _onUpdate, ctx) { const cfg = effectiveConfig(); const dateStr = (params.date as string) ?? new Date().toISOString().split("T")[0]; const autoCreate = (params.autoCreate as boolean) ?? false; // Get daily-note config from disk (CLI can't read hidden .obsidian/ files). // obsidian vault info=path gives us the absolute vault root. const vaultPathResult = await runCli(pi, cfg, "vault", ["info=path"], { vault: params.vault as string | undefined }); if (vaultPathResult.isError) { throw new Error(`Could not determine vault path: ${vaultPathResult.errorMessage}`); } const vaultRoot = vaultPathResult.stdout.trim(); const dnPath = `${vaultRoot}/.obsidian/daily-notes.json`; let dnConfig: { folder?: string; format?: string; template?: string }; try { dnConfig = JSON.parse(readFileSync(dnPath, "utf8")); } catch (err) { throw new Error(`Could not read Obsidian daily-notes configuration at ${dnPath}: ${err instanceof Error ? err.message : String(err)}`); } const folder = dnConfig.folder ?? ""; const fmt = dnConfig.format ?? "/YYYY-MM-DD"; const d = new Date(dateStr + "T12:00:00Z"); if (isNaN(d.getTime())) throw new Error(`Invalid date: ${dateStr}`); const yyyy = String(d.getUTCFullYear()); const mm = String(d.getUTCMonth() + 1).padStart(2, "0"); const dd = String(d.getUTCDate()).padStart(2, "0"); const monthNames = ["january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december"]; const mmName = monthNames[d.getUTCMonth()]; // Build path from format tokens let path = fmt .replace(/YYYY/g, yyyy) .replace(/MM-MMMM/g, `${mm}-${mmName}`) .replace(/MM/g, mm) .replace(/DD/g, dd) .replace(/YYYY-MM-DD/g, `${yyyy}-${mm}-${dd}`); // Strip leading / to build the full path if (path.startsWith("/")) path = path.slice(1); const fullPath = (folder ? `${folder}/${path}` : path) + ".md"; // Try reading the actual file (not via daily:read which has the double-slash bug). try { const details = await executeCommand(ctx, "read", [`path=${fullPath}`], params.vault as string | undefined, signal); // Check if output looks like a template (Templater <%...%> tags unprocessed). const looksUnprocessed = details.stdout?.includes("<%*") || details.stdout?.includes("<% "); if (looksUnprocessed && autoCreate) { // File exists but is unprocessed — likely the template was copied // without Templater running. Force re-create. throw new Error("Unprocessed template detected; re-creating"); } return toToolResult({ ...details, command: "daily", args: [dateStr] }); } catch (readErr) { // File doesn't exist or is unprocessed — create it. if (!autoCreate) { const msg = readErr instanceof Error ? readErr.message : String(readErr); throw new Error(`Daily note for ${dateStr} not found at ${fullPath}. Set autoCreate=true to create it. (${msg})`); } // Check if eval is allowed for creation. const evalDecision = decide(cfg, undefined, "eval"); if (!evalDecision.allowed) { // Fallback: use obsidian create with template (may not trigger Templater). try { // Confirm destructive write when confirmDestructive is enabled. if (cfg.confirmDestructive && ctx.hasUI && ctx.mode === "tui") { const ok = await ctx.ui.confirm( "obsidian daily create (write)", `Create daily note "${fullPath}" on vault "${getSessionVault() ?? cfg.vault ?? "active"}"?`, ); if (!ok) throw new Error(`Cancelled by user: daily note creation for ${dateStr}`); } const templateName = dnConfig.template ?? "daily template"; const details = await executeCommand(ctx, "create", [ `path=${fullPath}`, `template=${templateName}`, ], params.vault as string | undefined, signal); // Re-read to get the content const reRead = await executeCommand(ctx, "read", [`path=${fullPath}`], params.vault as string | undefined, signal); return toToolResult({ ...reRead, command: "daily", args: [dateStr, "(created via template)"] }); } catch (createErr) { throw new Error( `Failed to create daily note for ${dateStr}. ` + `Enable the audited fixed-script path in obsidianCli to use the Templater-aware creation path. ` + `(${createErr instanceof Error ? createErr.message : String(createErr)})`, ); } } // Eval is allowed — use Templater-powered creation. const templatePath = (dnConfig as Record).template as string ?? "005 Templates/daily template.md"; const targetJson = JSON.stringify(fullPath); const templateJson = JSON.stringify(templatePath); const script = `(async () => { const targetPath = ${targetJson}; const templatePath = ${templateJson}; // Check if file already exists const existing = app.vault.getAbstractFileByPath(targetPath); if (existing) { // Re-trigger Templater on existing file by touching it const content = await app.vault.read(existing); await app.vault.modify(existing, content); return JSON.stringify({ success: true, existed: true, path: targetPath }); } // Create parent folders const parent = targetPath.substring(0, targetPath.lastIndexOf("/")); const parentFolder = app.vault.getAbstractFileByPath(parent); if (!parentFolder) { await app.vault.createFolder(parent); } // Read template const tpl = app.vault.getAbstractFileByPath(templatePath); if (!tpl) return JSON.stringify({ success: false, error: "Template not found: " + templatePath }); const raw = await app.vault.read(tpl); // Create file with template content — Templater's trigger_on_file_creation // hook will process it automatically. await app.vault.create(targetPath, raw); return JSON.stringify({ success: true, created: true, path: targetPath }); })()`; const createResult = await executeCommand(ctx, "eval", [`code=${script}`], params.vault as string | undefined, signal, { confirm: false, fixedScript: true }); const parsed = JSON.parse(createResult.stdout ?? "{}"); if (!parsed.success) { throw new Error(`Failed to create daily note: ${parsed.error ?? "unknown"}`); } // Small delay to let Templater finish processing await new Promise((r) => setTimeout(r, 300)); // Read the created note const reRead = await executeCommand(ctx, "read", [`path=${fullPath}`], params.vault as string | undefined, signal); return toToolResult({ ...reRead, command: "daily", args: [dateStr, parsed.created ? "(created)" : "(existed)"] }); } }, renderCall: (args: any, theme: Theme, _context: any) => { const text = new Text("", 0, 0); const date = (args.date as string) ?? new Date().toISOString().split("T")[0]; const auto = args.autoCreate ? " +create" : ""; text.setText(theme.fg("toolTitle", theme.bold(`◆ obsidian daily ${date}${auto}`))); return text; }, renderResult: (result: any, options: any, theme: Theme, _context: any) => { const text = new Text("", 0, 0); if (options.isPartial) return text.setText(theme.fg("warning", "running…")), text; const d = result.details as CliDetails | undefined; if (!d) return text.setText(theme.fg("dim", "(no details)")), text; if (d.error) return text.setText(theme.fg("error", `✗ ${d.error}`)), text; let out = theme.fg("success", `✓ daily ${d.args?.[0] ?? "today"}`) + theme.fg("dim", ` · ${d.durationMs ?? 0}ms`); if (d.stdout) { const summary = summarizeOutput(d.stdout, options.expanded ? 200 : 10); out += `\n${summary.lines.join("\n")}`; if (summary.truncated) out += `\n${theme.fg("dim", `… ${summary.total - summary.lines.length} more lines`)}`; } text.setText(out); return text; }, }); // Tool: obsidian_dataview_query — executes DQL queries via the Dataview // plugin API and returns structured results (table/list/task). // Requires the Dataview community plugin to be installed and enabled. pi.registerTool({ name: "obsidian_dataview_query", label: "Dataview query", description: "Execute a Dataview DQL query against the vault and return structured results. " + "Supports LIST, TABLE, TASK, CALENDAR, and FROM/WHERE/SORT clauses. " + "Requires the Dataview plugin to be installed and enabled.", promptSnippet: "Obsidian vault: Execute Dataview DQL queries and return structured results", promptGuidelines: guidelines["dataview_query"], parameters: Type.Object({ query: Type.String({ description: "DQL query, e.g. 'LIST FROM #projects SORT file.name ASC' or 'TABLE tags, file.cday FROM \"notes\"'" }), vault: Type.Optional(Type.String({ description: "Vault name or id (defaults to configured/active vault)" })), }), async execute(_id, params, signal, _onUpdate, ctx) { const queryJson = JSON.stringify((params as Record).query as string); const script = `(async () => { const dvPlugin = app.plugins.plugins.dataview; if (!dvPlugin?.api) { return JSON.stringify({ success: false, error: "Dataview plugin is not installed or not enabled. Install it from Obsidian community plugins." }); } const query = ${queryJson}; let result; try { // dataview.api.query() executes a DQL query and returns { successful, value } result = await dvPlugin.api.query(query); } catch (e) { return JSON.stringify({ success: false, error: "Dataview query failed: " + (e.message || String(e)) }); } if (!result.successful) { return JSON.stringify({ success: false, error: "Query returned unsuccessful: " + JSON.stringify(result) }); } const value = result.value; const output = { success: true, type: value.type }; if (value.type === "table") { output.headers = value.headers; output.values = value.values.map((row) => row.map((cell) => { if (cell === null || cell === undefined) return null; if (typeof cell === "object" && cell !== null) { // Dataview objects: { path, name, type }, links, dates, etc. if (cell.path !== undefined) return cell.path; // file/link if (cell.ts !== undefined) return cell.toString(); // date if (cell.toString && typeof cell.toString === "function") { const s = cell.toString(); return s === "[object Object]" ? JSON.stringify(cell) : s; } return JSON.stringify(cell); } return cell; }) ); } else if (value.type === "list") { output.values = value.values.map((item) => { if (item === null || item === undefined) return null; if (typeof item === "object" && item !== null) { if (item.path !== undefined) return item.path; if (item.toString && typeof item.toString === "function") { const s = item.toString(); return s === "[object Object]" ? JSON.stringify(item) : s; } return JSON.stringify(item); } return item; }); } else if (value.type === "task") { output.values = value.values.map((t) => ({ text: t.text, completed: t.completed, path: t.path, line: t.line, due: t.due?.toString?.() ?? null, priority: t.priority ?? null, })); } else { output.raw = JSON.stringify(value); } return JSON.stringify(output); })()`; const details = await executeCommand(ctx, "eval", [`code=${script}`], params.vault as string | undefined, signal, { confirm: false, fixedScript: true }); return toToolResult(details); }, renderCall: (args: any, _theme: Theme, _context: any) => { const text = new Text("", 0, 0); const query = (args.query as string) ?? ""; const preview = query.length > 60 ? query.slice(0, 57) + "…" : query; text.setText(`◆ obsidian_dataview_query ${preview}`); return text; }, renderResult: (result: any, options: any, theme: Theme, _context: any) => { const text = new Text("", 0, 0); if (options.isPartial) return text.setText(theme.fg("warning", "running…")), text; const d = result.details as CliDetails | undefined; if (!d) return text.setText(theme.fg("dim", "(no details)")), text; if (d.error) return text.setText(theme.fg("error", `✗ ${d.error}`)), text; try { const parsed = JSON.parse(d.stdout ?? "{}"); if (parsed.success) { const count = parsed.values?.length ?? 0; text.setText(theme.fg("success", `✓ ${parsed.type} query · ${count} results`) + theme.fg("dim", ` · ${d.durationMs ?? 0}ms`)); } else { text.setText(theme.fg("error", `✗ ${parsed.error ?? "unknown"}`)); } } catch { text.setText(theme.fg("dim", d.stdout?.slice(0, 120) ?? "")); } return text; }, }); // Tool: obsidian_dataviewjs_run — executes DataviewJS code in a proxy // context that captures dv.list(), dv.table(), dv.taskList() etc. outputs // and returns structured data (no HTML rendering needed). // This executes user-supplied JavaScript and is opt-in, unlike the fixed // wrappers above. The default config therefore never registers it. if (config.allowDataviewJs) { pi.registerTool({ name: "obsidian_dataviewjs_run", label: "DataviewJS run", description: "Execute DataviewJS code against the vault and return captured outputs. " + "The code runs with `dv` (Dataview API) available. dv.list(), dv.table(), " + "dv.pages(), dv.page(), dv.current() are all accessible. " + "Requires the Dataview plugin to be installed and enabled.", promptSnippet: "Obsidian vault: Execute DataviewJS code and return captured outputs", promptGuidelines: guidelines["dataviewjs_run"], parameters: Type.Object({ code: Type.String({ description: "DataviewJS code. Receives `dv`. Use dv.list(), dv.table(), dv.pages(), etc. Example: 'dv.list(dv.pages(\"#projects\").file.name)'" }), vault: Type.Optional(Type.String({ description: "Vault name or id (defaults to configured/active vault)" })), }), async execute(_id, params, signal, _onUpdate, ctx) { const codeJson = JSON.stringify((params as Record).code as string); const script = `(async () => { const dvPlugin = app.plugins.plugins.dataview; if (!dvPlugin?.api) { return JSON.stringify({ success: false, error: "Dataview plugin is not installed or not enabled. Install it from Obsidian community plugins." }); } const userCode = ${codeJson}; const outputs = []; // Build a proxy dv that captures list/table/taskList calls and delegates // everything else to the real dataview API. const realApi = dvPlugin.api; const proxyDv = { // Capture dv.list(items) list: (items) => { const arr = Array.isArray(items) ? items : [...(items || [])]; const serialized = arr.map((item) => { if (item === null || item === undefined) return null; if (typeof item === "object" && item !== null) { if (item.path !== undefined) return item.path; if (item.toString && typeof item.toString === "function") { const s = item.toString(); return s === "[object Object]" ? JSON.stringify(item) : s; } return JSON.stringify(item); } return item; }); outputs.push({ type: "list", values: serialized }); }, // Capture dv.table(headers, rows) table: (headers, rows) => { const serializedRows = (rows || []).map((row) => (Array.isArray(row) ? row : [row]).map((cell) => { if (cell === null || cell === undefined) return null; if (typeof cell === "object" && cell !== null) { if (cell.path !== undefined) return cell.path; if (cell.toString && typeof cell.toString === "function") { const s = cell.toString(); return s === "[object Object]" ? JSON.stringify(cell) : s; } return JSON.stringify(cell); } return cell; }) ); outputs.push({ type: "table", headers: headers || [], values: serializedRows }); }, // Capture dv.taskList(tasks, groupByFile) taskList: (tasks, groupByFile) => { const arr = Array.isArray(tasks) ? tasks : [...(tasks || [])]; const serialized = arr.map((t) => ({ text: t.text, completed: t.completed, path: t.path, line: t.line, due: t.due?.toString?.() ?? null, priority: t.priority ?? null, })); outputs.push({ type: "taskList", groupByFile: !!groupByFile, values: serialized }); }, // Capture dv.markdown(text) - generic text output markdown: (text) => { outputs.push({ type: "markdown", text: String(text) }); }, // Capture dv.paragraph(text) paragraph: (text) => { outputs.push({ type: "paragraph", text: String(text) }); }, // Capture dv.header(level, text) header: (level, text) => { outputs.push({ type: "header", level, text: String(text) }); }, // Capture dv.el(tag, text) - HTML element output el: (tag, text) => { outputs.push({ type: "el", tag: String(tag), text: text != null ? String(text) : "" }); }, // Capture dv.span(text) span: (text) => { outputs.push({ type: "span", text: String(text) }); }, }; // Forward everything else to the real API: pages(), page(), current(), // date(), fileLink(), fileTasks(), etc. const dv = new Proxy(proxyDv, { get(target, prop) { if (prop in target) return target[prop]; if (typeof realApi[prop] === "function") { return realApi[prop].bind(realApi); } return realApi[prop]; }, }); try { // Execute user code. Wrap in async IIFE if it uses await. const fn = new Function("dv", userCode); const result = fn(dv); // If result is a promise, await it if (result && typeof result.then === "function") { await result; } return JSON.stringify({ success: true, outputs }); } catch (e) { return JSON.stringify({ success: false, error: "DataviewJS execution failed: " + (e.message || String(e)), partialOutputs: outputs.length > 0 ? outputs : undefined }); } })()`; const details = await executeCommand(ctx, "eval", [`code=${script}`], params.vault as string | undefined, signal, { fixedScript: true, confirm: true }); return toToolResult(details); }, renderCall: (args: any, _theme: Theme, _context: any) => { const text = new Text("", 0, 0); const code = (args.code as string) ?? ""; const preview = code.split("\n")[0]?.slice(0, 55) ?? ""; text.setText(`◆ obsidian_dataviewjs_run ${preview}${code.length > 55 ? "…" : ""}`); return text; }, renderResult: (result: any, options: any, theme: Theme, _context: any) => { const text = new Text("", 0, 0); if (options.isPartial) return text.setText(theme.fg("warning", "running…")), text; const d = result.details as CliDetails | undefined; if (!d) return text.setText(theme.fg("dim", "(no details)")), text; if (d.error) return text.setText(theme.fg("error", `✗ ${d.error}`)), text; try { const parsed = JSON.parse(d.stdout ?? "{}"); if (parsed.success) { const count = parsed.outputs?.length ?? 0; const types = parsed.outputs?.map((o: any) => o.type).join(", ") ?? ""; text.setText(theme.fg("success", `✓ ${count} output(s)`) + theme.fg("dim", ` · ${types} · ${d.durationMs ?? 0}ms`)); } else { text.setText(theme.fg("error", `✗ ${parsed.error ?? "unknown"}`)); } } catch { text.setText(theme.fg("dim", d.stdout?.slice(0, 120) ?? "")); } return text; }, }); } // Tool: obsidian_tasks_query — queries the Obsidian Tasks plugin cache with // server-side filtering and returns tasks with due dates, priorities, // urgency, recurrence, and scheduling metadata. pi.registerTool({ name: "obsidian_tasks_query", label: "Tasks plugin query", description: "Query tasks from the Obsidian Tasks plugin cache. " + "Filter by path, due date (overdue/today/upcoming), status, and priority. " + "Returns tasks with due dates, urgency scores, recurrence rules, and metadata. " + "Requires the Tasks plugin (obsidian-tasks-plugin) to be installed and enabled.", promptSnippet: "Obsidian vault: Query Tasks plugin cache with due dates, priorities, urgency", promptGuidelines: guidelines["tasks_plugin"], parameters: Type.Object({ path: Type.Optional(Type.String({ description: "Filter by folder path prefix, e.g. '002 Areas/Journal/Daily' or '001 Projects'" })), due: Type.Optional(Type.Union([Type.Literal("overdue"), Type.Literal("today"), Type.Literal("upcoming"), Type.Literal("any")], { description: "Filter by due date: overdue (before today), today, upcoming (future), any (default: any)" })), status: Type.Optional(Type.Union([Type.Literal("todo"), Type.Literal("done"), Type.Literal("all")], { description: "Filter by status (default: todo)" })), priority: Type.Optional(Type.Union([Type.Literal("highest"), Type.Literal("high"), Type.Literal("medium"), Type.Literal("low"), Type.Literal("none")], { description: "Filter by priority" })), limit: Type.Optional(Type.Number({ description: "Max results (default: 20, max: 100)" })), sort: Type.Optional(Type.Union([Type.Literal("due"), Type.Literal("urgency"), Type.Literal("path")], { description: "Sort order (default: due)" })), vault: Type.Optional(Type.String({ description: "Vault name or id (defaults to configured/active vault)" })), }), async execute(_id, params, signal, _onUpdate, ctx) { const p = params as Record; const pathFilter = JSON.stringify(p.path ?? ""); const dueFilter = JSON.stringify(p.due ?? "any"); const statusFilter = JSON.stringify(p.status ?? "todo"); const priorityFilter = JSON.stringify(p.priority ?? ""); const limit = Math.min(Number(p.limit) || 20, 100); const sort = JSON.stringify(p.sort ?? "due"); const script = `(() => { const tasksPlugin = app.plugins.plugins['obsidian-tasks-plugin']; if (!tasksPlugin?.cache?.getTasks) { return JSON.stringify({ success: false, error: "Tasks plugin is not installed or not enabled. Install it from Obsidian community plugins." }); } const all = tasksPlugin.cache.getTasks(); if (!all || all.length === 0) { return JSON.stringify({ success: true, tasks: [], total: 0 }); } const pathFilter = ${pathFilter}; const dueFilter = ${dueFilter}; const statusFilter = ${statusFilter}; const priorityFilter = ${priorityFilter}; const limit = ${limit}; const sortBy = ${sort}; const now = new Date(); const today = now.toISOString().split("T")[0]; // YYYY-MM-DD const fmtDate = (d) => { if (!d) return null; if (typeof d === "string") return d.slice(0, 10); if (typeof d.format === "function") return d.format("YYYY-MM-DD"); return String(d).slice(0, 10); }; const fmtISO = (d) => { if (!d) return null; if (typeof d === "string") return d; if (typeof d.toISOString === "function") return d.toISOString(); return String(d); }; let filtered = all; // Path filter if (pathFilter) { filtered = filtered.filter(t => (t.taskLocation?.path || "").startsWith(pathFilter)); } // Status filter // Tasks plugin stores status as an object: { symbol: "x", type: "DONE", ... } const statusDone = (t) => t.status?.type === "DONE" || t.status?.symbol === "x" || t.status === "done"; const statusCancelled = (t) => t.status?.type === "CANCELLED" || t.status?.symbol === "-" || t.status === "cancelled"; if (statusFilter === "todo") { filtered = filtered.filter(t => !statusDone(t) && !statusCancelled(t)); } else if (statusFilter === "done") { filtered = filtered.filter(t => statusDone(t)); } // Priority filter const PRIO_MAP = { highest: "1", high: "2", medium: "3", low: "4", none: "5" }; if (priorityFilter) { const targetPrio = PRIO_MAP[priorityFilter]; if (targetPrio) { filtered = filtered.filter(t => String(t.priority) === targetPrio); } } // Due date filter if (dueFilter === "overdue") { filtered = filtered.filter(t => { const d = fmtDate(t._dueDate); return d && d < today; }); } else if (dueFilter === "today") { filtered = filtered.filter(t => { const d = fmtDate(t._dueDate); return d === today; }); } else if (dueFilter === "upcoming") { filtered = filtered.filter(t => { const d = fmtDate(t._dueDate); return d && d >= today; }); } // Sort if (sortBy === "due") { filtered.sort((a, b) => { const da = fmtDate(a._dueDate); const db = fmtDate(b._dueDate); if (!da) return 1; if (!db) return -1; return da.localeCompare(db); }); } else if (sortBy === "urgency") { filtered.sort((a, b) => (b._urgency || 0) - (a._urgency || 0)); } else if (sortBy === "path") { filtered.sort((a, b) => (a.taskLocation?.path || "").localeCompare(b.taskLocation?.path || "")); } // Slice const sliced = filtered.slice(0, limit); const tasks = sliced.map(t => ({ description: t.description || "", status: t.status?.type || t.status?.symbol || t.status || "unknown", due: fmtISO(t._dueDate), scheduled: fmtISO(t._scheduledDate), start: fmtISO(t._startDate), created: fmtISO(t._createdDate), done: fmtISO(t._doneDate), priority: ({ "1": "highest", "2": "high", "3": "medium", "4": "low", "5": "none" })[String(t.priority)] || t.priority || "normal", urgency: typeof t._urgency === "number" ? Math.round(t._urgency * 100) / 100 : null, recurrence: t.recurrence?.rule || null, tags: t.tags || [], path: t.taskLocation?.path || "", line: t.taskLocation?.lineNumber || 0, originalMarkdown: t.originalMarkdown || "", hasChildren: (t.children || []).length > 0, })); return JSON.stringify({ success: true, tasks, total: filtered.length, returned: tasks.length }); })()`; const details = await executeCommand(ctx, "eval", [`code=${script}`], params.vault as string | undefined, signal, { confirm: false, fixedScript: true }); return toToolResult(details); }, renderCall: (args: any, _theme: Theme, _context: any) => { const text = new Text("", 0, 0); const parts: string[] = []; if (args.due && args.due !== "any") parts.push(args.due as string); if (args.path) parts.push((args.path as string).split("/").pop() || (args.path as string).slice(-25)); if (args.priority) parts.push(args.priority as string); if (args.status && args.status !== "todo") parts.push(args.status as string); const label = parts.length > 0 ? parts.join(" · ") : "all"; const limit = args.limit ?? 20; text.setText(`◆ obsidian_tasks_query ${label} — ${limit}`); return text; }, renderResult: (result: any, options: any, theme: Theme, _context: any) => { const text = new Text("", 0, 0); if (options.isPartial) return text.setText(theme.fg("warning", "running…")), text; const d = result.details as CliDetails | undefined; if (!d) return text.setText(theme.fg("dim", "(no details)")), text; if (d.error) return text.setText(theme.fg("error", `✗ ${d.error}`)), text; try { const parsed = JSON.parse(d.stdout ?? "{}"); if (parsed.success) { const returned = parsed.returned ?? parsed.tasks?.length ?? 0; const total = parsed.total ?? "?"; const summary = returned === total ? `${returned} tasks` : `${returned}/${total} tasks`; text.setText(theme.fg("success", `✓ ${summary}`) + theme.fg("dim", ` · ${d.durationMs ?? 0}ms`)); } else { text.setText(theme.fg("error", `✗ ${parsed.error ?? "unknown"}`)); } } catch { text.setText(theme.fg("dim", d.stdout?.slice(0, 120) ?? "")); } return text; }, }); }