From a0ba6eb0a9f62cd03c4b41ca404ea739d01c09e4 Mon Sep 17 00:00:00 2001 From: Logan Yang Date: Thu, 5 Mar 2026 18:44:43 -0800 Subject: [PATCH] feat: add obsidianBases CLI tool (read-only) (#2265) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add obsidianBases CLI tool (read-only) Add obsidianBases — a read-only category-based CLI tool for querying Obsidian Base (database) files. Supports three commands: bases (list all Base files), base:views (list views in a Base file), and base:query (query data from a Base view). Follows the same pattern as existing v1 CLI tools (obsidianProperties, obsidianTasks, obsidianLinks, obsidianTemplates). Co-Authored-By: Claude Opus 4.6 * fix: clarify file/path field descriptions in obsidianBases schema Change "Required" to "Used" in file description and add "Alternative to file=" in path description to accurately reflect that either file OR path satisfies the requirement, not both. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- designdocs/OBSIDIAN_CLI_INTEGRATION.md | 85 ++++++++- .../chainRunner/utils/AgentReasoningState.ts | 12 ++ .../chainRunner/utils/toolExecution.ts | 2 + src/tools/ObsidianCliTools.test.ts | 162 ++++++++++++++++-- src/tools/ObsidianCliTools.ts | 77 +++++++-- src/tools/builtinTools.ts | 18 ++ 6 files changed, 326 insertions(+), 30 deletions(-) diff --git a/designdocs/OBSIDIAN_CLI_INTEGRATION.md b/designdocs/OBSIDIAN_CLI_INTEGRATION.md index 7f3d9af0..64728978 100644 --- a/designdocs/OBSIDIAN_CLI_INTEGRATION.md +++ b/designdocs/OBSIDIAN_CLI_INTEGRATION.md @@ -83,6 +83,7 @@ All v1 tools are **read-only or direct-execution** (no confirmation UX required) | **obsidianRandomRead** | `random:read` | Read-only. Standalone tool (single command). Continues from v0. | | **obsidianLinks** | `backlinks`, `links`, `orphans`, `unresolved` | All read-only | | **obsidianTemplates** | `templates`, `template:read` | Read-only. `template:insert` deferred (requires active file context). Moved from v2. | +| **obsidianBases** | `bases`, `base:views`, `base:query` | All read-only | ### v2 (Future — ~9 commands: 3 mutations on existing tools + 2 new tools) @@ -92,7 +93,6 @@ v2 introduces **confirmation-required mutations** on existing v1 tools and adds |------|----------|-------| | **obsidianProperties** _(v1 extension)_ | `property:set`, `property:remove` | Light confirmation in chat before executing. Extends v1 read-only tool. | | **obsidianTasks** _(v1 extension)_ | `task` (toggle/done/todo/status) | Light confirmation in chat before executing. Extends v1 read-only tool. | -| **obsidianBases** | `bases`, `base:views`, `base:query` | Read-only. `base:create` deferred to later phase. | | **obsidianBookmarks** | `bookmarks`, `bookmark` | `bookmark` (add) gated by mutation setting | ### Excluded from Tool System @@ -663,7 +663,88 @@ obsidian template:read name="Daily Note" --- -### A.7 Error Responses +### A.7 `obsidianBases` — Base Database Queries + +#### `bases` + +List all Base (database) files in the vault. + +``` +obsidian bases +``` + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `total` | No | Return only the count of Base files. | + +**Output**: One Base file per line. + +``` +Contacts.base +Projects.base +Tasks.base +``` + +**Output (total)**: Single number. + +#### `base:views` + +List views defined in a Base file. + +``` +obsidian base:views file="Projects" +obsidian base:views path="Databases/Projects.base" +``` + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `file=` | No* | Target Base file by name (without extension). | +| `path=` | No* | Target Base file by vault-relative path. | + +\* One of `file` or `path` is required. + +**Output**: One view name per line. + +``` +All Items +By Status +Kanban +``` + +#### `base:query` + +Query data from a Base view. + +``` +obsidian base:query file="Projects" view="All Items" +obsidian base:query path="Databases/Projects.base" format=csv +``` + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `file=` | No* | Target Base file by name (without extension). | +| `path=` | No* | Target Base file by vault-relative path. | +| `view=` | No | View name to query. Omit for default view. | +| `format=` | No | Output format (e.g., `csv`). Omit for default text. | +| `total` | No | Return only the row count. | + +\* One of `file` or `path` is required. + +**Output (default text)**: Tabular data, one row per line. + +**Output (csv)**: CSV-formatted data. + +``` +Name,Status +Alpha,Active +Beta,Done +``` + +**Output (total)**: Single number. + +--- + +### A.8 Error Responses All commands return consistent error formats: diff --git a/src/LLMProviders/chainRunner/utils/AgentReasoningState.ts b/src/LLMProviders/chainRunner/utils/AgentReasoningState.ts index baaef8b9..08045d4f 100644 --- a/src/LLMProviders/chainRunner/utils/AgentReasoningState.ts +++ b/src/LLMProviders/chainRunner/utils/AgentReasoningState.ts @@ -244,6 +244,12 @@ export function summarizeToolResult( } return "Listed templates"; } + case "obsidianBases": { + const command = args?.command as string | undefined; + if (command === "base:views") return "Listed base views"; + if (command === "base:query") return "Queried base data"; + return "Listed bases"; + } case "indexVault": return "Indexed vault"; case "updateMemory": @@ -407,6 +413,12 @@ export function summarizeToolCall( } return "Listing templates"; } + case "obsidianBases": { + const command = args?.command as string | undefined; + if (command === "base:views") return "Listing base views"; + if (command === "base:query") return "Querying base data"; + return "Listing bases"; + } case "indexVault": return "Indexing vault"; case "updateMemory": diff --git a/src/LLMProviders/chainRunner/utils/toolExecution.ts b/src/LLMProviders/chainRunner/utils/toolExecution.ts index 41c84d7e..ea9a7c05 100644 --- a/src/LLMProviders/chainRunner/utils/toolExecution.ts +++ b/src/LLMProviders/chainRunner/utils/toolExecution.ts @@ -174,6 +174,7 @@ export function getToolDisplayName(toolName: string): string { obsidianTasks: "tasks (CLI)", obsidianLinks: "links (CLI)", obsidianTemplates: "templates (CLI)", + obsidianBases: "bases (CLI)", }; return displayNameMap[toolName] || toolName; @@ -203,6 +204,7 @@ export function getToolEmoji(toolName: string): string { obsidianTasks: "✅", obsidianLinks: "🔗", obsidianTemplates: "📄", + obsidianBases: "🗄️", }; return emojiMap[toolName] || "🔧"; diff --git a/src/tools/ObsidianCliTools.test.ts b/src/tools/ObsidianCliTools.test.ts index 0c560915..244834fd 100644 --- a/src/tools/ObsidianCliTools.test.ts +++ b/src/tools/ObsidianCliTools.test.ts @@ -1,4 +1,5 @@ import { + obsidianBasesTool, obsidianDailyNoteTool, obsidianLinksTool, obsidianPropertiesTool, @@ -159,17 +160,17 @@ describe("obsidianDailyNoteTool", () => { buildFailedResult("daily:read", "EFAIL", "Daily note plugin not enabled", 1) ); - await expect( - (obsidianDailyNoteTool as any).invoke({ command: "daily:read" }) - ).rejects.toThrow("Daily note plugin not enabled"); + await expect((obsidianDailyNoteTool as any).invoke({ command: "daily:read" })).rejects.toThrow( + "Daily note plugin not enabled" + ); }); test("throws ENOENT failure with actionable message", async () => { mockedRunCommand.mockResolvedValue(buildFailedResult("daily:read", "ENOENT", "")); - await expect( - (obsidianDailyNoteTool as any).invoke({ command: "daily:read" }) - ).rejects.toThrow("CLI binary not found"); + await expect((obsidianDailyNoteTool as any).invoke({ command: "daily:read" })).rejects.toThrow( + "CLI binary not found" + ); }); }); @@ -242,9 +243,9 @@ describe("obsidianPropertiesTool", () => { // When error code is present, it takes precedence over exit code in error message mockedRunCommand.mockResolvedValue(buildFailedResult("properties", "EFAIL", "", 1)); - await expect( - (obsidianPropertiesTool as any).invoke({ command: "properties" }) - ).rejects.toThrow("error code EFAIL"); + await expect((obsidianPropertiesTool as any).invoke({ command: "properties" })).rejects.toThrow( + "error code EFAIL" + ); }); }); @@ -351,7 +352,9 @@ describe("obsidianLinksTool", () => { }); test("orphans returns file list", async () => { - mockedRunCommand.mockResolvedValue(buildSuccessResult("orphans", "Inbox/draft.md\nAttic/old.md")); + mockedRunCommand.mockResolvedValue( + buildSuccessResult("orphans", "Inbox/draft.md\nAttic/old.md") + ); const response = await (obsidianLinksTool as any).invoke({ command: "orphans" }); const parsed = JSON.parse(response); @@ -468,16 +471,143 @@ describe("obsidianTemplatesTool", () => { buildFailedResult("templates", "EFAIL", "Templates plugin not enabled", 1) ); - await expect( - (obsidianTemplatesTool as any).invoke({ command: "templates" }) - ).rejects.toThrow("Templates plugin not enabled"); + await expect((obsidianTemplatesTool as any).invoke({ command: "templates" })).rejects.toThrow( + "Templates plugin not enabled" + ); }); test("throws ENOENT failure with actionable message", async () => { mockedRunCommand.mockResolvedValue(buildFailedResult("templates", "ENOENT", "")); - await expect( - (obsidianTemplatesTool as any).invoke({ command: "templates" }) - ).rejects.toThrow("CLI binary not found"); + await expect((obsidianTemplatesTool as any).invoke({ command: "templates" })).rejects.toThrow( + "CLI binary not found" + ); + }); +}); + +// --------------------------------------------------------------------------- +// obsidianBases +// --------------------------------------------------------------------------- + +describe("obsidianBasesTool", () => { + test("bases lists base files", async () => { + mockedRunCommand.mockResolvedValue( + buildSuccessResult("bases", "Contacts.base\nProjects.base\nTasks.base") + ); + + const response = await (obsidianBasesTool as any).invoke({ command: "bases" }); + const parsed = JSON.parse(response); + + expect(parsed.type).toBe("obsidian_cli_bases"); + expect(parsed.command).toBe("bases"); + expect(parsed.vault).toBeNull(); + expect(parsed.content).toBe("Contacts.base\nProjects.base\nTasks.base"); + expect(mockedRunCommand).toHaveBeenCalledWith({ + command: "bases", + vault: undefined, + params: {}, + }); + }); + + test("base:views passes file param", async () => { + mockedRunCommand.mockResolvedValue( + buildSuccessResult("base:views", "All Items\nBy Status\nKanban") + ); + + const response = await (obsidianBasesTool as any).invoke({ + command: "base:views", + file: "Projects", + }); + const parsed = JSON.parse(response); + + expect(parsed.type).toBe("obsidian_cli_bases"); + expect(parsed.content).toBe("All Items\nBy Status\nKanban"); + expect(mockedRunCommand).toHaveBeenCalledWith({ + command: "base:views", + vault: undefined, + params: { file: "Projects" }, + }); + }); + + test("base:views passes path param", async () => { + mockedRunCommand.mockResolvedValue(buildSuccessResult("base:views", "Default View")); + + await (obsidianBasesTool as any).invoke({ + command: "base:views", + path: "Databases/Projects.base", + }); + + expect(mockedRunCommand).toHaveBeenCalledWith({ + command: "base:views", + vault: undefined, + params: { path: "Databases/Projects.base" }, + }); + }); + + test("base:query passes file, view, and format params", async () => { + mockedRunCommand.mockResolvedValue( + buildSuccessResult("base:query", "Name,Status\nAlpha,Active\nBeta,Done") + ); + + const response = await (obsidianBasesTool as any).invoke({ + command: "base:query", + file: "Projects", + view: "All Items", + format: "csv", + }); + const parsed = JSON.parse(response); + + expect(parsed.content).toBe("Name,Status\nAlpha,Active\nBeta,Done"); + expect(mockedRunCommand).toHaveBeenCalledWith({ + command: "base:query", + vault: undefined, + params: { file: "Projects", view: "All Items", format: "csv" }, + }); + }); + + test("base:query with total flag", async () => { + mockedRunCommand.mockResolvedValue(buildSuccessResult("base:query", "42")); + + await (obsidianBasesTool as any).invoke({ + command: "base:query", + file: "Contacts", + total: true, + }); + + expect(mockedRunCommand).toHaveBeenCalledWith({ + command: "base:query", + vault: undefined, + params: { file: "Contacts", total: true }, + }); + }); + + test("base:views throws when file and path are both missing", async () => { + await expect((obsidianBasesTool as any).invoke({ command: "base:views" })).rejects.toThrow( + "file or path is required for base:views" + ); + }); + + test("base:query throws when file and path are both missing", async () => { + await expect((obsidianBasesTool as any).invoke({ command: "base:query" })).rejects.toThrow( + "file or path is required for base:query" + ); + }); + + test("throws on CLI failure with stderr message", async () => { + mockedRunCommand.mockResolvedValue( + buildFailedResult("bases", "EFAIL", "Bases plugin not enabled", 1) + ); + + await expect((obsidianBasesTool as any).invoke({ command: "bases" })).rejects.toThrow( + "Bases plugin not enabled" + ); + }); + + test("throws ENOENT failure with actionable message", async () => { + mockedRunCommand.mockResolvedValue(buildFailedResult("bases", "ENOENT", "")); + + await expect((obsidianBasesTool as any).invoke({ command: "bases" })).rejects.toThrow( + "CLI binary not found" + ); }); }); diff --git a/src/tools/ObsidianCliTools.ts b/src/tools/ObsidianCliTools.ts index f15a3add..2834deb0 100644 --- a/src/tools/ObsidianCliTools.ts +++ b/src/tools/ObsidianCliTools.ts @@ -195,10 +195,7 @@ const linksSchema = z.object({ .describe( "backlinks — list notes that link TO a given file. links — list outgoing links FROM a file. orphans — list files with no incoming links. unresolved — list wikilinks that don't resolve to any file." ), - file: z - .string() - .optional() - .describe("Target file by name (for backlinks and links commands)."), + file: z.string().optional().describe("Target file by name (for backlinks and links commands)."), path: z .string() .optional() @@ -207,10 +204,7 @@ const linksSchema = z.object({ .boolean() .optional() .describe("Include link counts per source file (for backlinks and unresolved)."), - verbose: z - .boolean() - .optional() - .describe("Include source file for each entry (for unresolved)."), + verbose: z.boolean().optional().describe("Include source file for each entry (for unresolved)."), total: z.boolean().optional().describe("Return only the count."), all: z .boolean() @@ -258,10 +252,7 @@ const templatesSchema = z.object({ .describe( "templates — list all available template names. template:read — read a template's content with variable resolution." ), - name: z - .string() - .optional() - .describe("Template name to read. Required for template:read."), + name: z.string().optional().describe("Template name to read. Required for template:read."), vault: z .string() .optional() @@ -297,3 +288,65 @@ export const obsidianTemplatesTool = createLangChainTool({ }; }, }); + +// --------------------------------------------------------------------------- +// obsidianBases — Base database queries (read-only) +// --------------------------------------------------------------------------- + +const basesSchema = z.object({ + command: z + .enum(["bases", "base:views", "base:query"]) + .describe( + "bases — list all Base files in the vault. base:views — list views defined in a Base file. base:query — query data from a Base view." + ), + file: z + .string() + .optional() + .describe("Target Base file by name (without extension). Used for base:views and base:query."), + path: z + .string() + .optional() + .describe( + "Target Base file by vault-relative path. Alternative to file= for base:views and base:query." + ), + view: z.string().optional().describe("View name to query. For base:query only."), + format: z + .string() + .optional() + .describe("Output format for base:query (e.g., 'csv'). Omit for default text output."), + total: z.boolean().optional().describe("Return only the count."), + vault: z + .string() + .optional() + .describe("Optional vault name to target. Omit to use the active vault."), +}); + +/** + * Tool for querying Obsidian Base (database) files via the official Obsidian CLI. + * Supports listing bases, listing views, and querying data from views. + */ +export const obsidianBasesTool = createLangChainTool({ + name: "obsidianBases", + description: + "Query Obsidian Base (database) files via the official Obsidian CLI: list all bases, list views in a base, or query data from a base view. Read-only.", + schema: basesSchema, + func: async (args) => { + const { command, vault } = args; + if ((command === "base:views" || command === "base:query") && !args.file && !args.path) { + throw new Error(`file or path is required for ${command}`); + } + + const params = buildCliParams(args as Record); + const result = await runObsidianCliCommand({ command, vault, params }); + + if (!result.ok) throwCliFailure(result); + + return { + type: "obsidian_cli_bases", + command: result.command, + vault: vault ?? null, + content: result.stdout.trim(), + durationMs: result.durationMs, + }; + }, +}); diff --git a/src/tools/builtinTools.ts b/src/tools/builtinTools.ts index 426234db..8acda5dc 100644 --- a/src/tools/builtinTools.ts +++ b/src/tools/builtinTools.ts @@ -7,6 +7,7 @@ import { updateMemoryTool } from "./memoryTools"; import { readNoteTool } from "./NoteTools"; import { obsidianRandomReadTool } from "./ObsidianCliDailyTools"; import { + obsidianBasesTool, obsidianDailyNoteTool, obsidianLinksTool, obsidianPropertiesTool, @@ -435,6 +436,23 @@ export function registerCliTools(): void { - This is useful for creating daily notes from templates — read the template first, then use obsidianDailyNote's daily:prepend to populate the note.`, }, }); + + registry.register({ + tool: obsidianBasesTool, + metadata: { + id: "obsidianBases", + displayName: "Bases", + description: "List Base files, views, or query data from Obsidian Bases", + category: "cli", + requiresVault: true, + customPromptInstructions: `For obsidianBases: +- Use to explore structured data in Obsidian Base (database) files. +- bases: list all Base files in the vault. Use total=true for just the count. +- base:views: list the views defined in a Base file. Requires file= or path=. +- base:query: query data from a specific Base view. Requires file= or path=, optionally view= and format=. +- This is read-only — no data modification.`, + }, + }); } /**