mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
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:
parent
d849ade60a
commit
a0ba6eb0a9
6 changed files with 326 additions and 30 deletions
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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] || "🔧";
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in a new issue