diff --git a/designdocs/OBSIDIAN_CLI_INTEGRATION.md b/designdocs/OBSIDIAN_CLI_INTEGRATION.md new file mode 100644 index 00000000..7f3d9af0 --- /dev/null +++ b/designdocs/OBSIDIAN_CLI_INTEGRATION.md @@ -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 [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=` (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=` | 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=` | 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=` | No | Target file by name (without extension). | +| `path=` | No | Target file by vault-relative path. | +| `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=` | Yes | Property name to read. | +| `file=` | No | Target file by name. | +| `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=` | No | Filter by file name. | +| `path=` | No | Filter by file path. | +| `todo` | No | Show only incomplete tasks. | +| `done` | No | Show only completed tasks. | +| `status=""` | 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=` | 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=` | No | Target file by name. | +| `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=` | No | Source file by name. | +| `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=` | 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=` 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` | diff --git a/designdocs/todo/OBSIDIAN_CLI_INTEGRATION.md b/designdocs/todo/OBSIDIAN_CLI_INTEGRATION.md deleted file mode 100644 index 2303457e..00000000 --- a/designdocs/todo/OBSIDIAN_CLI_INTEGRATION.md +++ /dev/null @@ -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_ diff --git a/src/LLMProviders/chainRunner/utils/AgentReasoningState.test.ts b/src/LLMProviders/chainRunner/utils/AgentReasoningState.test.ts new file mode 100644 index 00000000..259b1b7f --- /dev/null +++ b/src/LLMProviders/chainRunner/utils/AgentReasoningState.test.ts @@ -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"); + }); +}); diff --git a/src/LLMProviders/chainRunner/utils/AgentReasoningState.ts b/src/LLMProviders/chainRunner/utils/AgentReasoningState.ts index fdf06178..baaef8b9 100644 --- a/src/LLMProviders/chainRunner/utils/AgentReasoningState.ts +++ b/src/LLMProviders/chainRunner/utils/AgentReasoningState.ts @@ -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": diff --git a/src/LLMProviders/chainRunner/utils/toolExecution.ts b/src/LLMProviders/chainRunner/utils/toolExecution.ts index 0578e2a0..41c84d7e 100644 --- a/src/LLMProviders/chainRunner/utils/toolExecution.ts +++ b/src/LLMProviders/chainRunner/utils/toolExecution.ts @@ -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] || "🔧"; diff --git a/src/services/obsidianCli/ObsidianCliClient.test.ts b/src/services/obsidianCli/ObsidianCliClient.test.ts new file mode 100644 index 00000000..21cb8ac9 --- /dev/null +++ b/src/services/obsidianCli/ObsidianCliClient.test.ts @@ -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((_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((_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((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((_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"); + }); +}); diff --git a/src/services/obsidianCli/ObsidianCliClient.ts b/src/services/obsidianCli/ObsidianCliClient.ts new file mode 100644 index 00000000..25f4d21b --- /dev/null +++ b/src/services/obsidianCli/ObsidianCliClient.ts @@ -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; + 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; + }; +} + +/** + * 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=` 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 { + const startedAt = Date.now(); + + return await new Promise((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 { + 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 { + 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 { + return await runObsidianCliCommand({ + command: "random:read", + vault, + }); +} diff --git a/src/services/obsidianCli/cliErrors.ts b/src/services/obsidianCli/cliErrors.ts new file mode 100644 index 00000000..46aaf834 --- /dev/null +++ b/src/services/obsidianCli/cliErrors.ts @@ -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) + ); +} diff --git a/src/settings/v2/components/ToolSettingsSection.tsx b/src/settings/v2/components/ToolSettingsSection.tsx index 45bdcfea..06a72cfe 100644 --- a/src/settings/v2/components/ToolSettingsSection.tsx +++ b/src/settings/v2/components/ToolSettingsSection.tsx @@ -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 ( +
+ handleCliMasterToggle(checked, configurableInCategory)} + /> +
+ {configurableInCategory.map(({ metadata }) => ( +
+ + {metadata.displayName} + + {metadata.description} +
+ ))} +
+
+ ); + } + return ( {configurableInCategory.map(({ metadata }) => ( diff --git a/src/tools/ObsidianCliDailyTools.test.ts b/src/tools/ObsidianCliDailyTools.test.ts new file mode 100644 index 00000000..8a4c9e41 --- /dev/null +++ b/src/tools/ObsidianCliDailyTools.test.ts @@ -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" + ); + }); +}); diff --git a/src/tools/ObsidianCliDailyTools.ts b/src/tools/ObsidianCliDailyTools.ts new file mode 100644 index 00000000..416b8701 --- /dev/null +++ b/src/tools/ObsidianCliDailyTools.ts @@ -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, + }; + }, +}); diff --git a/src/tools/ObsidianCliTools.test.ts b/src/tools/ObsidianCliTools.test.ts new file mode 100644 index 00000000..0c560915 --- /dev/null +++ b/src/tools/ObsidianCliTools.test.ts @@ -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; + +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"); + }); +}); diff --git a/src/tools/ObsidianCliTools.ts b/src/tools/ObsidianCliTools.ts new file mode 100644 index 00000000..f15a3add --- /dev/null +++ b/src/tools/ObsidianCliTools.ts @@ -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): Record { + return Object.fromEntries( + Object.entries(args).filter(([k, v]) => k !== "command" && k !== "vault" && v !== undefined) + ) as Record; +} + +// --------------------------------------------------------------------------- +// 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); + 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); + 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); + 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); + 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); + 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, + }; + }, +}); diff --git a/src/tools/ToolRegistry.ts b/src/tools/ToolRegistry.ts index 287581a6..10eec309 100644 --- a/src/tools/ToolRegistry.ts +++ b/src/tools/ToolRegistry.ts @@ -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 diff --git a/src/tools/builtinTools.ts b/src/tools/builtinTools.ts index 5761b30a..426234db 100644 --- a/src/tools/builtinTools.ts +++ b/src/tools/builtinTools.ts @@ -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(); + } } }