Add Obsidian CLI daily/random read tools and reasoning summaries (#2181)

* Add Obsidian CLI daily/random read tools with fallback + reasoning summaries

* chore: move CLI design doc to designdocs/ and remove old TODO doc

- Move docs/OBSIDIAN_CLI_INTEGRATION_DESIGN.md to designdocs/OBSIDIAN_CLI_INTEGRATION.md
- Remove designdocs/todo/OBSIDIAN_CLI_INTEGRATION.md (replaced by PR's design doc)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: gate CLI tools behind Platform.isDesktopApp so they are invisible on mobile

- Move CLI tool registration from static BUILTIN_TOOLS array to registerCliTools()
- Only call registerCliTools() when Platform.isDesktopApp is true
- On mobile: tools are not registered, not shown in settings/UI/reasoning
- Update design doc with platform policy and CLI approach rationale

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: update CLI design doc with finalized category-based tool roadmap

- Replace generic v0/v1/v2 tiers with category-based tool grouping
- Define tools: obsidianDailyNote, obsidianProperties, obsidianTasks,
  obsidianFiles, obsidianLinks, obsidianTemplates, obsidianBases,
  obsidianBookmarks, obsidianTags
- Document excluded commands (destructive, plugin/theme, sync, dev tools)
- Add design rationale for category-based approach
- Update rollout plan to match new tool structure

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: remove search commands from v1 roadmap

search and search:context are redundant with Copilot's existing
keyword + semantic search. Update obsidianFiles to only include
read, append, prepend, random:read. Add search to excluded list.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: restrict file writes to daily notes, add write operations policy

- Remove append/prepend from obsidianFiles (now read-only: read, random:read)
- Daily note append/prepend: direct execution, no Composer diff needed
- Property set/remove and task toggle: require light confirmation
- Arbitrary file writes excluded — use existing Composer tool instead
- Add Write Operations Policy section to design doc

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: remove redundant CLI tools from roadmap, add tool disambiguation

Remove `read` from obsidianFiles (redundant with readNote) and drop
obsidianTags category (redundant with getTagList). Add Tool
Disambiguation section documenting overlap analysis and prompt
instruction guidelines for CLI tools.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: defer confirmation-required CLI mutations from v1 to v2

Move property:set, property:remove, and task toggle/status to v2 tier.
v1 now contains only read-only tools + direct-execution daily note
writes. This ensures v1 ships without needing confirmation UX.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add Appendix A with v1 CLI command reference

Document all 12 v1 CLI commands with parameters, output format, and
real examples. Covers obsidianDailyNote, obsidianProperties,
obsidianTasks, obsidianRandomRead, and obsidianLinks tools. Includes
error response reference.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: implement v1 CLI tools (properties, tasks, links, daily note category)

Add 4 new category-based CLI tools for the Obsidian CLI integration:
- obsidianDailyNote: read, append, prepend, path (replaces v0 obsidianDailyRead)
- obsidianProperties: list vault properties, read per-note properties
- obsidianTasks: list and filter tasks across vault
- obsidianLinks: backlinks, outgoing links, orphans, unresolved links

All tools are desktop-only (gated by Platform.isDesktopApp), invisible on
mobile, and labeled as "Obsidian CLI: ... (Experimental)" in settings.

Includes shared error handling module (cliErrors.ts), reasoning summaries,
tool display names, disambiguation prompt instructions, and 21 new tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: prioritize env CLI binaries and align desktop runtime check

- resolveBinaryCandidates: move env var overrides (OBSIDIAN_CLI_BINARY/
  OBSIDIAN_CLI_PATH) before the default "obsidian" binary so configured
  overrides are tried first, preventing an incompatible PATH binary from
  blocking a valid configured override.
- builtinTools: replace Platform.isDesktopApp with isDesktopRuntime()
  (now exported) so CLI tools are registered on both modern isDesktopApp
  and legacy isDesktop runtimes, matching the check used in the executor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor: single CLI master toggle, remove v0 overlap, extract buildCliParams

- Group all CLI tools under one "Obsidian CLI (Experimental)" section
  with a single master toggle in settings UI
- Remove v0 obsidianDailyRead registration (superseded by v1 obsidianDailyNote)
- Extract buildCliParams helper to reduce repetitive param building
- Use "cli" category for all CLI tool registrations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: align CLI section title with other tool items

Remove horizontal padding from CLI container box so SettingItem
title aligns flush with Vault Search, Web Search, etc. above it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: preserve raw stdout for daily:read to avoid stripping Markdown whitespace

Only trim stdout for non-read commands (append, prepend, path) where
whitespace is not semantically meaningful. Read commands return note content
where leading/trailing whitespace may be intentional.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: add daily create command and obsidianTemplates tool to v1 CLI

- Add 'daily' command to obsidianDailyNote: creates today's daily note
  from configured template if it doesn't exist
- Add obsidianTemplates category tool with 'templates' (list) and
  'template:read' (read content) commands
- Register obsidianTemplates in builtinTools.ts with cli category
- Update AgentReasoningState and toolExecution with new tool/command cases
- Update obsidianDailyNote prompt instructions to mention 'daily' command
- Move obsidianTemplates from v2 to v1 in design doc; add daily command docs
- Add tests for daily command and full obsidianTemplates tool coverage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: remove daily command, keep only obsidianTemplates addition

The 'daily' CLI command is a UI action (opens the note in Obsidian) and
returns no content, making it unsuitable as an agent tool. Remove it.

Instead, the agent workflow for template-based daily note creation is:
  obsidianTemplates template:read → daily:read (check existence) →
  daily:prepend (write template content if needed)

Update obsidianDailyNote prompt instructions to describe this pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: update CLI tool prompt instructions for template-based daily note workflow

- obsidianDailyNote: add step-by-step guide for creating daily notes from
  templates (templates list → template:read → daily:prepend); note that
  daily:append/prepend auto-create the note if it doesn't exist
- obsidianTemplates: clarify usage of 'templates' and 'template:read',
  cross-reference the daily note creation workflow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: preserve raw stdout for read commands in ObsidianCliDailyTools

Same fix as d2c81b5 for ObsidianCliTools: daily:read and random:read
commands return full markdown note content, so trimming stdout can silently
alter formatting-sensitive notes. Both tools now return raw stdout.

Co-Authored-By: Claude Sonnet 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 17:40:07 -08:00 committed by GitHub
parent 11f3878244
commit d849ade60a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 2720 additions and 445 deletions

View file

@ -0,0 +1,676 @@
# Obsidian CLI Integration Design (MVP)
**Date:** 2026-02-11
**Status:** Draft — Experimental, Desktop-Only
**Scope:** Copilot plugin tooling (`AutonomousAgent` + `Copilot Plus` tool execution path)
## 1. Problem Statement
Obsidian now ships an official CLI (early access). Copilot already has a mature tool system, but it currently duplicates vault operations through plugin APIs and internal tool implementations. We need a low-risk path to leverage CLI capabilities without rewriting the agent loop.
## 2. Goals
1. Add Obsidian CLI capabilities with minimal changes to existing architecture.
2. Reuse current `ToolRegistry` + LangChain native tool calling flow.
3. Keep the first release desktop-only, safe-by-default, and observable.
4. Ship capabilities in explicit versioned tiers (`v0` -> `v1` -> `v2`) to keep routing and validation manageable.
## 3. Non-Goals (MVP)
1. No full one-to-one wrapper for every CLI command in the first iteration.
2. No mobile support (CLI is desktop-oriented).
3. No prompt/system-prompt rewrites beyond normal tool metadata guidance.
4. No dependency on interactive TUI mode in agent flows.
## 3a. Platform Policy
These tools are **desktop-only** and **experimental**.
- On mobile platforms, CLI tools are **not registered** in the `ToolRegistry` and are completely invisible to the user — they do not appear in tool settings, tool lists, agent reasoning, or any UI surface.
- Registration is gated by `Platform.isDesktopApp` in `initializeBuiltinTools()`. The runtime guard in `ObsidianCliClient.runObsidianCliCommand()` provides defense-in-depth but is not the primary gating mechanism.
- The Obsidian CLI requires `child_process.execFile`, which is only available in the desktop Electron renderer.
## 3b. Design Rationale — CLI Shell-Out vs Internal API
The CLI shell-out approach was chosen because:
1. **Breadth without cost**: The CLI exposes a large and growing command surface. Reimplementing each command via internal Obsidian APIs (`app.vault`, `app.metadataCache`, etc.) would require significant per-command development and maintenance effort.
2. **Forward compatibility**: New CLI commands become available to the tool system without code changes — only the allowlist needs updating.
3. **Safety**: `execFile` (not shell) with strict argument serialization prevents injection. A command allowlist and mutation gating limit blast radius.
4. **Incremental adoption**: The versioned tier system (v0 → v1 → v2) allows cautious rollout, starting with read-only commands.
Internal API tools remain the right choice for operations that need deep integration (e.g., frontmatter processing via `app.fileManager.processFrontMatter()`). The CLI approach is complementary, not a replacement.
## 4. Current Architecture Fit
Relevant integration points:
- `src/tools/ToolRegistry.ts` for tool registration and metadata.
- `src/tools/builtinTools.ts` for built-in tool definitions and initialization.
- `src/LLMProviders/chainRunner/utils/toolExecution.ts` for execution control and user-facing tool status behavior.
- `src/settings/model.ts` and `src/constants.ts` for defaults and persisted settings.
- `src/settings/v2/components/ToolSettingsSection.tsx` for user tool toggles.
This means we can ship CLI support as one additional built-in tool (or a small tool set) without changing chat/message architecture.
## 5. Tool Organization: Category-Based Grouping
Instead of one tool per CLI command (~100 commands = too many tools) or one generic umbrella tool (too vague for LLM routing), commands are grouped into **category-based tools**. Each tool accepts a `command` parameter scoped to its category.
### Design Rationale
- **Clear semantic signals for the LLM**: User asks about daily notes → `obsidianDailyNote` tool. No ambiguity in tool selection.
- **Scales well**: New CLI commands slot into existing category tools without adding new tool registrations.
- **Manageable tool count**: ~10 tools total vs ~25+ individual tools or 1 opaque gateway.
- **Per-category allowlists**: Each tool validates its `command` parameter against a scoped allowlist, limiting blast radius.
### v0 (Current — 2 commands, 2 tools)
| Tool | Commands | Notes |
|------|----------|-------|
| `obsidianDailyRead` | `daily:read` | Read-only. Dedicated tool for v0 simplicity. |
| `obsidianRandomRead` | `random:read` | Read-only. Dedicated tool for v0 simplicity. |
### v1 (Current — 13 commands across 7 tools)
All v1 tools are **read-only or direct-execution** (no confirmation UX required).
| Tool | Commands | Notes |
|------|----------|-------|
| **obsidianDailyNote** | `daily:read`, `daily:append`, `daily:prepend`, `daily:path` | Append/prepend execute directly (see Write Operations Policy). Subsumes v0 `obsidianDailyRead`. |
| **obsidianProperties** | `properties`, `property:read` | Read-only. Write commands (`property:set`, `property:remove`) deferred to v2. |
| **obsidianTasks** | `tasks` | Read-only (task listing). Write command (`task` toggle/status) deferred to v2. |
| **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. |
### v2 (Future — ~9 commands: 3 mutations on existing tools + 2 new tools)
v2 introduces **confirmation-required mutations** on existing v1 tools and adds new tool categories.
| Tool | Commands | Notes |
|------|----------|-------|
| **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
The following CLI commands are **not exposed** to the AI agent:
| Category | Commands | Rationale |
|----------|----------|-----------|
| Destructive file ops | `delete`, `move`, `rename`, `create --overwrite` | Too dangerous for autonomous agent use |
| Plugin/theme management | `plugin:*`, `theme:*`, `snippet:*` | Not an AI task, security risk |
| Sync & history | `sync:*`, `history:restore`, `diff` | User-managed operations, data loss risk |
| UI/workspace control | `tabs`, `tab:open`, `workspace`, `open`, `daily` (open variant) | UI-only, no data value for LLM |
| System commands | `reload`, `restart`, `version`, `vault`, `vaults` | Not useful for agent workflows |
| Developer tools | `eval`, `dev:*`, `devtools` | Arbitrary code execution risk |
| Niche metadata | `aliases`, `wordcount`, `recents`, `hotkeys`, `commands` | Low AI synergy |
| Search | `search`, `search:context`, `search:open` | Redundant with Copilot's existing keyword + semantic search (`localSearch`) |
| File read | `read` | Redundant with Copilot's existing `readNote` tool (see Tool Disambiguation) |
| Tag listing | `tags` | Redundant with Copilot's existing `getTagList` tool (see Tool Disambiguation) |
| Arbitrary file writes | `append`, `prepend`, `create` | File modifications beyond daily notes should go through the existing Composer tool (`writeToFile`/`replaceInFile`) |
### Write Operations Policy
| Operation | Execution model | Tier | Rationale |
|-----------|----------------|------|-----------|
| **Daily note append/prepend** | Direct execution, show result in chat response | v1 | User explicitly asked for the action; daily notes are append-only by nature and low-risk |
| **Arbitrary file append/prepend** | Excluded — use Composer tool | — | File modifications beyond daily notes need the Composer diff/preview UX for safety |
| **Property set/remove** | Light confirmation in chat before executing | v2 | Metadata changes are reversible but should be intentional; deferred to validate read-only tools first |
| **Task toggle/status** | Light confirmation in chat before executing | v2 | Status changes are reversible but should be intentional; deferred to validate read-only tools first |
### Tool Disambiguation
CLI tools are **complementary** to existing internal tools, not replacements. Several CLI commands were evaluated and intentionally excluded because Copilot already has superior internal implementations:
| CLI Command | Existing Internal Tool | Why Internal Wins |
|-------------|----------------------|-------------------|
| `read` | `readNote` | In-process (`app.vault.cachedRead`), 200-line chunking, multi-strategy path resolution (wikilink, basename, partial match), linked notes extraction, mtime metadata. CLI `read` spawns a process, returns raw text, and requires exact paths. |
| `tags` | `getTagList` | In-process (`app.metadataCache`), structured JSON with occurrence counts, frontmatter/inline breakdown, progressive size limiting (500KB cap), configurable `maxEntries`. CLI `tags` returns unstructured text with no filtering. |
| `search`, `search:context` | `localSearch` | Hybrid keyword + semantic search with BM25, query expansion, reranking, time range filtering, tag-aware retrieval. CLI search is basic text matching. |
| `append`, `prepend`, `create` | `writeToFile` / `replaceInFile` | Composer diff/preview UX for safety, line-ending normalization, SEARCH/REPLACE blocks, auto-accept setting. Exception: `daily:append`/`daily:prepend` use CLI directly (low-risk, append-only). |
**Prompt instruction guidelines for CLI tools:**
- Each CLI tool's `customPromptInstructions` must include explicit disambiguation guidance directing the LLM to the correct tool.
- Example: "Use `readNote` for reading specific notes by path. Use `obsidianDailyNote` for daily note operations (read, append, prepend). Use `obsidianRandomRead` for picking a random note."
- When an existing internal tool and a CLI tool could both handle a request, the internal tool should be preferred unless the CLI tool provides unique capability (e.g., daily note path resolution, random note selection, backlink traversal).
## 6. Implementation Design
### 6.1 Service Layer: `ObsidianCliClient`
Located at `src/services/obsidianCli/ObsidianCliClient.ts`. Responsible for:
1. CLI availability/version checks.
2. Safe command execution via `execFile` (not shell).
3. Argument serialization to `parameter=value` and boolean flags.
4. Timeout + output-size limits.
5. Structured error mapping for tool responses.
6. Fallback binary resolution (tries `obsidian` → known macOS app paths).
Key guardrails:
- No shell interpolation.
- Per-tool command allowlist.
- Desktop-only runtime guard.
### 6.2 Tool Layer
Each category tool is a LangChain `StructuredTool` with a zod schema. Tools are registered conditionally in `src/tools/builtinTools.ts` via `registerCliTools()`, gated by `Platform.isDesktopApp`.
### 6.3 Settings
Planned settings fields:
1. `obsidianCliAllowMutations: boolean` (default `false`) — gates write commands across all CLI tools.
2. `obsidianCliTimeoutMs: number` (default `15000`) — per-command timeout.
3. `obsidianCliPath: string` (default `"obsidian"`) — custom binary path override.
## 7. Execution + UX Rules
1. If CLI unavailable, return a clear actionable tool error.
2. If running on mobile, return unsupported-platform error.
3. Surface tool result summaries in existing tool banners/reasoning stream.
4. Keep file-modifying behavior conservative until explicit mutation rollout.
## 8. Testing Plan
Unit tests:
1. Argument serialization (`parameter=value`, booleans, multiline escaping handling).
2. Allowlist enforcement and mutation gating.
3. Timeout/error mapping behavior.
4. Desktop/mobile guards.
Integration tests (mocked process):
1. Successful command execution output.
2. Non-zero exit behavior.
3. Large output truncation/handling.
Manual validation:
1. Run read-only commands from agent mode and verify response quality.
2. Verify settings toggles affect tool availability correctly.
## 9. Rollout Plan
### Phase 0: Design + scaffolding (done)
- Design doc, `ObsidianCliClient` service, `obsidianDailyRead`/`obsidianRandomRead` tools.
- Desktop-only gating via `Platform.isDesktopApp` in `registerCliTools()`.
- Tests for arg serialization, fallback binary resolution, tool wrappers.
### Phase 1: v0 release (current)
- Ship `daily:read` and `random:read` as dedicated read-only tools.
- Validate CLI reliability and UX across desktop platforms.
### Phase 2: v1 expansion
- Refactor v0 `obsidianDailyRead` into category-based `obsidianDailyNote` (read, append, prepend, path).
- Keep `obsidianRandomRead` as standalone tool (single command).
- Add `obsidianProperties` (read-only), `obsidianTasks` (read-only), `obsidianLinks` tools.
- All v1 tools are read-only or direct-execution — no confirmation UX needed.
- Add per-tool command allowlists and prompt disambiguation guidance.
### Phase 3: v2 expansion
- Add confirmation-required mutations to existing v1 tools: `property:set`, `property:remove`, `task` toggle/status.
- Implement mutation gating via `obsidianCliAllowMutations` setting and light confirmation UX.
- Add new tool categories: `obsidianTemplates`, `obsidianBases`, `obsidianBookmarks`.
## 10. Risks and Mitigations
1. CLI behavior/version drift.
Mitigation: version checks + graceful fallback.
2. Security risks from command injection.
Mitigation: `execFile`, allowlist, strict param serializer.
3. UX confusion between existing tools and CLI-backed actions.
Mitigation: clear tool naming + incremental command scope.
## 11. Open Questions
### Resolved
1. ~~Should `obsidianCli` be exposed in standard tool settings for all users, or hidden behind a feature flag first?~~
**Resolved**: CLI tools are registered in `ToolRegistry` like any other tool, gated by `Platform.isDesktopApp`. No separate feature flag — they appear in tool settings on desktop, invisible on mobile.
2. ~~Do we want a dedicated category for CLI-backed tools?~~
**Resolved**: No dedicated category. CLI tools use category `"file"` alongside existing file tools. They are distinguished by their `id` prefix (`obsidian*`) and `displayName` suffix `(CLI)`.
3. ~~Should we prefer existing internal tools over CLI for certain operations (for consistency/performance)?~~
**Resolved**: Yes. Internal tools are preferred when they exist. See Tool Disambiguation section — `readNote` over CLI `read`, `getTagList` over CLI `tags`, `localSearch` over CLI `search`, Composer over CLI file writes. CLI tools are only used for capabilities without an internal equivalent.
### Open
1. What minimum CLI version should be required for initial support?
2. How should the agent reliably choose between similar tools when both could handle a request? For example, `daily:append`/`daily:prepend` vs the Composer tool (`writeToFile`/`replaceInFile`) when the user says "add something to my daily note." Current approach is prompt-instruction disambiguation, but this depends on LLM adherence to instructions. Alternatives: remove overlapping commands entirely, or add runtime routing that intercepts and redirects.
3. Can we reliably resolve the Obsidian CLI binary path across platforms? Current approach: try `obsidian` on PATH → env vars (`OBSIDIAN_CLI_BINARY`, `OBSIDIAN_CLI_PATH`) → macOS fallback paths (`/Applications/Obsidian.app/Contents/MacOS/obsidian`). Windows and Linux fallback paths are not yet implemented. If the CLI is not on PATH and no env var is set, the tool fails. Should we add a settings field for manual path override, or auto-detect from known install locations per platform?
---
## Appendix A: V1 CLI Command Reference
All commands are invoked as `obsidian <command> [params...]`. Output is **plain text** (no `format=json`) — LLMs consume text natively without the token overhead of JSON structure.
Global parameter available on all commands: `vault=<name>` (targets a specific vault; omit for default).
### A.1 `obsidianDailyNote` — Daily Note Operations
#### `daily:read`
Read today's daily note content.
```
obsidian daily:read
```
| Parameter | Required | Description |
|-----------|----------|-------------|
| _(none)_ | | No parameters. Reads the daily note for today. |
**Output**: Full markdown content of today's daily note. Empty string if no daily note exists.
```
# 2026-03-03
## Tasks
- [ ] Review PR #2181
- [x] Update design doc
## Notes
Meeting with Alice about CLI integration...
```
#### `daily:path`
Get the vault-relative file path of today's daily note.
```
obsidian daily:path
```
| Parameter | Required | Description |
|-----------|----------|-------------|
| _(none)_ | | No parameters. |
**Output**: Single line — the vault-relative path.
```
2026-03-03.md
```
This is the only way to discover the daily note path without knowing the user's daily note folder/date format configuration.
#### `daily:append`
Append content to the end of today's daily note. Creates the daily note if it doesn't exist.
```
obsidian daily:append content="- Meeting with Alice at 3pm"
```
| Parameter | Required | Description |
|-----------|----------|-------------|
| `content=<text>` | Yes | Text to append. |
| `inline` | No | Boolean flag. Append without a leading newline. |
**Output**: Empty on success. The content is added at the end of the file.
**Note**: `open` and `paneType` parameters are accepted by the CLI but are not passed by the tool (UI-only, no value for agent).
#### `daily:prepend`
Prepend content to the beginning of today's daily note (after frontmatter). Creates the daily note if it doesn't exist.
```
obsidian daily:prepend content="## Morning Standup"
```
| Parameter | Required | Description |
|-----------|----------|-------------|
| `content=<text>` | Yes | Text to prepend. |
| `inline` | No | Boolean flag. Prepend without a trailing newline. |
**Output**: Empty on success.
---
### A.2 `obsidianProperties` — Note Property Access
#### `properties`
List frontmatter properties. Can operate vault-wide or on a specific note.
**Vault-wide** (list all property names used across the vault):
```
obsidian properties
```
```
aliases
author
cssclasses
date
tags
title
```
**For a specific note** (list that note's property key-value pairs):
```
obsidian properties file="Rewrite as tweet"
```
```
copilot-command-context-menu-enabled: false
copilot-command-slash-enabled: false
copilot-command-context-menu-order: 90
copilot-command-model-key: ""
copilot-command-last-used: 0
```
| Parameter | Required | Description |
|-----------|----------|-------------|
| `file=<name>` | No | Target file by name (without extension). |
| `path=<path>` | No | Target file by vault-relative path. |
| `name=<name>` | No | Get count for a specific property name (vault-wide mode). |
| `counts` | No | Include occurrence counts (vault-wide mode). |
| `sort=count` | No | Sort by count instead of name (vault-wide mode). |
| `total` | No | Return only the property count. |
**Output (vault-wide)**: One property name per line, alphabetically sorted by default. With `counts`, format is `name: count`. With `total`, a single number.
**Output (per-file)**: `key: value` pairs, one per line (YAML-like).
#### `property:read`
Read a single property value from a specific note.
```
obsidian property:read name="tags" file="My Note"
```
| Parameter | Required | Description |
|-----------|----------|-------------|
| `name=<name>` | Yes | Property name to read. |
| `file=<name>` | No | Target file by name. |
| `path=<path>` | No | Target file by vault-relative path. |
**Output**: The raw property value. For arrays, comma-separated. For strings, the plain value.
```
90
```
---
### A.3 `obsidianTasks` — Task Listing
#### `tasks`
List tasks across the vault with filtering options.
```
obsidian tasks todo
obsidian tasks file="Project Plan" verbose
obsidian tasks daily
```
| Parameter | Required | Description |
|-----------|----------|-------------|
| `file=<name>` | No | Filter by file name. |
| `path=<path>` | No | Filter by file path. |
| `todo` | No | Show only incomplete tasks. |
| `done` | No | Show only completed tasks. |
| `status="<char>"` | No | Filter by status character (e.g., `status="/"` for in-progress). |
| `daily` | No | Show tasks from today's daily note. |
| `verbose` | No | Group tasks by file with line numbers. |
| `total` | No | Return only the task count. |
**Output (default text)**: One task per line, markdown checkbox format.
```
- [ ] Review PR #2181
- [ ] Update design doc
- [x] Write CLI client tests
```
**Output (verbose)**: Tasks grouped under file headings with line numbers.
```
Projects/launch-plan.md
L12: - [ ] Review PR #2181
L15: - [x] Write CLI client tests
Daily/2026-03-03.md
L8: - [ ] Update design doc
```
**Output (total)**: Single number.
```
3
```
**Empty result**: `No tasks found.`
---
### A.4 `obsidianRandomRead` — Random Note
#### `random:read`
Read a randomly selected markdown note from the vault.
```
obsidian random:read
obsidian random:read folder="Ideas"
```
| Parameter | Required | Description |
|-----------|----------|-------------|
| `folder=<path>` | No | Limit selection to a specific folder. |
**Output**: Full markdown content of the randomly selected note. A different note is returned each invocation.
**Empty result**: `No markdown files found.` (when folder is empty or doesn't exist).
---
### A.5 `obsidianLinks` — Link Graph Queries
#### `backlinks`
List notes that link TO a given file (incoming links).
```
obsidian backlinks file="My Note"
obsidian backlinks path="Projects/plan.md" counts
```
| Parameter | Required | Description |
|-----------|----------|-------------|
| `file=<name>` | No | Target file by name. |
| `path=<path>` | No | Target file by vault-relative path. |
| `counts` | No | Include link counts per source file. |
| `total` | No | Return only the backlink count. |
**Output (default TSV)**: One source file per line.
```
Projects/roadmap.md
Daily/2026-03-01.md
```
**Output (counts)**: Source file with link count.
```
Projects/roadmap.md 3
Daily/2026-03-01.md 1
```
**Output (total)**: Single number.
**Empty result**: `No backlinks found.`
#### `links`
List outgoing links FROM a given file.
```
obsidian links file="My Note"
obsidian links path="Projects/plan.md" total
```
| Parameter | Required | Description |
|-----------|----------|-------------|
| `file=<name>` | No | Source file by name. |
| `path=<path>` | No | Source file by vault-relative path. |
| `total` | No | Return only the link count. |
**Output**: One link target per line.
```
Projects/roadmap.md
Ideas/brainstorm.md
```
**Empty result**: `No links found.`
#### `orphans`
List files with no incoming links (not linked from any other note).
```
obsidian orphans
obsidian orphans total
```
| Parameter | Required | Description |
|-----------|----------|-------------|
| `total` | No | Return only the orphan count. |
| `all` | No | Include non-markdown files (images, PDFs, etc.). |
**Output**: One file path per line.
```
2026-03-03.md
BOT/DailyAIDigest/2026-02-26-Daily-AI-Digest.md
copilot/copilot-conversations/hello@20260302_145233.md
DemoCanvas.canvas
```
**Output (total)**: Single number (e.g., `84`).
#### `unresolved`
List wikilinks that don't resolve to any existing file in the vault.
```
obsidian unresolved
obsidian unresolved counts verbose
obsidian unresolved total
```
| Parameter | Required | Description |
|-----------|----------|-------------|
| `counts` | No | Include how many times each unresolved link appears. |
| `verbose` | No | Include source file for each unresolved link. |
| `total` | No | Return only the unresolved link count. |
**Output (default TSV)**: One unresolved link target per line.
```
Nonexistent Note
Old Project Reference
meeting-notes-2025
```
**Output (counts)**: Link target with occurrence count.
```
Nonexistent Note 5
Old Project Reference 2
```
**Output (verbose)**: Link target with source files.
```
Nonexistent Note Projects/roadmap.md
Nonexistent Note Daily/2026-03-01.md
Old Project Reference Archive/cleanup.md
```
**Output (total)**: Single number (e.g., `771`).
---
### A.6 `obsidianTemplates` — Template Listing and Reading
#### `templates`
List all available template names in the configured templates folder.
```
obsidian templates
```
| Parameter | Required | Description |
|-----------|----------|-------------|
| _(none)_ | | No parameters. Lists all template names. |
**Output**: One template name per line.
```
Daily Note
Meeting Notes
Project Plan
Weekly Review
```
---
#### `template:read`
Read a template's content with variable placeholders resolved.
```
obsidian template:read name="Daily Note"
```
| Parameter | Required | Description |
|-----------|----------|-------------|
| `name=<name>` | Yes | Template name (as returned by `templates`). |
**Output**: Full markdown content of the template.
```
# {{date}}
## Tasks
- [ ]
## Notes
```
---
### A.7 Error Responses
All commands return consistent error formats:
| Condition | Output |
|-----------|--------|
| File not found | `Error: File "path/to/file.md" not found.` |
| Missing required param | `Error: Missing required parameter: name=<name>` with usage line |
| No results | Command-specific empty message (e.g., `No tasks found.`, `No backlinks found.`, `No links found.`) |
| CLI binary not found | Process error code `ENOENT` — handled by `ObsidianCliClient` fallback resolution |
| Timeout | Process killed after `timeoutMs` — handled by `ObsidianCliClient` |

View file

@ -1,444 +0,0 @@
# TODO - Obsidian CLI Integration Priorities
This document tracks prioritized integration opportunities between the Obsidian CLI and the Copilot plugin's AI agent tool system. Each item identifies a capability gap, its user value, and a recommended implementation approach.
> **Architecture Note:** Since Copilot runs **inside** Obsidian, CLI commands should be implemented as internal API tool calls (using `app.vault`, `app.metadataCache`, `app.fileManager`, etc.) rather than shelling out to the CLI binary. The CLI command set serves as a **capability roadmap** — a reference for what tools to build.
---
## P0 — Must Have (Critical Gaps, Highest User Value)
### 1. [TODO] Property Management — `property:set` / `property:read` / `property:remove` / `properties` (P0)
#### What It Does
Read, write, and remove YAML frontmatter properties on notes. List all properties across the vault with occurrence counts.
#### Priority Justification
- **Current gap:** Complete. Copilot has zero frontmatter management capability. The only way to modify frontmatter today is `writeToFile`, which requires rewriting the entire file.
- Frontmatter is the backbone of Obsidian knowledge management (tags, dates, status, categories, ratings, custom fields). This is one of the most requested AI capabilities.
#### User Value
Extremely high. Users can ask the AI to auto-categorize notes, set status fields, tag content, and manage metadata workflows (e.g., "mark all my meeting notes from last week as reviewed").
#### AI Synergy
AI can analyze note content and set appropriate properties automatically. Enables bulk metadata operations that would be tedious manually.
#### Implementation Notes
- Use `app.fileManager.processFrontMatter()` internally (already used in `main.ts:708` for `lastAccessedAt`)
- Create `src/tools/PropertyTools.ts` with tools: `readProperty`, `setProperty`, `removeProperty`, `listProperties`
- Schema: `{ notePath: string, name: string, value?: string|number|boolean|string[], type?: string }`
- Register in `builtinTools.ts` under a new `"property"` category
---
### 2. [TODO] Task Management — `tasks` / `task` (P0)
#### What It Does
List tasks across the vault with filters (file, status, done/todo), and toggle individual task status (done, todo, custom status characters).
#### Priority Justification
- **Current gap:** Complete. No task interaction whatsoever in the current tool set.
- Task management is a top-3 Obsidian workflow. Users want AI to help track, organize, and complete tasks.
#### User Value
Very high. Users can ask: "summarize all open tasks", "find overdue items", "mark tasks done after discussing them", "create a weekly task review".
#### AI Synergy
Transformative. AI can generate task reports, identify overdue items, toggle task status based on conversation context, and integrate task management with search and daily notes.
#### Implementation Notes
- Use `app.metadataCache` to find tasks (`listItems` with task property in cached file metadata)
- Parse task status from markdown checkboxes: `- [ ]`, `- [x]`, `- [/]`, etc.
- Toggle tasks by reading file content and replacing the specific line
- Create `src/tools/TaskTools.ts` with tools: `listTasks`, `updateTaskStatus`
- Schema for `listTasks`: `{ file?: string, status?: string, done?: boolean, todo?: boolean, limit?: number }`
- Schema for `updateTaskStatus`: `{ notePath: string, line: number, status: string }`
---
### 3. [TODO] Daily Note Integration — `daily:read` / `daily:append` / `daily:prepend` / `daily:path` (P0)
#### What It Does
Read, append to, and prepend to the daily note. Get the daily note path based on user-configured format and folder.
#### Priority Justification
- **Current gap:** Significant. While `readNote` can read a daily note if the exact path is known, there is no convenient daily note integration. Users must know the exact path and date format.
- Daily notes are the #1 Obsidian workflow for most users.
#### User Value
Very high. Users can ask: "what did I write today?", "add this task to my daily note", "summarize my daily notes from this week", "prepend a morning planning prompt".
#### AI Synergy
High. AI can review daily progress, append new tasks and ideas, generate daily summaries, and integrate daily notes with task management.
#### Implementation Notes
- Use Obsidian's daily note API or moment-based path resolution from core plugin settings
- Key challenge: daily note path format varies per user settings (folder, date format, template)
- Create `src/tools/DailyNoteTools.ts` with tools: `readDailyNote`, `appendToDailyNote`, `prependToDailyNote`, `getDailyNotePath`
- Schema for append/prepend: `{ content: string, inline?: boolean }`
---
### 4. [TODO] Append / Prepend to Any Note — `append` / `prepend` (P0)
#### What It Does
Add content to the beginning or end of any note without overwriting its existing content.
#### Priority Justification
- **Current gap:** Significant. `writeToFile` replaces the **entire file** content. `replaceInFile` requires finding exact text to replace (and only works on files > 3000 chars). There is no way to incrementally add content to a note.
- Adding to notes (journaling, collecting thoughts, appending meeting notes) is a fundamental workflow.
#### User Value
High. Users can ask: "add these action items to my meeting notes", "append a summary to this note", "log this idea to my inbox".
#### AI Synergy
High. AI can add insights, summaries, related links, and new sections to existing notes without risk of overwriting content.
#### Implementation Notes
- Simple pattern: `app.vault.read(file)` → prepend/append content → `app.vault.modify(file, newContent)`
- Two implementation approaches:
1. Extend `writeToFile` in `ComposerTools.ts` with a `mode` parameter (`"overwrite" | "append" | "prepend"`)
2. Create dedicated `appendToNote` and `prependToNote` tools
- Option 1 is recommended (less tool proliferation, reuses existing preview/confirmation UX)
- Schema addition: `mode?: "overwrite" | "append" | "prepend"`, `inline?: boolean`
---
## P1 — High Value (Strong AI Synergy, Moderate Gap)
### 5. [TODO] Backlinks and Outgoing Links — `backlinks` / `links` (P1)
#### What It Does
List incoming links (backlinks) and outgoing links for a note, with optional counts and file paths.
#### Priority Justification
- **Current gap:** Significant. `readNote` returns a `linkedNotes` array (outgoing wikilinks parsed from content), but there is no way to find **backlinks** (which notes link TO a given note).
- Graph relationships are central to Obsidian's value proposition.
#### User Value
High. Users can ask: "what notes reference this project?", "find all notes that link to my meeting notes", "show me related content".
#### AI Synergy
AI can reason about note relationships, discover related content through the knowledge graph, and build contextual understanding of how notes connect.
#### Implementation Notes
- Use `app.metadataCache.resolvedLinks` for outgoing links
- Use `app.metadataCache.getBacklinksForFile(file)` for backlinks (internal API)
- Create `src/tools/LinkTools.ts` with tools: `getBacklinks`, `getOutgoingLinks`
- Schema: `{ notePath: string, total?: boolean }`
---
### 6. [TODO] Note Outline / Heading Structure — `outline` (P1)
#### What It Does
Get the heading hierarchy of a note as a structured tree (heading text, level, line position).
#### Priority Justification
- **Current gap:** Moderate. `readNote` provides raw content but no structured heading tree. The AI must parse headings from raw markdown.
- Helps AI navigate long notes efficiently by understanding structure before reading content.
#### User Value
Medium-high. Enables smarter note navigation: AI reads the outline first, then targets only relevant sections.
#### AI Synergy
AI can understand note structure, request only relevant chunks, and provide better responses about note organization.
#### Implementation Notes
- Use `app.metadataCache.getFileCache(file)?.headings` to get heading metadata
- Return structured heading hierarchy with levels, text, and line positions
- Could be added to `NoteTools.ts` or as a standalone `OutlineTools.ts`
- Schema: `{ notePath: string, format?: "tree" | "flat" }`
---
### 7. [TODO] Template Integration — `template:read` / `template:insert` / `templates` (P1)
#### What It Does
List available templates, read template content (with optional variable resolution), and insert templates into notes.
#### Priority Justification
- **Current gap:** Complete. No template interaction in the current tool set.
- Template-based note creation is a key Obsidian workflow.
#### User Value
Medium-high. Users can ask: "create a new meeting note using my meeting template", "what templates do I have?", "apply the project template to this note".
#### AI Synergy
AI can create properly structured notes from templates, understand available templates, and fill in template variables with context-aware content.
#### Implementation Notes
- Access template folder path from Obsidian core plugin settings
- Use `app.vault.read()` for template content
- Template insertion requires understanding Obsidian's template variable resolution (`{{date}}`, `{{title}}`, etc.)
- Create `src/tools/TemplateTools.ts` with tools: `listTemplates`, `readTemplate`, `insertTemplate`
---
### 8. [TODO] Execute Obsidian Commands — `command` / `commands` (P1)
#### What It Does
List all registered Obsidian commands and execute them by ID. This is a meta-capability that enables triggering any Obsidian feature.
#### Priority Justification
- **Current gap:** Complete. No way to trigger Obsidian commands from the AI agent.
- Potentially very high value as a meta-capability (open graph view, toggle reading mode, run other plugin commands).
- **Risk:** Security/safety concerns with arbitrary command execution require careful scoping.
#### User Value
High for power users. Enables: "open graph view", "toggle dark mode", "run Dataview refresh", "enable focus mode".
#### AI Synergy
Enables AI to trigger ANY Obsidian feature, making it a true automation assistant.
#### Implementation Notes
- Use `app.commands.executeCommandById(id)` for execution
- Use `app.commands.listCommands()` for discovery
- **Must** implement safety constraints: allowlisted commands, user confirmation for destructive ones, or a configurable blocklist
- Create `src/tools/CommandTools.ts` with tools: `listCommands`, `executeCommand`
- Schema: `{ commandId: string }` with safety validation
---
### 9. [TODO] Bookmark Management — `bookmarks` / `bookmark` (P1)
#### What It Does
List existing bookmarks and create new bookmarks for files, folders, searches, or URLs.
#### Priority Justification
- **Current gap:** Complete. No bookmark interaction.
- Bookmarks are useful but secondary to core note management.
#### User Value
Medium. AI can bookmark important search results, notes for later review, or frequently referenced content.
#### AI Synergy
AI can organize bookmarks based on conversation context, bookmark relevant findings during research workflows.
#### Implementation Notes
- Use Obsidian's bookmark core plugin API
- Create bookmark tools or integrate into existing file tools
- Schema: `{ file?: string, search?: string, url?: string, title?: string }`
---
## P2 — Nice to Have (Complementary, Low Gap)
### 10. [TODO] Native Text Search — `search` / `search:context` (P2)
#### What It Does
Search vault for text using Obsidian's built-in search engine, with optional line-level context.
#### Priority Justification
- Copilot already has excellent search (lexical + semantic + hybrid + reranking via `localSearch` and `semanticSearch`).
- CLI search would be complementary for **exact text matching** but offers low incremental value over existing tools.
#### Implementation Notes
- Could be useful as a fallback when Copilot's search misses exact text matches
- Lower priority given existing search infrastructure
---
### 11. [TODO] Vault Health Tools — `orphans` / `deadends` / `unresolved` (P2)
#### What It Does
List files with no incoming links (orphans), no outgoing links (deadends), and unresolved wikilinks.
#### Priority Justification
- AI could help identify and fix broken links, orphan notes, and dead-end notes.
- Useful for vault maintenance workflows but not daily use.
#### Implementation Notes
- Use `app.metadataCache.resolvedLinks` and `app.metadataCache.unresolvedLinks`
- Create `src/tools/VaultHealthTools.ts`
---
### 12. [TODO] File Move / Rename — `move` / `rename` (P2)
#### What It Does
Move or rename files while maintaining all wikilink references across the vault.
#### Priority Justification
- Not critical since `writeToFile` + delete can achieve file creation at new paths.
- However, proper `move` maintains all wikilink references, which is important for vault integrity.
#### Implementation Notes
- Use `app.fileManager.renameFile()` which automatically updates links
- Schema: `{ notePath: string, newPath: string }`
---
### 13. [TODO] Base / Database Integration — `base:query` / `base:create` (P2)
#### What It Does
Query structured data from Obsidian Base files and create new items.
#### Priority Justification
- Base is Obsidian's database feature (relatively new).
- AI querying structured data would be valuable for users who use Base.
- Lower priority since Base adoption is still growing.
#### Implementation Notes
- Depends on Base plugin API availability
- Schema: `{ file: string, view?: string, format?: string }`
---
### 14. [TODO] Plugin Reload — `plugin:reload` (P2, Developer Only)
#### What It Does
Reload a plugin by ID without restarting Obsidian.
#### Priority Justification
- Already documented in `CLAUDE.md` for developer workflow.
- Critical for Copilot developers, irrelevant for end users.
- Not a user-facing tool.
#### Implementation Notes
- CLI command: `/Applications/Obsidian.app/Contents/MacOS/obsidian plugin:reload id=copilot`
- Already used in development workflow
---
### 15. [TODO] Developer Tools — `dev:console` / `dev:errors` / `eval` (P2, Developer Only)
#### What It Does
Show captured console messages, errors, and execute JavaScript in the app context.
#### Priority Justification
- Developer debugging tools. Not user-facing.
- `eval` could theoretically be a meta-capability for the AI agent, but security risk is extreme.
#### Implementation Notes
- For development workflow only
- `eval` should **never** be exposed as an AI tool due to arbitrary code execution risk
---
## P3 — Low Priority (Minimal Gap or Low AI Synergy)
The following CLI commands have minimal integration value for the Copilot AI agent:
| Command | Reason for Low Priority |
| --------------------------------------------------------------- | ---------------------------------------- |
| `aliases` | Niche metadata, low AI synergy |
| `wordcount` | Copilot already has token counting |
| `recents` | Low AI synergy |
| `random` / `random:read` | Niche use case |
| `tabs` / `tab:open` / `workspace` | UI management, not AI territory |
| `vault` / `vaults` | Metadata already available via `app` API |
| `plugins` / `plugin:enable/disable/install/uninstall` | Not an AI workflow |
| `themes` / `snippets` | Appearance management |
| `hotkeys` / `hotkey` | Configuration, not AI |
| `sync` / `sync:status/history/read/restore` | User-managed sync operations |
| `history` / `diff` | Specialized version management |
| `web` | Copilot already has web search |
| `dev:dom`, `dev:css`, `dev:screenshot`, `dev:cdp`, `dev:mobile` | Developer tools |
| `version`, `restart`, `reload` | System commands |
---
## Implementation Roadmap
### Phase 1 — P0 Tools (4 new tool groups)
| # | Tool Group | New File | Tools |
| --- | ---------------------- | ----------------------------------- | ------------------------------------------------------------------------------ |
| 1 | Property Management | `src/tools/PropertyTools.ts` | `readProperty`, `setProperty`, `removeProperty`, `listProperties` |
| 2 | Task Management | `src/tools/TaskTools.ts` | `listTasks`, `updateTaskStatus` |
| 3 | Daily Note Integration | `src/tools/DailyNoteTools.ts` | `readDailyNote`, `appendToDailyNote`, `prependToDailyNote`, `getDailyNotePath` |
| 4 | Append / Prepend | Extend `src/tools/ComposerTools.ts` | Add `mode` parameter to `writeToFile` |
### Phase 2 — P1 Tools (4 new tool groups)
| # | Tool Group | New File | Tools |
| --- | -------------------- | ---------------------------------------------------- | ---------------------------------------------------------- |
| 5 | Link Navigation | `src/tools/LinkTools.ts` | `getBacklinks`, `getOutgoingLinks` |
| 6 | Note Outline | `src/tools/OutlineTools.ts` or extend `NoteTools.ts` | `getNoteOutline` |
| 7 | Template Integration | `src/tools/TemplateTools.ts` | `listTemplates`, `readTemplate`, `insertTemplate` |
| 8 | Command Execution | `src/tools/CommandTools.ts` | `listCommands`, `executeCommand` (with safety constraints) |
### Phase 3 — P2 Tools (selected)
| # | Tool Group | New File | Tools |
| --- | --------------- | --------------------------------- | ----------------------------------------------- |
| 9 | Vault Health | `src/tools/VaultHealthTools.ts` | `listOrphans`, `listDeadends`, `listUnresolved` |
| 10 | File Operations | `src/tools/FileOperationTools.ts` | `moveNote`, `renameNote` |
| 11 | Base / Database | `src/tools/BaseTools.ts` | `queryBase`, `createBaseItem` |
---
### References
- [Obsidian CLI Documentation](https://help.obsidian.md/cli)
- Current Copilot tools: `src/tools/builtinTools.ts`
- Tool registry: `src/tools/ToolRegistry.ts`
- Tool creation pattern: `src/tools/createLangChainTool.ts`
---
_Last updated: 2026-03-03_

View file

@ -0,0 +1,104 @@
import { summarizeToolCall, summarizeToolResult } from "./AgentReasoningState";
describe("AgentReasoningState tool summaries", () => {
test("summarizeToolCall has daily/random CLI specific wording", () => {
expect(summarizeToolCall("obsidianDailyNote", { command: "daily:read" })).toBe(
"Reading today's daily note"
);
expect(summarizeToolCall("obsidianDailyNote", { command: "daily:read", vault: "Work" })).toBe(
`Reading today's daily note from "Work"`
);
expect(summarizeToolCall("obsidianDailyNote", { command: "daily:append" })).toBe(
"Appending to daily note"
);
expect(
summarizeToolCall("obsidianDailyNote", { command: "daily:prepend", vault: "Work" })
).toBe(`Prepending to daily note from "Work"`);
expect(summarizeToolCall("obsidianDailyNote", { command: "daily:path" })).toBe(
"Getting daily note path"
);
expect(summarizeToolCall("obsidianRandomRead")).toBe("Reading a random note");
expect(summarizeToolCall("obsidianRandomRead", { vault: "Personal" })).toBe(
`Reading a random note from "Personal"`
);
});
test("summarizeToolResult has daily/random CLI specific wording", () => {
expect(
summarizeToolResult("obsidianDailyNote", { success: true }, undefined, {
command: "daily:read",
vault: "Work",
})
).toBe(`Loaded today's daily note from "Work"`);
expect(
summarizeToolResult("obsidianDailyNote", { success: true }, undefined, {
command: "daily:read",
})
).toBe("Loaded today's daily note");
expect(
summarizeToolResult("obsidianDailyNote", { success: true }, undefined, {
command: "daily:append",
})
).toBe("Appended to daily note");
expect(
summarizeToolResult("obsidianDailyNote", { success: true }, undefined, {
command: "daily:path",
vault: "Work",
})
).toBe(`Got daily note path from "Work"`);
expect(
summarizeToolResult("obsidianRandomRead", { success: true }, undefined, { vault: "Personal" })
).toBe(`Loaded a random note from "Personal"`);
expect(summarizeToolResult("obsidianRandomRead", { success: true })).toBe(
"Loaded a random note"
);
});
test("summarizeToolResult failure path reuses CLI call summary", () => {
expect(
summarizeToolResult("obsidianRandomRead", { success: false }, undefined, { vault: "VaultA" })
).toBe(`Reading a random note from "VaultA" failed`);
});
test("summarizeToolCall has properties/tasks/links CLI specific wording", () => {
expect(summarizeToolCall("obsidianProperties", { command: "properties" })).toBe(
"Listing vault properties"
);
expect(summarizeToolCall("obsidianProperties", { command: "property:read", name: "tags" })).toBe(
`Reading property "tags"`
);
expect(summarizeToolCall("obsidianTasks", { command: "tasks" })).toBe("Listing vault tasks");
expect(summarizeToolCall("obsidianLinks", { command: "backlinks" })).toBe("Listing backlinks");
expect(summarizeToolCall("obsidianLinks", { command: "orphans" })).toBe(
"Listing orphaned notes"
);
expect(summarizeToolCall("obsidianLinks", { command: "unresolved" })).toBe(
"Listing unresolved links"
);
});
test("summarizeToolResult has properties/tasks/links CLI specific wording", () => {
expect(
summarizeToolResult("obsidianProperties", { success: true }, undefined, {
command: "properties",
})
).toBe("Listed vault properties");
expect(
summarizeToolResult("obsidianProperties", { success: true }, undefined, {
command: "property:read",
name: "tags",
})
).toBe(`Read property "tags"`);
expect(
summarizeToolResult("obsidianTasks", { success: true }, undefined, { command: "tasks" })
).toBe("Listed vault tasks");
expect(
summarizeToolResult("obsidianLinks", { success: true }, undefined, { command: "backlinks" })
).toBe("Listed backlinks");
expect(
summarizeToolResult("obsidianLinks", { success: true }, undefined, { command: "orphans" })
).toBe("Listed orphaned notes");
});
});

View file

@ -202,6 +202,48 @@ export function summarizeToolResult(
return "Converted timestamp";
case "convertTimeBetweenTimezones":
return "Converted timezone";
case "obsidianDailyNote": {
const command = args?.command as string | undefined;
const vault = args?.vault as string | undefined;
const vaultSuffix = vault && vault.trim().length > 0 ? ` from "${vault}"` : "";
if (command === "daily:append") return `Appended to daily note${vaultSuffix}`;
if (command === "daily:prepend") return `Prepended to daily note${vaultSuffix}`;
if (command === "daily:path") return `Got daily note path${vaultSuffix}`;
return `Loaded today's daily note${vaultSuffix}`;
}
case "obsidianRandomRead": {
const vault = args?.vault as string | undefined;
if (vault && vault.trim().length > 0) {
return `Loaded a random note from "${vault}"`;
}
return "Loaded a random note";
}
case "obsidianProperties": {
const command = args?.command as string | undefined;
if (command === "property:read") {
const name = args?.name as string | undefined;
return name ? `Read property "${name}"` : "Read property";
}
return "Listed vault properties";
}
case "obsidianTasks":
return "Listed vault tasks";
case "obsidianLinks": {
const command = args?.command as string | undefined;
if (command === "backlinks") return "Listed backlinks";
if (command === "links") return "Listed outgoing links";
if (command === "orphans") return "Listed orphaned notes";
if (command === "unresolved") return "Listed unresolved links";
return "Queried link graph";
}
case "obsidianTemplates": {
const command = args?.command as string | undefined;
if (command === "template:read") {
const name = args?.name as string | undefined;
return name ? `Read template "${name}"` : "Read template";
}
return "Listed templates";
}
case "indexVault":
return "Indexed vault";
case "updateMemory":
@ -323,6 +365,48 @@ export function summarizeToolCall(
return "Converting timestamp";
case "convertTimeBetweenTimezones":
return "Converting timezone";
case "obsidianDailyNote": {
const command = args?.command as string | undefined;
const vault = args?.vault as string | undefined;
const vaultSuffix = vault && vault.trim().length > 0 ? ` from "${vault}"` : "";
if (command === "daily:append") return `Appending to daily note${vaultSuffix}`;
if (command === "daily:prepend") return `Prepending to daily note${vaultSuffix}`;
if (command === "daily:path") return `Getting daily note path${vaultSuffix}`;
return `Reading today's daily note${vaultSuffix}`;
}
case "obsidianRandomRead": {
const vault = args?.vault as string | undefined;
if (vault && vault.trim().length > 0) {
return `Reading a random note from "${vault}"`;
}
return "Reading a random note";
}
case "obsidianProperties": {
const command = args?.command as string | undefined;
if (command === "property:read") {
const name = args?.name as string | undefined;
return name ? `Reading property "${name}"` : "Reading property";
}
return "Listing vault properties";
}
case "obsidianTasks":
return "Listing vault tasks";
case "obsidianLinks": {
const command = args?.command as string | undefined;
if (command === "backlinks") return "Listing backlinks";
if (command === "links") return "Listing outgoing links";
if (command === "orphans") return "Listing orphaned notes";
if (command === "unresolved") return "Listing unresolved links";
return "Querying link graph";
}
case "obsidianTemplates": {
const command = args?.command as string | undefined;
if (command === "template:read") {
const name = args?.name as string | undefined;
return name ? `Reading template "${name}"` : "Reading template";
}
return "Listing templates";
}
case "indexVault":
return "Indexing vault";
case "updateMemory":

View file

@ -168,6 +168,12 @@ export function getToolDisplayName(toolName: string): string {
indexTool: "index",
writeToFile: "file editor",
replaceInFile: "file editor",
obsidianDailyNote: "daily note (CLI)",
obsidianRandomRead: "random note (CLI)",
obsidianProperties: "properties (CLI)",
obsidianTasks: "tasks (CLI)",
obsidianLinks: "links (CLI)",
obsidianTemplates: "templates (CLI)",
};
return displayNameMap[toolName] || toolName;
@ -191,6 +197,12 @@ export function getToolEmoji(toolName: string): string {
writeToFile: "✏️",
replaceInFile: "🔄",
readNote: "🔍",
obsidianDailyNote: "📅",
obsidianRandomRead: "🎲",
obsidianProperties: "🏷️",
obsidianTasks: "✅",
obsidianLinks: "🔗",
obsidianTemplates: "📄",
};
return emojiMap[toolName] || "🔧";

View file

@ -0,0 +1,215 @@
import { Platform } from "obsidian";
import {
buildObsidianCliArgs,
runRandomReadCommand,
runObsidianCliCommand,
} from "@/services/obsidianCli/ObsidianCliClient";
/**
* Callback signature used by mocked execFile in tests.
*/
type MockExecCallback = (
error: (Error & { code?: string | number | null; signal?: string | null }) | null,
stdout: string,
stderr: string
) => void;
/**
* ExecFile argument shape for typed jest mocks.
*/
type MockExecArgs = [
binary: string,
args: string[],
options: { timeout?: number; maxBuffer?: number; windowsHide?: boolean },
callback: MockExecCallback,
];
/**
* Runtime require container shape for tests.
*/
interface TestRequireContainer {
require?: (id: string) => unknown;
}
describe("ObsidianCliClient", () => {
let originalRequire: ((id: string) => unknown) | undefined;
beforeEach(() => {
const platform = Platform as unknown as { isDesktopApp?: boolean; isDesktop?: boolean };
platform.isDesktopApp = true;
const container = globalThis as unknown as TestRequireContainer;
originalRequire = container.require;
});
afterEach(() => {
const container = globalThis as unknown as TestRequireContainer;
if (originalRequire) {
container.require = originalRequire;
} else {
delete container.require;
}
jest.clearAllMocks();
});
test("buildObsidianCliArgs keeps vault before command and serializes params", () => {
const args = buildObsidianCliArgs({
command: "daily:read",
vault: "Personal",
params: {
alpha: "first",
flagEnabled: true,
ignoredFalseFlag: false,
multiline: "line1\nline2",
},
});
expect(args).toEqual([
"vault=Personal",
"daily:read",
"alpha=first",
"flagEnabled",
"multiline=line1\\nline2",
]);
});
test("runObsidianCliCommand returns successful process payload", async () => {
const execFileMock = jest.fn<void, MockExecArgs>((_binary, _args, _options, callback) =>
callback(null, "Daily note content", "")
);
const container = globalThis as unknown as TestRequireContainer;
container.require = jest.fn((id: string) => {
if (id === "child_process") {
return { execFile: execFileMock };
}
return {};
});
const result = await runObsidianCliCommand({
command: "daily:read",
vault: "Work",
timeoutMs: 5000,
});
expect(result.ok).toBe(true);
expect(result.command).toBe("daily:read");
expect(result.args).toEqual(["vault=Work", "daily:read"]);
expect(result.stdout).toBe("Daily note content");
expect(result.stderr).toBe("");
expect(result.exitCode).toBe(0);
expect(execFileMock).toHaveBeenCalledTimes(1);
});
test("runObsidianCliCommand captures process failure output", async () => {
const execFileMock = jest.fn<void, MockExecArgs>((_binary, _args, _options, callback) => {
const error = new Error("spawn ENOENT") as Error & {
code?: string | number | null;
signal?: string | null;
};
error.code = "ENOENT";
callback(error, "", "command not found");
});
const container = globalThis as unknown as TestRequireContainer;
container.require = jest.fn((id: string) => {
if (id === "child_process") {
return { execFile: execFileMock };
}
return {};
});
const result = await runObsidianCliCommand({
command: "daily:read",
});
expect(result.ok).toBe(false);
expect(result.errorCode).toBe("ENOENT");
expect(result.exitCode).toBeNull();
expect(result.stderr).toBe("command not found");
expect(result.attemptedBinaries).toEqual([
"obsidian",
"/Applications/Obsidian.app/Contents/MacOS/obsidian",
"/Applications/Obsidian.app/Contents/MacOS/Obsidian",
]);
});
test("runObsidianCliCommand falls back to app binary when default binary is missing", async () => {
const execFileMock = jest.fn<void, MockExecArgs>((binary, _args, _options, callback) => {
if (binary === "obsidian") {
const error = new Error("spawn ENOENT") as Error & {
code?: string | number | null;
signal?: string | null;
};
error.code = "ENOENT";
callback(error, "", "");
return;
}
if (binary === "/Applications/Obsidian.app/Contents/MacOS/obsidian") {
callback(null, "Recovered via fallback binary", "");
return;
}
const error = new Error("spawn ENOENT") as Error & {
code?: string | number | null;
signal?: string | null;
};
error.code = "ENOENT";
callback(error, "", "");
});
const container = globalThis as unknown as TestRequireContainer;
container.require = jest.fn((id: string) => {
if (id === "child_process") {
return { execFile: execFileMock };
}
return {};
});
const result = await runObsidianCliCommand({
command: "random:read",
});
expect(result.ok).toBe(true);
expect(result.binary).toBe("/Applications/Obsidian.app/Contents/MacOS/obsidian");
expect(result.attemptedBinaries).toEqual([
"obsidian",
"/Applications/Obsidian.app/Contents/MacOS/obsidian",
]);
expect(execFileMock).toHaveBeenCalledTimes(2);
});
test("runRandomReadCommand executes random:read wrapper", async () => {
const execFileMock = jest.fn<void, MockExecArgs>((_binary, _args, _options, callback) =>
callback(null, "Random note content", "")
);
const container = globalThis as unknown as TestRequireContainer;
container.require = jest.fn((id: string) => {
if (id === "child_process") {
return { execFile: execFileMock };
}
return {};
});
const result = await runRandomReadCommand("Personal");
expect(result.ok).toBe(true);
expect(result.command).toBe("random:read");
expect(result.args).toEqual(["vault=Personal", "random:read"]);
expect(result.stdout).toBe("Random note content");
});
test("runObsidianCliCommand rejects on non-desktop runtime", async () => {
const platform = Platform as unknown as { isDesktopApp?: boolean; isDesktop?: boolean };
platform.isDesktopApp = false;
platform.isDesktop = false;
await expect(
runObsidianCliCommand({
command: "daily:read",
})
).rejects.toThrow("only supported in desktop Obsidian");
});
});

View file

@ -0,0 +1,402 @@
import { Platform } from "obsidian";
/**
* Default CLI executable name registered by Obsidian.
*/
export const DEFAULT_OBSIDIAN_CLI_BINARY = "obsidian";
/**
* Known desktop fallback paths for Obsidian CLI on macOS installs.
*/
const OBSIDIAN_CLI_MACOS_FALLBACK_BINARIES = [
"/Applications/Obsidian.app/Contents/MacOS/obsidian",
"/Applications/Obsidian.app/Contents/MacOS/Obsidian",
];
/**
* Default timeout for CLI command execution.
*/
export const DEFAULT_OBSIDIAN_CLI_TIMEOUT_MS = 15_000;
/**
* Default maximum process output buffer (1 MB).
*/
export const DEFAULT_OBSIDIAN_CLI_MAX_BUFFER_BYTES = 1_048_576;
/**
* Supported primitive parameter value types for CLI serialization.
*/
export type ObsidianCliParamValue = string | number | boolean | null | undefined;
/**
* Shape of a command invocation for the Obsidian CLI.
*/
export interface ObsidianCliInvocation {
command: string;
vault?: string;
params?: Record<string, ObsidianCliParamValue>;
timeoutMs?: number;
maxBufferBytes?: number;
binary?: string;
}
/**
* Result object returned from Obsidian CLI process execution.
*/
export interface ObsidianCliProcessResult {
command: string;
args: string[];
binary: string;
attemptedBinaries: string[];
ok: boolean;
stdout: string;
stderr: string;
exitCode: number | null;
errorCode: string | number | null;
signal: string | null;
durationMs: number;
}
/**
* Callback signature used by Node's `execFile`.
*/
type ExecFileCallback = (error: ExecFileError | null, stdout: string, stderr: string) => void;
/**
* Minimal shape of process execution error from `execFile`.
*/
interface ExecFileError extends Error {
code?: string | number | null;
signal?: string | null;
}
/**
* Minimal options shape supported by `execFile`.
*/
interface ExecFileOptions {
timeout?: number;
maxBuffer?: number;
windowsHide?: boolean;
}
/**
* Minimal `execFile` function contract used in this module.
*/
type ExecFileFn = (
file: string,
args: string[],
options: ExecFileOptions,
callback: ExecFileCallback
) => void;
/**
* Minimal shape of the required child_process module.
*/
interface ChildProcessModule {
execFile?: ExecFileFn;
}
/**
* Minimal global shape that may expose Node's `require` in the desktop renderer.
*/
interface RequireContainer {
require?: (id: string) => unknown;
}
/**
* Minimal global shape that may expose `process.env`.
*/
interface ProcessContainer {
process?: {
env?: Record<string, string | undefined>;
};
}
/**
* Check whether the current runtime is desktop Obsidian.
* Supports both `isDesktopApp` and legacy `isDesktop` flags for compatibility.
*
* @returns True when running in desktop runtime.
*/
export function isDesktopRuntime(): boolean {
const platform = Platform as unknown as { isDesktopApp?: boolean; isDesktop?: boolean };
return Boolean(platform.isDesktopApp ?? platform.isDesktop);
}
/**
* Resolve `require` from the desktop runtime.
*
* @returns Runtime `require` function.
* @throws If `require` is not available.
*/
function getRuntimeRequire(): (id: string) => unknown {
const container = globalThis as unknown as RequireContainer;
if (typeof container.require !== "function") {
throw new Error(
"Node require is unavailable in this runtime. Obsidian CLI commands require the desktop app."
);
}
return container.require;
}
/**
* Resolve Node's `execFile` function from `child_process`.
*
* @returns `execFile` function.
* @throws If `child_process.execFile` cannot be resolved.
*/
function getExecFileFunction(): ExecFileFn {
const runtimeRequire = getRuntimeRequire();
const childProcessModule = runtimeRequire("child_process") as ChildProcessModule;
if (typeof childProcessModule.execFile !== "function") {
throw new Error("Failed to resolve child_process.execFile");
}
return childProcessModule.execFile;
}
/**
* Normalize parameter values for the CLI's text-based parser.
* Converts literal newlines/tabs to escaped sequences.
*
* @param value - Raw parameter value.
* @returns CLI-safe parameter string.
*/
function normalizeCliParameterValue(value: string): string {
return value.replace(/\n/g, "\\n").replace(/\t/g, "\\t");
}
/**
* Read optional CLI binary overrides from environment variables.
*
* @returns Ordered non-empty binary candidates from environment.
*/
function getCliBinaryCandidatesFromEnv(): string[] {
const container = globalThis as unknown as ProcessContainer;
const env = container.process?.env;
if (!env) {
return [];
}
const envCandidates = [env.OBSIDIAN_CLI_BINARY, env.OBSIDIAN_CLI_PATH];
return envCandidates.map((candidate) => candidate?.trim() || "").filter(Boolean);
}
/**
* Build a deduplicated ordered list of executable candidates.
*
* @param explicitBinary - Explicit binary override provided by caller.
* @returns Ordered list of binary candidates to attempt.
*/
function resolveBinaryCandidates(explicitBinary?: string): string[] {
const explicit = explicitBinary?.trim();
if (explicit) {
return [explicit];
}
const candidates = [
...getCliBinaryCandidatesFromEnv(),
DEFAULT_OBSIDIAN_CLI_BINARY,
...OBSIDIAN_CLI_MACOS_FALLBACK_BINARIES,
];
return Array.from(new Set(candidates.map((candidate) => candidate.trim()).filter(Boolean)));
}
/**
* Build Obsidian CLI argument list from invocation data.
* Keeps `vault=<name>` first when provided, per CLI docs.
*
* @param invocation - Invocation payload.
* @returns Ordered CLI arguments.
*/
export function buildObsidianCliArgs(invocation: ObsidianCliInvocation): string[] {
const args: string[] = [];
const trimmedVault = invocation.vault?.trim();
if (trimmedVault) {
args.push(`vault=${trimmedVault}`);
}
args.push(invocation.command);
const entries = Object.entries(invocation.params || {});
entries.sort(([left], [right]) => left.localeCompare(right));
for (const [key, value] of entries) {
if (value === undefined || value === null) {
continue;
}
if (typeof value === "boolean") {
if (value) {
args.push(key);
}
continue;
}
args.push(`${key}=${normalizeCliParameterValue(String(value))}`);
}
return args;
}
/**
* Normalize process exit code from `execFile` error payload.
*
* @param code - Error code from process callback.
* @returns Numeric exit code when available.
*/
function toExitCode(code: string | number | null | undefined): number | null {
return typeof code === "number" ? code : null;
}
/**
* Execute the CLI once with a specific binary candidate.
*
* @param execFile - Process execution function.
* @param command - Logical command being executed.
* @param binary - Binary candidate path/name.
* @param args - CLI arguments.
* @param timeoutMs - Timeout in milliseconds.
* @param maxBufferBytes - Maximum process output buffer in bytes.
* @param attemptedBinaries - Current list of attempted binaries.
* @returns Structured process result.
*/
async function executeOnce(
execFile: ExecFileFn,
command: string,
binary: string,
args: string[],
timeoutMs: number,
maxBufferBytes: number,
attemptedBinaries: string[]
): Promise<ObsidianCliProcessResult> {
const startedAt = Date.now();
return await new Promise<ObsidianCliProcessResult>((resolve) => {
execFile(
binary,
args,
{
timeout: timeoutMs,
maxBuffer: maxBufferBytes,
windowsHide: true,
},
(error, stdout, stderr) => {
const durationMs = Date.now() - startedAt;
if (error) {
resolve({
command,
args,
binary,
attemptedBinaries: [...attemptedBinaries],
ok: false,
stdout: stdout || "",
stderr: stderr || "",
exitCode: toExitCode(error.code),
errorCode: error.code ?? null,
signal: error.signal ?? null,
durationMs,
});
return;
}
resolve({
command,
args,
binary,
attemptedBinaries: [...attemptedBinaries],
ok: true,
stdout: stdout || "",
stderr: stderr || "",
exitCode: 0,
errorCode: null,
signal: null,
durationMs,
});
}
);
});
}
/**
* Execute a single Obsidian CLI command.
*
* @param invocation - Command invocation payload.
* @returns Structured process result including stdout/stderr and execution metadata.
* @throws If runtime is unsupported or invocation is invalid.
*/
export async function runObsidianCliCommand(
invocation: ObsidianCliInvocation
): Promise<ObsidianCliProcessResult> {
const command = invocation.command?.trim();
if (!command) {
throw new Error("Obsidian CLI command is required");
}
if (!isDesktopRuntime()) {
throw new Error("Obsidian CLI commands are only supported in desktop Obsidian.");
}
const args = buildObsidianCliArgs({ ...invocation, command });
const timeoutMs = invocation.timeoutMs ?? DEFAULT_OBSIDIAN_CLI_TIMEOUT_MS;
const maxBufferBytes = invocation.maxBufferBytes ?? DEFAULT_OBSIDIAN_CLI_MAX_BUFFER_BYTES;
const execFile = getExecFileFunction();
const candidateBinaries = resolveBinaryCandidates(invocation.binary);
const attemptedBinaries: string[] = [];
let lastResult: ObsidianCliProcessResult | null = null;
for (const candidateBinary of candidateBinaries) {
attemptedBinaries.push(candidateBinary);
const result = await executeOnce(
execFile,
command,
candidateBinary,
args,
timeoutMs,
maxBufferBytes,
attemptedBinaries
);
lastResult = result;
if (result.ok) {
return result;
}
if (result.errorCode !== "ENOENT") {
return result;
}
}
if (lastResult) {
return lastResult;
}
throw new Error("Obsidian CLI execution failed before process spawn.");
}
/**
* Read the current daily note content through Obsidian CLI (`daily:read`).
*
* @param vault - Optional vault name target.
* @returns CLI process result for the `daily:read` command.
*/
export async function runDailyReadCommand(vault?: string): Promise<ObsidianCliProcessResult> {
return await runObsidianCliCommand({
command: "daily:read",
vault,
});
}
/**
* Read a random note through Obsidian CLI (`random:read`).
*
* @param vault - Optional vault name target.
* @returns CLI process result for the `random:read` command.
*/
export async function runRandomReadCommand(vault?: string): Promise<ObsidianCliProcessResult> {
return await runObsidianCliCommand({
command: "random:read",
vault,
});
}

View file

@ -0,0 +1,50 @@
import type { ObsidianCliProcessResult } from "./ObsidianCliClient";
/**
* Build a readable error string from CLI process output.
*
* Priority: stderr ENOENT errorCode exitCode fallback.
*
* @param stderr - Standard error payload.
* @param exitCode - Numeric process exit code when available.
* @param errorCode - Process error code from runtime.
* @param attemptedBinaries - Binaries that were attempted.
* @returns User-facing error summary.
*/
export function formatCliFailureMessage(
stderr: string,
exitCode: number | null,
errorCode: string | number | null,
attemptedBinaries: string[]
): string {
const trimmedStderr = stderr.trim();
if (trimmedStderr.length > 0) {
return trimmedStderr;
}
if (errorCode === "ENOENT") {
const attempted = attemptedBinaries.length > 0 ? attemptedBinaries.join(", ") : "unknown";
return `CLI binary not found. Tried: ${attempted}. Ensure Obsidian CLI is installed or set OBSIDIAN_CLI_BINARY/OBSIDIAN_CLI_PATH.`;
}
if (errorCode !== null) {
return `Obsidian CLI failed with error code ${String(errorCode)}`;
}
if (exitCode !== null) {
return `Obsidian CLI failed with exit code ${exitCode}`;
}
return "Obsidian CLI command failed for an unknown reason";
}
/**
* Throw a standardized error from a failed CLI result.
*
* @param result - Failed CLI process result.
*/
export function throwCliFailure(result: ObsidianCliProcessResult): never {
throw new Error(
formatCliFailureMessage(result.stderr, result.exitCode, result.errorCode, result.attemptedBinaries)
);
}

View file

@ -2,6 +2,7 @@ import React from "react";
import { SettingItem } from "@/components/ui/setting-item";
import { AGENT_MAX_ITERATIONS_LIMIT } from "@/constants";
import { updateSetting, useSettingsValue } from "@/settings/model";
import { ToolDefinition } from "@/tools/ToolRegistry";
import { ToolRegistry } from "@/tools/ToolRegistry";
export const ToolSettingsSection: React.FC = () => {
@ -25,6 +26,21 @@ export const ToolSettingsSection: React.FC = () => {
updateSetting("autonomousAgentEnabledToolIds", Array.from(newEnabledIds));
};
/**
* Toggle all CLI tools on or off at once.
*/
const handleCliMasterToggle = (enabled: boolean, cliTools: ToolDefinition[]) => {
const newEnabledIds = new Set(enabledToolIds);
for (const { metadata } of cliTools) {
if (enabled) {
newEnabledIds.add(metadata.id);
} else {
newEnabledIds.delete(metadata.id);
}
}
updateSetting("autonomousAgentEnabledToolIds", Array.from(newEnabledIds));
};
const renderToolsByCategory = () => {
const categories = Array.from(toolsByCategory.entries()).filter(([_, tools]) =>
tools.some((t) => configurableTools.includes(t))
@ -35,6 +51,38 @@ export const ToolSettingsSection: React.FC = () => {
if (configurableInCategory.length === 0) return null;
// CLI tools get a special grouped section with a single master toggle
if (category === "cli") {
const allEnabled = configurableInCategory.every(({ metadata }) =>
enabledToolIds.has(metadata.id)
);
return (
<div
key="cli"
className="tw-flex tw-flex-col tw-gap-2 tw-rounded-md tw-border tw-border-border tw-pb-3"
>
<SettingItem
type="switch"
title="Obsidian CLI (Experimental)"
description="Enable direct vault operations via the Obsidian desktop CLI"
checked={allEnabled}
onCheckedChange={(checked) => handleCliMasterToggle(checked, configurableInCategory)}
/>
<div className="tw-ml-4 tw-flex tw-flex-col tw-gap-1 tw-border-l tw-border-border tw-px-3">
{configurableInCategory.map(({ metadata }) => (
<div key={metadata.id} className="tw-flex tw-flex-col">
<span className="tw-text-xs tw-font-medium tw-text-normal">
{metadata.displayName}
</span>
<span className="tw-text-xs tw-text-muted">{metadata.description}</span>
</div>
))}
</div>
</div>
);
}
return (
<React.Fragment key={category}>
{configurableInCategory.map(({ metadata }) => (

View file

@ -0,0 +1,136 @@
import { obsidianDailyReadTool, obsidianRandomReadTool } from "./ObsidianCliDailyTools";
import {
runDailyReadCommand,
runRandomReadCommand,
} from "@/services/obsidianCli/ObsidianCliClient";
jest.mock("@/services/obsidianCli/ObsidianCliClient", () => ({
runDailyReadCommand: jest.fn(),
runRandomReadCommand: jest.fn(),
}));
type CliResult = {
command: string;
args: string[];
binary: string;
attemptedBinaries: string[];
ok: boolean;
stdout: string;
stderr: string;
exitCode: number | null;
errorCode: string | number | null;
signal: string | null;
durationMs: number;
};
const mockedRunDailyReadCommand = runDailyReadCommand as jest.MockedFunction<
typeof runDailyReadCommand
>;
const mockedRunRandomReadCommand = runRandomReadCommand as jest.MockedFunction<
typeof runRandomReadCommand
>;
/**
* Build a minimal successful CLI response payload for tool tests.
*
* @param command - CLI command identifier.
* @param stdout - Command output payload.
* @returns Successful CLI response.
*/
function buildSuccessResult(command: string, stdout: string): CliResult {
return {
command,
args: [command],
binary: "obsidian",
attemptedBinaries: ["obsidian"],
ok: true,
stdout,
stderr: "",
exitCode: 0,
errorCode: null,
signal: null,
durationMs: 10,
};
}
/**
* Build a minimal failed CLI response payload for tool tests.
*
* @param command - CLI command identifier.
* @param errorCode - Process error code.
* @param stderr - Standard error output.
* @returns Failed CLI response.
*/
function buildFailedResult(command: string, errorCode: string, stderr: string): CliResult {
return {
command,
args: [command],
binary: "obsidian",
attemptedBinaries: [
"obsidian",
"/Applications/Obsidian.app/Contents/MacOS/obsidian",
"/Applications/Obsidian.app/Contents/MacOS/Obsidian",
],
ok: false,
stdout: "",
stderr,
exitCode: null,
errorCode,
signal: null,
durationMs: 10,
};
}
describe("ObsidianCliDailyTools", () => {
beforeEach(() => {
jest.clearAllMocks();
});
test("obsidianDailyReadTool returns parsed daily note payload", async () => {
mockedRunDailyReadCommand.mockResolvedValue(
buildSuccessResult("daily:read", "Today I worked on CLI integration.")
);
const response = await (obsidianDailyReadTool as any).invoke({ vault: "Work" });
const parsed = JSON.parse(response);
expect(parsed.type).toBe("obsidian_cli_daily_read");
expect(parsed.command).toBe("daily:read");
expect(parsed.vault).toBe("Work");
expect(parsed.content).toBe("Today I worked on CLI integration.");
expect(mockedRunDailyReadCommand).toHaveBeenCalledWith("Work");
});
test("obsidianRandomReadTool returns parsed random note payload", async () => {
mockedRunRandomReadCommand.mockResolvedValue(
buildSuccessResult("random:read", "Random note body")
);
const response = await (obsidianRandomReadTool as any).invoke({});
const parsed = JSON.parse(response);
expect(parsed.type).toBe("obsidian_cli_random_read");
expect(parsed.command).toBe("random:read");
expect(parsed.vault).toBeNull();
expect(parsed.content).toBe("Random note body");
expect(mockedRunRandomReadCommand).toHaveBeenCalledWith(undefined);
});
test("obsidianDailyReadTool throws CLI stderr on failure", async () => {
mockedRunDailyReadCommand.mockResolvedValue(
buildFailedResult("daily:read", "EFAIL", "daily note unavailable")
);
await expect((obsidianDailyReadTool as any).invoke({})).rejects.toThrow(
"daily note unavailable"
);
});
test("obsidianRandomReadTool surfaces actionable ENOENT failure details", async () => {
mockedRunRandomReadCommand.mockResolvedValue(buildFailedResult("random:read", "ENOENT", ""));
await expect((obsidianRandomReadTool as any).invoke({})).rejects.toThrow(
"CLI binary not found"
);
});
});

View file

@ -0,0 +1,84 @@
import { z } from "zod";
import {
runDailyReadCommand,
runRandomReadCommand,
} from "@/services/obsidianCli/ObsidianCliClient";
import { formatCliFailureMessage } from "@/services/obsidianCli/cliErrors";
import { createLangChainTool } from "./createLangChainTool";
/**
* Tool that reads today's daily note via the official Obsidian CLI (`daily:read`).
*/
export const obsidianDailyReadTool = createLangChainTool({
name: "obsidianDailyRead",
description:
"Read the current daily note via Obsidian CLI and return the note content as plain text.",
schema: z.object({
vault: z
.string()
.optional()
.describe(
"Optional vault name to target. Omit to use the active/default vault resolution from Obsidian CLI."
),
}),
func: async ({ vault }) => {
const result = await runDailyReadCommand(vault);
if (!result.ok) {
throw new Error(
`Failed to read daily note via Obsidian CLI: ${formatCliFailureMessage(
result.stderr,
result.exitCode,
result.errorCode,
result.attemptedBinaries
)}`
);
}
return {
type: "obsidian_cli_daily_read",
command: result.command,
vault: vault || null,
content: result.stdout,
durationMs: result.durationMs,
};
},
});
/**
* Tool that reads a random note via the official Obsidian CLI (`random:read`).
*/
export const obsidianRandomReadTool = createLangChainTool({
name: "obsidianRandomRead",
description: "Read a random note via Obsidian CLI and return the note content as plain text.",
schema: z.object({
vault: z
.string()
.optional()
.describe(
"Optional vault name to target. Omit to use the active/default vault resolution from Obsidian CLI."
),
}),
func: async ({ vault }) => {
const result = await runRandomReadCommand(vault);
if (!result.ok) {
throw new Error(
`Failed to read random note via Obsidian CLI: ${formatCliFailureMessage(
result.stderr,
result.exitCode,
result.errorCode,
result.attemptedBinaries
)}`
);
}
return {
type: "obsidian_cli_random_read",
command: result.command,
vault: vault || null,
content: result.stdout,
durationMs: result.durationMs,
};
},
});

View file

@ -0,0 +1,483 @@
import {
obsidianDailyNoteTool,
obsidianLinksTool,
obsidianPropertiesTool,
obsidianTasksTool,
obsidianTemplatesTool,
} from "./ObsidianCliTools";
import { runObsidianCliCommand } from "@/services/obsidianCli/ObsidianCliClient";
jest.mock("@/services/obsidianCli/ObsidianCliClient", () => ({
runObsidianCliCommand: jest.fn(),
}));
const mockedRunCommand = runObsidianCliCommand as jest.MockedFunction<typeof runObsidianCliCommand>;
type CliResult = {
command: string;
args: string[];
binary: string;
attemptedBinaries: string[];
ok: boolean;
stdout: string;
stderr: string;
exitCode: number | null;
errorCode: string | number | null;
signal: string | null;
durationMs: number;
};
function buildSuccessResult(command: string, stdout: string): CliResult {
return {
command,
args: [command],
binary: "obsidian",
attemptedBinaries: ["obsidian"],
ok: true,
stdout,
stderr: "",
exitCode: 0,
errorCode: null,
signal: null,
durationMs: 10,
};
}
function buildFailedResult(
command: string,
errorCode: string,
stderr: string,
exitCode: number | null = null
): CliResult {
return {
command,
args: [command],
binary: "obsidian",
attemptedBinaries: [
"obsidian",
"/Applications/Obsidian.app/Contents/MacOS/obsidian",
"/Applications/Obsidian.app/Contents/MacOS/Obsidian",
],
ok: false,
stdout: "",
stderr,
exitCode,
errorCode,
signal: null,
durationMs: 10,
};
}
beforeEach(() => {
jest.clearAllMocks();
});
// ---------------------------------------------------------------------------
// obsidianDailyNote
// ---------------------------------------------------------------------------
describe("obsidianDailyNoteTool", () => {
test("daily:read returns note content payload", async () => {
mockedRunCommand.mockResolvedValue(
buildSuccessResult("daily:read", "# 2026-03-03\n\nToday's tasks...")
);
const response = await (obsidianDailyNoteTool as any).invoke({ command: "daily:read" });
const parsed = JSON.parse(response);
expect(parsed.type).toBe("obsidian_cli_daily_note");
expect(parsed.command).toBe("daily:read");
expect(parsed.vault).toBeNull();
expect(parsed.content).toBe("# 2026-03-03\n\nToday's tasks...");
expect(mockedRunCommand).toHaveBeenCalledWith({
command: "daily:read",
vault: undefined,
params: {},
});
});
test("daily:path returns path payload", async () => {
mockedRunCommand.mockResolvedValue(buildSuccessResult("daily:path", "Daily/2026-03-03.md"));
const response = await (obsidianDailyNoteTool as any).invoke({
command: "daily:path",
vault: "Work",
});
const parsed = JSON.parse(response);
expect(parsed.type).toBe("obsidian_cli_daily_note");
expect(parsed.command).toBe("daily:path");
expect(parsed.vault).toBe("Work");
expect(parsed.content).toBe("Daily/2026-03-03.md");
});
test("daily:append passes content and inline params", async () => {
mockedRunCommand.mockResolvedValue(buildSuccessResult("daily:append", ""));
await (obsidianDailyNoteTool as any).invoke({
command: "daily:append",
content: "- New task",
inline: false,
});
expect(mockedRunCommand).toHaveBeenCalledWith({
command: "daily:append",
vault: undefined,
params: { content: "- New task", inline: false },
});
});
test("daily:prepend passes content param", async () => {
mockedRunCommand.mockResolvedValue(buildSuccessResult("daily:prepend", ""));
await (obsidianDailyNoteTool as any).invoke({
command: "daily:prepend",
content: "## Morning",
});
expect(mockedRunCommand).toHaveBeenCalledWith({
command: "daily:prepend",
vault: undefined,
params: { content: "## Morning" },
});
});
test("daily:append throws when content is missing", async () => {
await expect(
(obsidianDailyNoteTool as any).invoke({ command: "daily:append" })
).rejects.toThrow("content is required for daily:append");
});
test("daily:prepend throws when content is missing", async () => {
await expect(
(obsidianDailyNoteTool as any).invoke({ command: "daily:prepend" })
).rejects.toThrow("content is required for daily:prepend");
});
test("throws on CLI failure with stderr message", async () => {
mockedRunCommand.mockResolvedValue(
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");
});
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");
});
});
// ---------------------------------------------------------------------------
// obsidianProperties
// ---------------------------------------------------------------------------
describe("obsidianPropertiesTool", () => {
test("properties vault-wide returns property list", async () => {
mockedRunCommand.mockResolvedValue(
buildSuccessResult("properties", "aliases\nauthor\ndate\ntags")
);
const response = await (obsidianPropertiesTool as any).invoke({ command: "properties" });
const parsed = JSON.parse(response);
expect(parsed.type).toBe("obsidian_cli_properties");
expect(parsed.command).toBe("properties");
expect(parsed.content).toBe("aliases\nauthor\ndate\ntags");
expect(mockedRunCommand).toHaveBeenCalledWith({
command: "properties",
vault: undefined,
params: {},
});
});
test("properties passes file and counts params", async () => {
mockedRunCommand.mockResolvedValue(
buildSuccessResult("properties", "tags: false\ntitle: My Note")
);
await (obsidianPropertiesTool as any).invoke({
command: "properties",
file: "My Note",
counts: true,
});
expect(mockedRunCommand).toHaveBeenCalledWith({
command: "properties",
vault: undefined,
params: { file: "My Note", counts: true },
});
});
test("property:read returns single property value", async () => {
mockedRunCommand.mockResolvedValue(buildSuccessResult("property:read", "project, review"));
const response = await (obsidianPropertiesTool as any).invoke({
command: "property:read",
name: "tags",
file: "My Note",
});
const parsed = JSON.parse(response);
expect(parsed.content).toBe("project, review");
expect(mockedRunCommand).toHaveBeenCalledWith({
command: "property:read",
vault: undefined,
params: { name: "tags", file: "My Note" },
});
});
test("property:read throws when name is missing", async () => {
await expect(
(obsidianPropertiesTool as any).invoke({ command: "property:read" })
).rejects.toThrow("name is required for property:read");
});
test("throws on CLI failure with error code message", async () => {
// 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");
});
});
// ---------------------------------------------------------------------------
// obsidianTasks
// ---------------------------------------------------------------------------
describe("obsidianTasksTool", () => {
test("tasks returns task list", async () => {
mockedRunCommand.mockResolvedValue(
buildSuccessResult("tasks", "- [ ] Review PR #2181\n- [x] Write tests")
);
const response = await (obsidianTasksTool as any).invoke({ command: "tasks" });
const parsed = JSON.parse(response);
expect(parsed.type).toBe("obsidian_cli_tasks");
expect(parsed.command).toBe("tasks");
expect(parsed.content).toBe("- [ ] Review PR #2181\n- [x] Write tests");
expect(mockedRunCommand).toHaveBeenCalledWith({
command: "tasks",
vault: undefined,
params: {},
});
});
test("tasks passes all filter params", async () => {
mockedRunCommand.mockResolvedValue(buildSuccessResult("tasks", "- [ ] Task A"));
await (obsidianTasksTool as any).invoke({
command: "tasks",
file: "Project Plan",
todo: true,
verbose: true,
vault: "Work",
});
expect(mockedRunCommand).toHaveBeenCalledWith({
command: "tasks",
vault: "Work",
params: { file: "Project Plan", todo: true, verbose: true },
});
});
test("tasks with daily flag", async () => {
mockedRunCommand.mockResolvedValue(buildSuccessResult("tasks", "- [ ] Daily task"));
await (obsidianTasksTool as any).invoke({ command: "tasks", daily: true });
expect(mockedRunCommand).toHaveBeenCalledWith({
command: "tasks",
vault: undefined,
params: { daily: true },
});
});
test("throws on CLI failure with stderr", async () => {
mockedRunCommand.mockResolvedValue(buildFailedResult("tasks", "ENOENT", ""));
await expect((obsidianTasksTool as any).invoke({ command: "tasks" })).rejects.toThrow(
"CLI binary not found"
);
});
});
// ---------------------------------------------------------------------------
// obsidianLinks
// ---------------------------------------------------------------------------
describe("obsidianLinksTool", () => {
test("backlinks returns source file list", async () => {
mockedRunCommand.mockResolvedValue(
buildSuccessResult("backlinks", "Projects/roadmap.md\nDaily/2026-03-01.md")
);
const response = await (obsidianLinksTool as any).invoke({
command: "backlinks",
file: "My Note",
});
const parsed = JSON.parse(response);
expect(parsed.type).toBe("obsidian_cli_links");
expect(parsed.command).toBe("backlinks");
expect(parsed.content).toBe("Projects/roadmap.md\nDaily/2026-03-01.md");
expect(mockedRunCommand).toHaveBeenCalledWith({
command: "backlinks",
vault: undefined,
params: { file: "My Note" },
});
});
test("links returns outgoing link list", async () => {
mockedRunCommand.mockResolvedValue(
buildSuccessResult("links", "Ideas/brainstorm.md\nProjects/roadmap.md")
);
await (obsidianLinksTool as any).invoke({ command: "links", path: "Notes/note.md" });
expect(mockedRunCommand).toHaveBeenCalledWith({
command: "links",
vault: undefined,
params: { path: "Notes/note.md" },
});
});
test("orphans returns file list", async () => {
mockedRunCommand.mockResolvedValue(buildSuccessResult("orphans", "Inbox/draft.md\nAttic/old.md"));
const response = await (obsidianLinksTool as any).invoke({ command: "orphans" });
const parsed = JSON.parse(response);
expect(parsed.content).toBe("Inbox/draft.md\nAttic/old.md");
});
test("unresolved passes counts and verbose params", async () => {
mockedRunCommand.mockResolvedValue(
buildSuccessResult("unresolved", "Missing Note\t5\nOld Reference\t2")
);
await (obsidianLinksTool as any).invoke({
command: "unresolved",
counts: true,
verbose: false,
});
expect(mockedRunCommand).toHaveBeenCalledWith({
command: "unresolved",
vault: undefined,
params: { counts: true, verbose: false },
});
});
test("backlinks with total flag", async () => {
mockedRunCommand.mockResolvedValue(buildSuccessResult("backlinks", "4"));
await (obsidianLinksTool as any).invoke({ command: "backlinks", file: "Note", total: true });
expect(mockedRunCommand).toHaveBeenCalledWith({
command: "backlinks",
vault: undefined,
params: { file: "Note", total: true },
});
});
test("throws on CLI failure with stderr message", async () => {
mockedRunCommand.mockResolvedValue(
buildFailedResult("backlinks", "EFAIL", 'Error: File "note.md" not found.', 1)
);
await expect(
(obsidianLinksTool as any).invoke({ command: "backlinks", file: "note" })
).rejects.toThrow('File "note.md" not found.');
});
});
// ---------------------------------------------------------------------------
// obsidianTemplates
// ---------------------------------------------------------------------------
describe("obsidianTemplatesTool", () => {
test("templates returns list of template names", async () => {
mockedRunCommand.mockResolvedValue(
buildSuccessResult("templates", "Daily Note\nMeeting Notes\nProject Plan")
);
const response = await (obsidianTemplatesTool as any).invoke({ command: "templates" });
const parsed = JSON.parse(response);
expect(parsed.type).toBe("obsidian_cli_templates");
expect(parsed.command).toBe("templates");
expect(parsed.vault).toBeNull();
expect(parsed.content).toBe("Daily Note\nMeeting Notes\nProject Plan");
expect(mockedRunCommand).toHaveBeenCalledWith({
command: "templates",
vault: undefined,
params: {},
});
});
test("templates passes vault param", async () => {
mockedRunCommand.mockResolvedValue(buildSuccessResult("templates", "Daily Note"));
await (obsidianTemplatesTool as any).invoke({ command: "templates", vault: "Work" });
expect(mockedRunCommand).toHaveBeenCalledWith({
command: "templates",
vault: "Work",
params: {},
});
});
test("template:read returns template content", async () => {
mockedRunCommand.mockResolvedValue(
buildSuccessResult("template:read", "# {{date}}\n\n## Tasks\n- [ ] ")
);
const response = await (obsidianTemplatesTool as any).invoke({
command: "template:read",
name: "Daily Note",
});
const parsed = JSON.parse(response);
expect(parsed.type).toBe("obsidian_cli_templates");
expect(parsed.command).toBe("template:read");
expect(parsed.content).toBe("# {{date}}\n\n## Tasks\n- [ ]");
expect(mockedRunCommand).toHaveBeenCalledWith({
command: "template:read",
vault: undefined,
params: { name: "Daily Note" },
});
});
test("template:read throws when name is missing", async () => {
await expect(
(obsidianTemplatesTool as any).invoke({ command: "template:read" })
).rejects.toThrow("name is required for template:read");
});
test("throws on CLI failure with stderr message", async () => {
mockedRunCommand.mockResolvedValue(
buildFailedResult("templates", "EFAIL", "Templates plugin not enabled", 1)
);
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");
});
});

View file

@ -0,0 +1,299 @@
import { z } from "zod";
import { runObsidianCliCommand } from "@/services/obsidianCli/ObsidianCliClient";
import { throwCliFailure } from "@/services/obsidianCli/cliErrors";
import { createLangChainTool } from "./createLangChainTool";
/**
* Build CLI params from a tool args object, excluding `command` and `vault`.
* Filters out undefined values so only explicitly provided params are sent.
*/
function buildCliParams(args: Record<string, unknown>): Record<string, string | boolean> {
return Object.fromEntries(
Object.entries(args).filter(([k, v]) => k !== "command" && k !== "vault" && v !== undefined)
) as Record<string, string | boolean>;
}
// ---------------------------------------------------------------------------
// obsidianDailyNote — daily note category tool (v1)
// ---------------------------------------------------------------------------
const dailyNoteSchema = z.object({
command: z
.enum(["daily:read", "daily:append", "daily:prepend", "daily:path"])
.describe(
"daily:read — read today's daily note content. daily:append — append text to the end. daily:prepend — prepend text to the beginning. daily:path — get the vault-relative file path."
),
content: z
.string()
.optional()
.describe("Text to append or prepend. Required for daily:append and daily:prepend."),
inline: z
.boolean()
.optional()
.describe(
"For daily:append: append without a leading newline. For daily:prepend: prepend without a trailing newline."
),
vault: z
.string()
.optional()
.describe("Optional vault name to target. Omit to use the active vault."),
});
/**
* Category tool for all daily note operations via the official Obsidian CLI.
* Supports reading, appending, prepending, and path resolution.
*/
export const obsidianDailyNoteTool = createLangChainTool({
name: "obsidianDailyNote",
description:
"Read, append, or prepend content to today's daily note, or get its vault path, via the official Obsidian CLI. Use readNote for reading specific notes by path. Use obsidianRandomRead for picking a random note.",
schema: dailyNoteSchema,
func: async (args) => {
const { command, vault } = args;
if ((command === "daily:append" || command === "daily:prepend") && !args.content) {
throw new Error(`content is required for ${command}`);
}
const params = buildCliParams(args as Record<string, unknown>);
const result = await runObsidianCliCommand({ command, vault, params });
if (!result.ok) throwCliFailure(result);
// Preserve raw stdout for read commands — trimming may alter meaningful Markdown whitespace.
// Non-read commands (append, prepend, path) return short status strings where trimming is safe.
const content = command === "daily:read" ? result.stdout : result.stdout.trim();
return {
type: "obsidian_cli_daily_note",
command: result.command,
vault: vault ?? null,
content,
durationMs: result.durationMs,
};
},
});
// ---------------------------------------------------------------------------
// obsidianProperties — frontmatter property access (v1, read-only)
// ---------------------------------------------------------------------------
const propertiesSchema = z.object({
command: z
.enum(["properties", "property:read"])
.describe(
"properties — list property names vault-wide or key-value pairs for a specific note. property:read — read a single property value from a note."
),
name: z
.string()
.optional()
.describe(
"For property:read: the property name to read (required). For properties vault-wide: filter to this property name."
),
file: z.string().optional().describe("Target file by name (without extension)."),
path: z.string().optional().describe("Target file by vault-relative path."),
counts: z
.boolean()
.optional()
.describe("Include occurrence counts alongside property names (vault-wide mode)."),
sort: z
.string()
.optional()
.describe("Sort order for vault-wide listing. Use 'count' to sort by occurrence count."),
total: z.boolean().optional().describe("Return only the total property count."),
vault: z
.string()
.optional()
.describe("Optional vault name to target. Omit to use the active vault."),
});
/**
* Tool for reading frontmatter properties via the official Obsidian CLI.
* Supports vault-wide property listing and per-note property lookup.
*/
export const obsidianPropertiesTool = createLangChainTool({
name: "obsidianProperties",
description:
"Read frontmatter properties from your vault via the official Obsidian CLI: list all property names used vault-wide, or read specific property values from a note.",
schema: propertiesSchema,
func: async (args) => {
const { command, vault } = args;
if (command === "property:read" && !args.name) {
throw new Error("name is required for property:read");
}
const params = buildCliParams(args as Record<string, unknown>);
const result = await runObsidianCliCommand({ command, vault, params });
if (!result.ok) throwCliFailure(result);
return {
type: "obsidian_cli_properties",
command: result.command,
vault: vault ?? null,
content: result.stdout.trim(),
durationMs: result.durationMs,
};
},
});
// ---------------------------------------------------------------------------
// obsidianTasks — task listing across vault (v1, read-only)
// ---------------------------------------------------------------------------
const tasksSchema = z.object({
command: z.literal("tasks").describe("List tasks across the vault with optional filters."),
file: z.string().optional().describe("Filter tasks by file name."),
path: z.string().optional().describe("Filter tasks by vault-relative file path."),
todo: z.boolean().optional().describe("Show only incomplete (todo) tasks."),
done: z.boolean().optional().describe("Show only completed tasks."),
status: z
.string()
.optional()
.describe("Filter by a specific status character (e.g. '/' for in-progress)."),
daily: z.boolean().optional().describe("Show tasks from today's daily note only."),
verbose: z.boolean().optional().describe("Group tasks by file with line numbers."),
total: z.boolean().optional().describe("Return only the total task count."),
vault: z
.string()
.optional()
.describe("Optional vault name to target. Omit to use the active vault."),
});
/**
* Tool for listing tasks across the vault via the official Obsidian CLI.
* Supports filtering by completion status, file, daily note, and more.
*/
export const obsidianTasksTool = createLangChainTool({
name: "obsidianTasks",
description:
"List tasks across your vault via the official Obsidian CLI with filters for completion status, file, daily note, and more.",
schema: tasksSchema,
func: async (args) => {
const { command, vault } = args;
const params = buildCliParams(args as Record<string, unknown>);
const result = await runObsidianCliCommand({ command, vault, params });
if (!result.ok) throwCliFailure(result);
return {
type: "obsidian_cli_tasks",
command: result.command,
vault: vault ?? null,
content: result.stdout.trim(),
durationMs: result.durationMs,
};
},
});
// ---------------------------------------------------------------------------
// obsidianLinks — link graph queries (v1, read-only)
// ---------------------------------------------------------------------------
const linksSchema = z.object({
command: z
.enum(["backlinks", "links", "orphans", "unresolved"])
.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)."),
path: z
.string()
.optional()
.describe("Target file by vault-relative path (for backlinks and links commands)."),
counts: z
.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)."),
total: z.boolean().optional().describe("Return only the count."),
all: z
.boolean()
.optional()
.describe("Include non-markdown files such as images and PDFs (for orphans)."),
vault: z
.string()
.optional()
.describe("Optional vault name to target. Omit to use the active vault."),
});
/**
* Tool for querying the vault link graph via the official Obsidian CLI.
* Supports backlinks, outgoing links, orphaned notes, and unresolved wikilinks.
*/
export const obsidianLinksTool = createLangChainTool({
name: "obsidianLinks",
description:
"Query the vault link graph via the official Obsidian CLI: list backlinks to a file, outgoing links from a file, orphaned notes with no incoming links, or unresolved wikilinks.",
schema: linksSchema,
func: async (args) => {
const { command, vault } = args;
const params = buildCliParams(args as Record<string, unknown>);
const result = await runObsidianCliCommand({ command, vault, params });
if (!result.ok) throwCliFailure(result);
return {
type: "obsidian_cli_links",
command: result.command,
vault: vault ?? null,
content: result.stdout.trim(),
durationMs: result.durationMs,
};
},
});
// ---------------------------------------------------------------------------
// obsidianTemplates — template listing and reading (v1, read-only)
// ---------------------------------------------------------------------------
const templatesSchema = z.object({
command: z
.enum(["templates", "template:read"])
.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."),
vault: z
.string()
.optional()
.describe("Optional vault name to target. Omit to use the active vault."),
});
/**
* Tool for listing and reading templates via the official Obsidian CLI.
* Supports listing available templates and reading template content.
*/
export const obsidianTemplatesTool = createLangChainTool({
name: "obsidianTemplates",
description:
"List available templates or read template content via the official Obsidian CLI. Use before creating notes to find the right template.",
schema: templatesSchema,
func: async (args) => {
const { command, vault } = args;
if (command === "template:read" && !args.name) {
throw new Error("name is required for template:read");
}
const params = buildCliParams(args as Record<string, unknown>);
const result = await runObsidianCliCommand({ command, vault, params });
if (!result.ok) throwCliFailure(result);
return {
type: "obsidian_cli_templates",
command: result.command,
vault: vault ?? null,
content: result.stdout.trim(),
durationMs: result.durationMs,
};
},
});

View file

@ -8,7 +8,7 @@ export interface ToolMetadata {
id: string;
displayName: string;
description: string;
category: "search" | "time" | "file" | "media" | "mcp" | "memory" | "custom";
category: "search" | "time" | "file" | "media" | "mcp" | "memory" | "custom" | "cli";
isAlwaysEnabled?: boolean; // Tools that are always available (e.g., time tools)
requiresVault?: boolean; // Tools that need vault access
customPromptInstructions?: string; // Optional custom instructions for this tool

View file

@ -1,9 +1,18 @@
import { getSettings } from "@/settings/model";
import { Vault } from "obsidian";
import { isDesktopRuntime } from "@/services/obsidianCli/ObsidianCliClient";
import { replaceInFileTool, writeToFileTool } from "./ComposerTools";
import { createGetFileTreeTool } from "./FileTreeTools";
import { updateMemoryTool } from "./memoryTools";
import { readNoteTool } from "./NoteTools";
import { obsidianRandomReadTool } from "./ObsidianCliDailyTools";
import {
obsidianDailyNoteTool,
obsidianLinksTool,
obsidianPropertiesTool,
obsidianTasksTool,
obsidianTemplatesTool,
} from "./ObsidianCliTools";
import { localSearchTool, webSearchTool } from "./SearchTools";
import { createGetTagListTool } from "./TagTools";
import {
@ -316,6 +325,118 @@ Example: statement: "I'm studying Japanese and I'm preparing for JLPT N3"`,
});
}
/**
* Register desktop-only Obsidian CLI tools.
* These tools are completely invisible on mobile not registered, not shown in any UI.
*/
export function registerCliTools(): void {
const registry = ToolRegistry.getInstance();
registry.register({
tool: obsidianDailyNoteTool,
metadata: {
id: "obsidianDailyNote",
displayName: "Daily Note",
description: "Read, append, or prepend to today's daily note, or get its path",
category: "cli",
requiresVault: true,
customPromptInstructions: `For obsidianDailyNote:
- Use for all daily note operations: reading content, appending text, prepending text, or getting the file path.
- Use readNote for reading specific notes by path. Use obsidianDailyNote only for today's daily note.
- daily:read read today's daily note content.
- daily:append append text to the end of today's daily note. Requires content parameter.
- daily:prepend prepend text to the beginning of today's daily note. Requires content parameter.
- daily:path get the vault-relative file path of today's daily note (useful for follow-up readNote calls).
- daily:append and daily:prepend auto-create the daily note if it doesn't exist.
- To create today's daily note from a template: first use obsidianTemplates with 'templates' to list available templates and identify the daily note template, then use 'template:read' with the template name to get the resolved content (with variables like {{date}} expanded), then use daily:prepend to populate the new note.
- For arbitrary file writes beyond daily notes, use writeToFile or replaceInFile instead.
- If the user names a specific vault, pass it using the vault parameter.`,
},
});
registry.register({
tool: obsidianRandomReadTool,
metadata: {
id: "obsidianRandomRead",
displayName: "Random Note",
description: "Read a random note using the official Obsidian CLI",
category: "cli",
requiresVault: true,
customPromptInstructions: `For obsidianRandomRead:
- Use when the user explicitly asks for a random note or random note content.
- If the user names a specific vault, pass it using the vault parameter.
- Do not use when the user asks for a specific note; use readNote/getFileTree for specific targets.`,
},
});
registry.register({
tool: obsidianPropertiesTool,
metadata: {
id: "obsidianProperties",
displayName: "Properties",
description: "Read frontmatter properties from notes or list all property names in the vault",
category: "cli",
requiresVault: true,
customPromptInstructions: `For obsidianProperties:
- Use to inspect frontmatter properties across the vault or within a specific note.
- properties (no file/path): list all property names used vault-wide. Add counts=true for occurrence counts.
- properties with file= or path=: list that note's key-value property pairs.
- property:read: read a single property value from a specific note. Requires name parameter.
- Do not use for full note content use readNote for that.`,
},
});
registry.register({
tool: obsidianTasksTool,
metadata: {
id: "obsidianTasks",
displayName: "Tasks",
description: "List tasks across the vault with filters for status, file, and daily note",
category: "cli",
requiresVault: true,
customPromptInstructions: `For obsidianTasks:
- Use to list and filter tasks across the vault.
- todo=true: show only incomplete tasks. done=true: show only completed tasks.
- daily=true: show tasks from today's daily note only.
- verbose=true: group tasks by file with line numbers.
- total=true: return only the task count.
- file= or path=: limit to a specific note.`,
},
});
registry.register({
tool: obsidianLinksTool,
metadata: {
id: "obsidianLinks",
displayName: "Links",
description: "Query the vault link graph: backlinks, outgoing links, orphans, unresolved",
category: "cli",
requiresVault: true,
customPromptInstructions: `For obsidianLinks:
- Use to explore the vault's link graph.
- backlinks: list notes that link TO a given file. Use file= or path= to target a note.
- links: list outgoing links FROM a given file. Use file= or path= to target a note.
- orphans: list all notes with no incoming links. Use total=true for just the count.
- unresolved: list wikilinks that don't resolve to any existing file. Use counts=true for occurrence counts, verbose=true to see source files.`,
},
});
registry.register({
tool: obsidianTemplatesTool,
metadata: {
id: "obsidianTemplates",
displayName: "Templates",
description: "List available templates or read template content",
category: "cli",
requiresVault: true,
customPromptInstructions: `For obsidianTemplates:
- Use 'templates' to list all available templates when you need to find the right template for a task.
- Use 'template:read' with a template name to get its content with variables resolved. Requires name parameter.
- This is useful for creating daily notes from templates read the template first, then use obsidianDailyNote's daily:prepend to populate the note.`,
},
});
}
/**
* Initialize all built-in tools in the registry.
* This function registers tool definitions, not user preferences.
@ -354,5 +475,10 @@ export function initializeBuiltinTools(vault?: Vault): void {
if (settings.enableSavedMemory) {
registerMemoryTool();
}
// Register desktop-only CLI tools (invisible on mobile)
if (isDesktopRuntime()) {
registerCliTools();
}
}
}