feat: add obsidianBases CLI tool (read-only) (#2265)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Logan Yang 2026-03-05 18:44:43 -08:00 committed by GitHub
parent d849ade60a
commit a0ba6eb0a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 326 additions and 30 deletions

View file

@ -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=<name>` | No* | Target Base file by name (without extension). |
| `path=<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=<name>` | No* | Target Base file by name (without extension). |
| `path=<path>` | No* | Target Base file by vault-relative path. |
| `view=<name>` | No | View name to query. Omit for default view. |
| `format=<fmt>` | 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:

View file

@ -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":

View file

@ -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] || "🔧";

View file

@ -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"
);
});
});

View file

@ -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<string, unknown>);
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,
};
},
});

View file

@ -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.`,
},
});
}
/**