diff --git a/.bun-version b/.bun-version
deleted file mode 100644
index 9b51125..0000000
--- a/.bun-version
+++ /dev/null
@@ -1 +0,0 @@
-1.1.34
\ No newline at end of file
diff --git a/.claude/settings.local.json b/.claude/settings.local.json
deleted file mode 100644
index f545e12..0000000
--- a/.claude/settings.local.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "permissions": {
- "allow": [
- "WebSearch",
- "WebFetch(domain:bun.com)",
- "WebFetch(domain:bun.sh)",
- "WebFetch(domain:github.com)"
- ],
- "deny": [],
- "ask": []
- }
-}
\ No newline at end of file
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..fd3ad8d
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,26 @@
+# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
+
+version: 2
+updates:
+ - package-ecosystem: npm
+ directory: /
+ schedule:
+ interval: weekly
+ day: monday
+ open-pull-requests-limit: 10
+ commit-message:
+ prefix: "deps"
+ groups:
+ # Reduce PR noise while keeping major updates separate for review
+ minor-and-patch:
+ update-types:
+ - minor
+ - patch
+
+ - package-ecosystem: github-actions
+ directory: /
+ schedule:
+ interval: weekly
+ day: monday
+ commit-message:
+ prefix: "ci"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..64c3b8f
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,61 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ test:
+ name: Test
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ node-version: [20, 22, 24]
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+
+ - name: Setup Node.js ${{ matrix.node-version }}
+ uses: actions/setup-node@v6
+ with:
+ node-version: ${{ matrix.node-version }}
+ cache: npm
+
+ - name: Enable corepack
+ run: corepack enable
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Run tests
+ run: npm test
+
+ - name: Build
+ run: npm run build
+
+ security:
+ name: Security audit
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version-file: .nvmrc
+ cache: npm
+
+ - name: Enable corepack
+ run: corepack enable
+
+ - name: Verify lockfile integrity
+ run: npm ci
+
+ # https://docs.npmjs.com/cli/v10/commands/npm-audit
+ - name: Run npm audit
+ run: npm audit --audit-level=moderate
+
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 0000000..762d8ea
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,38 @@
+name: CodeQL
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+ schedule:
+ - cron: "0 0 * * 1"
+
+jobs:
+ analyze:
+ name: Analyze
+ runs-on: ubuntu-latest
+ permissions:
+ actions: read
+ contents: read
+ security-events: write
+
+ strategy:
+ fail-fast: false
+ matrix:
+ language: [javascript-typescript]
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v4
+ with:
+ languages: ${{ matrix.language }}
+ queries: security-extended
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v4
+ with:
+ category: "/language:${{ matrix.language }}"
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 0000000..74161a9
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,44 @@
+# https://docs.npmjs.com/generating-provenance-statements
+
+name: Publish
+
+on:
+ release:
+ types: [published]
+
+permissions: {}
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ id-token: write
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version-file: .nvmrc
+ registry-url: https://registry.npmjs.org
+
+ - name: Enable corepack
+ run: corepack enable
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Run tests
+ run: npm test
+
+ - name: Build
+ run: npm run build
+
+ # Provenance creates a verifiable link between the npm package
+ # and this GitHub repository, allowing users to audit the source
+ - name: Publish with provenance
+ run: npm publish --provenance --access public
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml
new file mode 100644
index 0000000..9804487
--- /dev/null
+++ b/.github/workflows/scorecard.yml
@@ -0,0 +1,39 @@
+# https://securityscorecards.dev/
+# https://github.com/ossf/scorecard-action
+
+name: Scorecard
+
+on:
+ branch_protection_rule:
+ schedule:
+ - cron: "0 0 * * 1"
+ push:
+ branches: [main]
+
+permissions: read-all
+
+jobs:
+ analysis:
+ name: Scorecard analysis
+ runs-on: ubuntu-latest
+ permissions:
+ security-events: write
+ id-token: write
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Run analysis
+ uses: ossf/scorecard-action@v2.4.3
+ with:
+ results_file: results.sarif
+ results_format: sarif
+ publish_results: true
+
+ - name: Upload results to GitHub Security tab
+ uses: github/codeql-action/upload-sarif@v4
+ with:
+ sarif_file: results.sarif
diff --git a/.gitignore b/.gitignore
index 8d950cd..0405f10 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,7 +9,6 @@ coverage/
# Production
build/
-dist/
# Runtime data
pids
@@ -81,11 +80,10 @@ jspm_packages/
# Nuxt.js build / generate output
.nuxt
-dist
# Gatsby files
.cache/
-public
+# public (excluded to allow website/public/)
# Storybook build outputs
.out
@@ -119,9 +117,21 @@ Thumbs.db
*.swo
*~
+# Claude Code settings
+.claude/settings.local.json
+
# Bun
bun.lockb
# NPM publishing
npm-debug.log*
.npm
+.npmrc
+
+# Website (monorepo)
+website/node_modules/
+website/dist/
+website/.astro/
+website/.wrangler/
+website/package-lock.json
+website/bun.lock
diff --git a/.npmignore b/.npmignore
new file mode 100644
index 0000000..b710948
--- /dev/null
+++ b/.npmignore
@@ -0,0 +1,63 @@
+# Source files
+*.ts
+!*.d.ts
+
+# Tests
+*.test.js
+*.test.ts
+*.spec.js
+*.spec.ts
+__tests__/
+test/
+tests/
+
+# Config files
+tsconfig.json
+tsconfig.build.json
+vitest.config.ts
+.eslintrc*
+.prettierrc*
+.editorconfig
+
+# Development files
+debug-server.js
+.nvmrc
+.npmrc
+
+# CI/CD
+.github/
+.gitlab-ci.yml
+.travis.yml
+.circleci/
+
+# Git
+.git/
+.gitignore
+.gitattributes
+
+# Documentation (optional - you may want to keep some)
+CHANGELOG.md
+docs/
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+
+# OS files
+.DS_Store
+Thumbs.db
+
+# Logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# Dependencies
+node_modules/
+
+# Website (monorepo)
+website/
diff --git a/.npmrc b/.npmrc
deleted file mode 100644
index 102bcc1..0000000
--- a/.npmrc
+++ /dev/null
@@ -1,11 +0,0 @@
-# NPM Registry Configuration
-registry=https://registry.npmjs.org/
-
-# Scoped package configuration for @bitbonsai
-@mauricio.wolff:registry=https://registry.npmjs.org/
-
-# Publishing configuration
-access=public
-
-# Optional: Save exact versions
-save-exact=true
diff --git a/.nvmrc b/.nvmrc
index 8fdd954..a45fd52 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-22
\ No newline at end of file
+24
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..4bb60ad
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,130 @@
+# Agent Instructions
+
+## Project Overview
+
+MCPVault is a Model Context Protocol (MCP) server that provides a universal AI bridge for Obsidian vaults. It enables any MCP-compatible AI assistant (Claude, ChatGPT, Gemini, etc.) to safely read and write notes in Obsidian vaults while preserving YAML frontmatter and enforcing security boundaries.
+
+## Commands
+
+```bash
+# MCP server
+npm run build # Compile TypeScript to dist/
+npm test # Run test suite (Vitest)
+npm run test:watch # Tests in watch mode
+npm start /path/vault # Run server locally with tsx
+
+# Single test
+npm test -- path/to/test.test.ts
+npm test -- -t "test name pattern"
+
+# Publishing
+npm run publish:dry # Dry run
+npm run publish:beta # Publish with beta tag
+npm run publish:latest # Publish as latest
+
+# Website
+npm run website # Start Astro dev server with Bun (http://localhost:4321)
+
+# MCP Inspector
+npx @modelcontextprotocol/inspector npm start /path/to/vault
+```
+
+## Architecture
+
+### File Structure
+
+```
+server.ts # MCP server entry point, tool registration, request handlers
+src/
+ filesystem.ts # FileSystemService — all file operations with security
+ frontmatter.ts # FrontmatterHandler — YAML parsing via gray-matter
+ pathfilter.ts # PathFilter — security layer for path validation
+ search.ts # SearchService — full-text search with token-optimized output
+ uri.ts # Obsidian URI generation
+ types.ts # All TypeScript interfaces
+ *.test.ts # Co-located test files
+website/ # Astro 5 website (separate package, see website/AGENTS.md)
+```
+
+### Core Components
+
+**server.ts** — Entry point. Registers 15 MCP tools, handles CLI args (--help, --version, vault path), initializes services, routes tool calls. Auto-trims whitespace from all path arguments.
+
+**FileSystemService** (`src/filesystem.ts`) — Orchestrates file ops with security. Path resolution and traversal prevention. Implements: read, write, patch, delete, move, list, batch read, frontmatter update, tag management, vault stats. Uses native `fs/promises`.
+
+**FrontmatterHandler** (`src/frontmatter.ts`) — Parses/stringifies YAML frontmatter via `gray-matter`. Validates structure (blocks functions, symbols, invalid types). Preserves original content.
+
+**PathFilter** (`src/pathfilter.ts`) — Blocks `.obsidian/`, `.git/`, `node_modules/`, system files, dot files. Note tools allow `.md`, `.markdown`, `.txt`; directory listings may include other file types by filename. Checks path components independently.
+
+**SearchService** (`src/search.ts`) — Content and frontmatter search with multi-word matching and BM25 relevance reranking. Returns token-optimized results with minified field names: `{p, t, ex, mc, ln, uri}`. Max 20 results.
+
+### 15 MCP Tools
+
+| Tool | Description |
+|------|-------------|
+| read_note | Read a single note with frontmatter |
+| write_note | Create or overwrite (supports overwrite, append, prepend modes) |
+| patch_note | Efficient partial update via find-and-replace |
+| list_directory | List files and folders in the vault |
+| delete_note | Delete a note (requires path confirmation) |
+| search_notes | Full-text search across vault content |
+| move_note | Move or rename a note |
+| move_file | Move or rename any file (binary-safe, file-only, requires path confirmation) |
+| read_multiple_notes | Batch read up to 10 notes |
+| update_frontmatter | Safely update YAML frontmatter |
+| get_notes_info | Get metadata without reading content |
+| get_frontmatter | Extract frontmatter only |
+| manage_tags | Add, remove, or list tags |
+| get_vault_stats | Vault statistics: total notes, folders, size, recent files |
+| list_all_tags | List all tags across the vault with occurrence counts |
+
+### Design Patterns
+
+- **Service layer**: Each service has single responsibility, dependency-injected into server.ts, independently testable
+- **Security-first**: All paths validated through PathFilter, `resolvePath()` prevents traversal, confirmation required for destructive ops
+- **Token optimization**: Minified field names by default (`fm` not `frontmatter`), optional `prettyPrint` parameter, compact search format
+- **Error handling**: Structured results with `success` boolean, failed batch ops return partial results (`ok` + `err` arrays)
+
+### Key Implementation Details
+
+- **Paths**: Always relative to vault root. Leading slashes stripped. Whitespace trimmed automatically.
+- **Frontmatter**: Always use FrontmatterHandler for read/write. `originalContent` field has raw file content. Empty frontmatter = no YAML block.
+- **Write modes**: overwrite (default), append (content to end, merge frontmatter), prepend (content to beginning, merge frontmatter)
+- **Patch**: Exact string match including whitespace/newlines. `replaceAll: false` (default) fails on multiple matches to prevent accidents.
+- **Version**: Read from `package.json` at runtime. Used in MCP server init, --version flag, and website nav badge.
+
+## Website (Dual Content)
+
+The `website/` directory is a separate Astro package. It serves content in two formats that **must be kept in sync**:
+
+| Format | Location | Audience |
+|--------|----------|----------|
+| HTML (rich, interactive) | `website/src/components/` | Browsers |
+| Markdown (plain text) | `website/public/*.md` + `llm.txt` | LLMs and AI agents |
+
+When updating content, always update both. See `website/AGENTS.md` for full details and file mapping.
+
+## Testing
+
+Vitest with globals enabled, node environment. Test files co-located as `*.test.ts`.
+
+When writing tests:
+- Test both success and error cases
+- Test path security (traversal, access denied)
+- Test frontmatter parsing edge cases
+- Use `Promise.allSettled` patterns for batch operations
+
+## Security
+
+When modifying file operations:
+- Always validate paths through PathFilter
+- Always use `resolvePath()` to prevent traversal
+- Never expose system directories or configuration
+- Validate frontmatter before writing
+- Require confirmation for destructive operations
+
+## Config Files
+
+- `tsconfig.json` — Main TypeScript config (strict mode, ES2022)
+- `tsconfig.build.json` — Build config (excludes tests, outputs to `dist/`)
+- `vitest.config.ts` — Test config (globals, node environment)
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..036e5b2
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,317 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [0.11.2] - 2026-04-16
+
+### Added
+- `delete_note` now supports soft-delete via `trashMode` parameter ([#91](https://github.com/bitbonsai/mcpvault/issues/91))
+ - `none` (default): permanent delete (previous behavior)
+ - `local`: move file to `.trash/` inside the vault, preserving relative folder structure
+ - `system`: move file to OS trash/recycle bin via the `trash` package
+
+## [0.11.1] - 2026-04-16
+
+### Fixed
+- Frontmatter updates now use AST-aware YAML preservation. Unmodified fields keep their original formatting, fixing:
+ - `YYYY-MM-DD` dates being rewritten as full ISO timestamps ([#77](https://github.com/bitbonsai/mcpvault/issues/77))
+ - `HH:MM` values being parsed as YAML 1.1 sexagesimal integers ([#75](https://github.com/bitbonsai/mcpvault/issues/75))
+ - Quoted strings losing their quote style ([#76](https://github.com/bitbonsai/mcpvault/issues/76))
+
+### Changed
+- `update_frontmatter`, `manage_tags`, and `write_note` (append/prepend modes) now preserve raw YAML for unmodified fields via `yaml.parseDocument`
+
+## [0.11.0] - 2026-03-22
+
+### Added
+- New `list_all_tags` tool: scans all vault notes for frontmatter tags and inline `#hashtags`, returns deduplicated list sorted by frequency ([#80](https://github.com/bitbonsai/mcpvault/issues/80))
+- Obsidian CLI integration in the skill: preflight checks, command patterns for active file, daily notes, backlinks, and open-in-editor
+
+## [0.10.0] - 2026-03-20
+
+### Added
+- New `createServer(vaultPath, options?)` factory function for library consumers ([#84](https://github.com/bitbonsai/mcpvault/issues/84))
+- `src/index.ts` barrel exports for all public APIs and types
+- TypeScript declaration files (`.d.ts`) included in published package
+- `exports`, `types` fields in `package.json` for proper ESM library consumption
+
+### Changed
+- `server.ts` slimmed to ~60-line CLI entry point, all logic moved to `src/createServer.ts`
+- Test files excluded from `dist/` output
+- Minimum Node version bumped to 20 (Node 18 EOL)
+
+## [0.9.1] - 2026-03-20
+
+### Fixed
+- Symlinks inside the vault that resolve outside the vault boundary are now blocked, closing a path traversal bypass ([#78](https://github.com/bitbonsai/mcpvault/issues/78))
+- Circular symlinks (ELOOP) and permission-denied symlink targets (EACCES) return clear error messages
+- `list_directory` now includes symlinked files and directories when the target resolves within the vault (previously all symlinks were silently skipped)
+
+### Changed
+- Vault root path is now resolved through symlinks at startup for consistent boundary checks
+- Dropped Node 18 from CI test matrix (EOL since April 2025, vitest 4.1 requires Node 20+)
+
+## [0.9.0] - 2026-03-12
+
+### Changed
+- Package renamed to `@bitbonsai/mcpvault` on npm at Obsidian's request — update your config by replacing `mcpvault` with `@bitbonsai/mcpvault`
+
+## [0.8.2] - 2026-03-08
+
+### Added
+- Support for Obsidian first-party note formats: `.base` and `.canvas`
+- Optional CLI startup without vault path argument (defaults to current working directory)
+
+### Changed
+- Website install/docs refreshed with optional CWD mode guidance and updated release notes
+- Website UX polish: collapsible install helpers, improved update callout behavior, and icon consistency
+
+### Fixed
+- `write_note` and `update_frontmatter` now safely handle frontmatter values passed as JSON strings
+- npm publish metadata cleanup for binary mapping and repository URL consistency
+
+## [0.8.1] - 2026-02-27
+
+### Added
+- New `move_file` tool for binary-safe file moves with explicit path confirmation
+
+### Changed
+- Search relevance improvements for multi-word BM25 ranking
+- Better support for non-note filenames in directory listing visibility
+
+### Fixed
+- `patch_note` undefined/null validation hardening
+
+## [0.7.5] - 2026-02-16
+
+### Changed
+- Search now matches note filenames in addition to content
+- Hidden directories are filtered from listings
+- OpenCode install docs and setup guidance updated
+
+## [0.7.4] - 2026-01-24
+
+### Added
+- `get_vault_stats` tool for vault-wide stats and recent file metadata
+
+### Changed
+- Improved error messages with actionable remediation suggestions
+
+## [0.7.3] - 2025-12-21
+
+### Fixed
+- Folder detection bug for directories containing dots in names
+
+## [0.7.2] - 2025-12-08
+
+### Fixed
+- Security hardening improvements including TOCTOU protections and regex injection prevention
+
+### Changed
+- CI/CD coverage improvements for reliability and release safety
+
+## [0.6.3] - 2025-10-10
+
+### Added
+- **Token optimization**: New `prettyPrint` parameter for all JSON responses (default: false)
+ - Applies to: `read_note`, `search_notes`, `list_directory`, `read_multiple_notes`, `get_notes_info`, `get_frontmatter`
+ - Reduces token usage by ~30-40% when disabled
+
+### Changed
+- **Response optimization**: Removed redundant fields from responses to reduce token count
+ - `read_note`: Removed redundant `path` field, shortened `frontmatter` to `fm`
+ - `list_directory`: Shortened `directories` to `dirs`, removed redundant `path` field
+ - `search_notes`: Removed redundant `query` and `resultCount` wrapper
+ - `read_multiple_notes`: Shortened fields to `ok`/`err`, removed redundant summary
+ - `get_notes_info`: Returns array directly without wrapper
+ - `get_frontmatter`: Returns frontmatter directly without wrapper
+- **Search results**: Minified field names for 40-60% token reduction
+ - `path` → `p`
+ - `title` → `t`
+ - `excerpt` → `ex`
+ - `matchCount` → `mc`
+ - `lineNumber` → `ln`
+- **Search excerpt**: Reduced context from 50 to 21 characters before/after match
+- **JSON formatting**: Default to compact (no pretty-printing) to save tokens
+
+### Performance
+- **Overall token reduction**: 40-60% fewer tokens in typical responses
+- **Search operations**: Significantly faster with smaller excerpts and minified fields
+- **API responses**: More efficient for high-volume operations
+
+## [0.6.1] - 2025-10-09
+
+### Added
+- **Comprehensive patch_note testing**: Added 16 tests covering all edge cases
+ - Single and multiple occurrence handling
+ - Empty string validation
+ - Special character handling (regex chars treated literally)
+ - Whitespace preservation (tabs and spaces)
+ - Case sensitivity verification
+ - Performance testing (100+ replacements)
+ - Path handling with spaces
+- **Test reorganization**: Moved tests from `tests/` to `src/` for better co-location
+ - Merged filesystem, patch, and integration tests into single file
+ - All 38 tests passing
+
+### Fixed
+- **patch_note validation**: Added validation for empty `oldString` and `newString` parameters
+- **Error messages**: Improved error messages for empty string parameters
+
+### Changed
+- **Test structure**: Reorganized test files to be co-located with source files
+ - `src/filesystem.test.ts` (30 tests)
+ - `src/frontmatter.test.ts` (8 tests)
+
+## [0.6.0] - 2025-10-08
+
+### Added
+- **patch_note tool**: Efficient partial note updates by replacing specific strings
+ - Replace single or multiple occurrences
+ - Multiline string support
+ - Safety checks for multiple matches
+ - Preserves frontmatter
+ - More efficient than rewriting entire files
+
+## [0.5.4] - 2025-09-23
+
+### Fixed
+- **YAML frontmatter operations**: Fixed critical "yaml.dump is not a function" errors in `write_note`, `update_frontmatter`, and `manage_tags` tools
+- **Dependency cleanup**: Removed js-yaml dependency entirely, now using gray-matter for all YAML operations
+- **list_directory**: Fixed handling of "." path to properly return root directory contents including both files and directories
+
+### Added
+- **Comprehensive test suite**: Added 22 integration tests covering all frontmatter operations
+- **Test coverage**: Added tests for write_note with frontmatter, append/prepend modes, update operations, and tag management
+- **Error validation**: Added edge case testing for tag management and frontmatter validation
+
+### Changed
+- **YAML handling**: Migrated all YAML serialization to use gray-matter consistently
+- **Validation**: Updated frontmatter validation to use `matter.stringify()` instead of `yaml.dump()`
+- **Package.json**: Updated main field to point to correct compiled output
+
+## [0.5.1] - 2025-09-23
+
+### Fixed
+- **Package configuration**: Fixed package.json main field to point to `dist/server.js`
+- **Executable permissions**: Fixed executable permissions for compiled JavaScript output
+- **Usage message**: Fixed usage message in compiled JavaScript to show correct npm command
+- **Claude Desktop compatibility**: Fixed "env: bun: No such file or directory" error
+
+### Added
+- **TypeScript compilation**: Added proper TypeScript build process for npm distribution
+- **Distribution files**: Added compiled JavaScript files to dist/ directory
+
+## [0.5.0] - 2025-09-23
+
+### Changed
+- **🚨 BREAKING**: Complete migration from Bun to npm/Node.js runtime
+- **Runtime**: Replaced all Bun APIs with Node.js equivalents
+- **Package manager**: Converted from Bun to npm package management
+- **Build system**: Added TypeScript compilation for distribution
+
+### Added
+- **Node.js compatibility**: Full Node.js runtime support (v18.0.0+)
+- **npm distribution**: Published as npm package `mcpvault`
+- **TypeScript tooling**: Added tsx for development and tsc for building
+- **Vitest testing**: Replaced Bun test with Vitest test runner
+
+### Removed
+- **Bun dependencies**: Removed all Bun-specific APIs and runtime dependencies
+- **Bun.file(), Bun.write()**: Replaced with Node.js fs functions
+- **Bun.Glob()**: Replaced with recursive directory scanning
+
+## [0.3.0] - 2025-09-23
+
+### Added
+- **Write modes**: Added `append`, `prepend`, and `overwrite` modes for flexible content editing
+- **Tag management**: Complete tag management system with add, remove, and list operations
+- **get_frontmatter**: New tool for metadata-only extraction without reading full content
+- **Path trimming**: Automatic whitespace handling in path inputs
+- **API documentation**: Complete documentation for all 11 MCP methods
+- **Quick Start guide**: 5-minute setup guide for immediate use
+- **Multi-platform support**: Claude Desktop, Claude Code, ChatGPT Desktop, IntelliJ IDEA support
+
+### Changed
+- **Package scope**: Migrated to scoped npm package `mcpvault`
+- **Documentation**: Made README AI-agnostic with comprehensive examples
+- **Version consistency**: Synchronized version across all files
+
+### Fixed
+- **API documentation**: Added missing docs for search_notes, move_note, read_multiple_notes, update_frontmatter, get_notes_info
+- **MCP inspector**: Fixed command syntax for testing
+
+## [0.2.x] - 2025-09-21/22
+
+### Added
+- **delete_note**: Safe deletion with confirmation requirement
+- **Path security**: Enhanced path validation and traversal protection
+- **Error handling**: Improved error messages and validation
+
+### Changed
+- **Project name**: Renamed from mcp-fs-obsidian to mcpvault
+- **Bun optimization**: Pure Bun implementation with native APIs
+- **Documentation**: Significantly improved README with examples
+
+### Fixed
+- **Command usage**: Fixed README to use bunx for end users
+- **File filtering**: Added .tmp files to gitignore
+
+## [0.1.0] - 2025-09-21
+
+### Added
+- **Initial release**: Basic MCP server for Obsidian vault access
+- **Core tools**: read_note, write_note, list_directory, search_notes
+- **Security**: Path filtering and vault boundary protection
+- **Frontmatter**: YAML frontmatter parsing and validation
+- **MCP protocol**: Model Context Protocol server implementation
+
+### Features
+- Obsidian vault integration
+- Safe file operations
+- Frontmatter handling
+- Directory listing
+- Content search
+
+---
+
+## Migration Notes
+
+### From Bun to Node.js (v0.5.0)
+If you were using the Bun version, update your configuration:
+
+**Old (Bun):**
+```json
+{
+ "command": "bunx",
+ "args": ["mcpvault", "/path/to/vault"]
+}
+```
+
+**New (Node.js):**
+```json
+{
+ "command": "npx",
+ "args": ["@bitbonsai/mcpvault@latest", "/path/to/vault"]
+}
+```
+
+### Package Name Change (v0.3.0)
+The package was renamed and moved to a scoped package for better npm distribution.
+
+## Security Updates
+
+All versions include security measures:
+- Path traversal protection
+- File type filtering
+- YAML validation
+- Vault boundary enforcement
+
+## Support
+
+- **Node.js**: v18.0.0 or later required
+- **MCP Clients**: Claude Desktop, Claude Code, ChatGPT Desktop, IntelliJ IDEA 2025.1+
+- **File Types**: .md, .markdown, .txt, .base, .canvas files supported
diff --git a/README.md b/README.md
index ce7da65..c385e63 100644
--- a/README.md
+++ b/README.md
@@ -1,50 +1,100 @@
-# MCP-Obsidian
+
+
+
-A lightweight Model Context Protocol (MCP) server for safe Obsidian vault access. This server provides AI assistants with the ability to read and write notes in an Obsidian vault while preventing YAML frontmatter corruption.
+# MCPVault
-**Supported AI Platforms:** Claude Desktop, Claude Code, ChatGPT Desktop (Enterprise+), IntelliJ IDEA 2025.1+, Cursor IDE, and other MCP-compatible clients.
+A universal AI bridge for Obsidian vaults using the Model Context Protocol (MCP) standard. Connect any MCP-compatible AI assistant to your knowledge base - works with Claude, ChatGPT, and future AI tools. This server provides safe read/write access to your notes while preventing YAML frontmatter corruption.
+
+
+
+[https://mcpvault.org](https://mcpvault.org)
+
+[Changelog](./CHANGELOG.md)
+
+
+
+
+
+[](https://github.com/bitbonsai/mcpvault)
+[](https://www.npmjs.com/package/@bitbonsai/mcpvault)
+[](https://www.npmjs.com/package/@bitbonsai/mcpvault)
+[](https://github.com/sponsors/bitbonsai)
+[](https://ko-fi.com/bitbonsai)
+[](https://liberapay.com/bitbonsai/)
+
+
+
+## Universal Compatibility
+
+Works with any MCP-compatible AI assistant including Claude Desktop, Claude Code, ChatGPT Desktop (Enterprise+), OpenCode, Gemini CLI, OpenAI Codex, IntelliJ IDEA 2025.1+, Cursor IDE, Windsurf IDE, and future AI platforms that adopt the MCP standard.
+
+https://github.com/user-attachments/assets/657ac4c6-1cd2-4cc3-829f-fd095a32f71c
## Quick Start (5 minutes)
-1. **Install Bun runtime:**
+1. **Install Node.js runtime:**
+
```bash
- curl -fsSL https://bun.sh/install | bash
+ # Download from https://nodejs.org (v18.0.0 or later)
+ # or use a package manager like nvm, brew, apt, etc.
```
2. **Test the server:**
If using the published package:
+
```bash
- bunx @modelcontextprotocol/inspector bunx @mauricio.wolff/mcp-obsidian /path/to/your/vault
+ npx @modelcontextprotocol/inspector npx @bitbonsai/mcpvault@latest /path/to/your/vault
```
3. **Configure your AI client:**
**Claude Desktop** - Copy this to `claude_desktop_config.json`:
+
```json
{
"mcpServers": {
"obsidian": {
- "command": "bunx",
- "args": ["@mauricio.wolff/mcp-obsidian", "/path/to/your/vault"]
+ "command": "npx",
+ "args": ["@bitbonsai/mcpvault@latest", "/path/to/your/vault"]
}
}
}
```
**Claude Code** - Copy this to `~/.claude.json`:
+
```json
{
"mcpServers": {
"obsidian": {
- "command": "bunx",
- "args": ["@mauricio.wolff/mcp-obsidian", "/path/to/your/vault"],
+ "command": "npx",
+ "args": ["@bitbonsai/mcpvault@latest", "/path/to/your/vault"],
"env": {}
}
}
}
```
+ **OpenCode** - Copy this to `~/.config/opencode/opencode.json`
+
+ ```json
+ {
+ "mcp": {
+ "obsidian": {
+ "type": "local",
+ "command": [
+ "npx",
+ "@bitbonsai/mcpvault@latest",
+ "/path/to/your/vault/"
+ ],
+ "enabled": true
+ }
+ }
+ }
+ ```
+
Replace `/path/to/your/vault` with your actual Obsidian vault path.
For other platforms, see [detailed configuration guides](#ai-client-configuration) below.
@@ -56,51 +106,90 @@ A lightweight Model Context Protocol (MCP) server for safe Obsidian vault access
**Success indicators:** Your AI should be able to list files and read notes from your vault.
+## Why MCPVault?
+
+### Universal AI Compatibility
+
+Built on the open Model Context Protocol standard, MCPVault is not locked to any single AI provider. As more AI assistants adopt MCP, your investment in this tool grows more valuable. Today it works with Claude and ChatGPT - tomorrow it will work with whatever AI tools emerge.
+
+### Future-Proof Your Knowledge Base
+
+Instead of waiting for each AI company to build Obsidian integrations, MCPVault provides a universal adapter that works with any MCP-compatible assistant. One tool, endless possibilities.
+
+### Open Standard, No Lock-in
+
+MCP is an open protocol. You're not tied to any specific vendor or platform. Your notes remain yours, accessible through any compatible AI assistant.
+
## Features
-- ✅ Safe frontmatter parsing and validation using gray-matter
+- ✅ Safe frontmatter parsing and validation using gray-matter with AST-aware updates that preserve raw formatting for unmodified fields
- ✅ Path filtering to exclude `.obsidian` directory and other system files
-- ✅ **Complete MCP toolkit**: 11 methods covering all vault operations
- - File operations: `read_note`, `write_note`, `delete_note`, `move_note`
+- ✅ **Configurable exclusions**: Use `--exclude ` to block paths or glob patterns from all tools (repeatable flag)
+- ✅ **Complete MCP toolkit**: 14 methods covering all vault operations
+ - File operations: `read_note`, `write_note`, `patch_note`, `delete_note`, `move_note`, `move_file`
- Directory operations: `list_directory`
- Batch operations: `read_multiple_notes`
- - Search: `search_notes` with content and frontmatter support
- - Metadata: `get_frontmatter`, `update_frontmatter`, `get_notes_info`
+ - Search: `search_notes` with multi-word matching and BM25 relevance reranking
+ - Metadata: `get_frontmatter`, `update_frontmatter`, `get_notes_info`, `get_vault_stats`
- Tag management: `manage_tags` (add, remove, list)
-- ✅ **NEW:** Write modes: `overwrite`, `append`, `prepend` for flexible content editing
-- ✅ **NEW:** Tag management: add, remove, and list tags in notes
+- ✅ Write modes: `overwrite`, `append`, `prepend` for flexible content editing
+- ✅ Tag management: add, remove, and list tags in notes
- ✅ Safe deletion with confirmation requirement to prevent accidents
- ✅ Automatic path trimming to handle whitespace in inputs
-- ✅ TypeScript support with Bun runtime (no compilation needed)
+- ✅ TypeScript support with Node.js runtime (using tsx for execution)
- ✅ Comprehensive error handling and validation
+- ✅ **Token-optimized responses**: 40-60% smaller responses with minified field names and compact JSON (v0.6.3+)
+- ✅ **Optional pretty-printing**: Set `prettyPrint: true` for human-readable debugging
+- ✅ **Performance optimized**: No unnecessary token consumption, efficient for large vaults
+- ✅ **Zero dependencies**: No Obsidian plugins required, works with any vault structure
## Prerequisites
-- [Bun](https://bun.sh) runtime (v1.0.0 or later)
-- An Obsidian vault (local directory with `.md` files)
+- [Node.js](https://nodejs.org) runtime (v18.0.0 or later)
+- An Obsidian vault (local directory with `.md`, `.markdown`, `.txt`, `.base`, or `.canvas` files)
- MCP-compatible AI client (Claude Desktop, ChatGPT Desktop, Claude Code, etc.)
## Installation
### For End Users (Recommended)
-No installation needed! Use `bunx` to run directly:
+No installation needed! Use `npx` to run directly:
```bash
-bunx @mauricio.wolff/mcp-obsidian /path/to/your/obsidian/vault
+npx @bitbonsai/mcpvault@latest /path/to/your/obsidian/vault
```
+If you omit the vault path, the server uses your current working directory as the vault root.
+
### For Developers
1. Clone this repository
-2. Install dependencies with Bun:
+2. Use the correct Node.js version:
+
+```bash
+nvm use # Uses Node 24 from .nvmrc
+```
+
+3. Install dependencies with npm:
+
```bash
-bun install
+npm install # Corepack automatically uses npm 10.9.0
```
-3. Test locally with MCP inspector:
+4. Test locally with MCP inspector:
+
```bash
-bunx @modelcontextprotocol/inspector bun server.ts /path/to/your/vault
+npx @modelcontextprotocol/inspector npm start /path/to/your/vault
+```
+
+**Pro tip:** Use MCP Inspector to test all server functionality before configuring with AI clients:
+
+```bash
+# Install globally for easier access
+npm install -g @modelcontextprotocol/inspector
+
+# Test with any vault
+mcp-inspector npx @bitbonsai/mcpvault@latest /path/to/your/vault
```
## Usage
@@ -108,13 +197,19 @@ bunx @modelcontextprotocol/inspector bun server.ts /path/to/your/vault
### Running the Server
**End users:**
+
```bash
-bunx @mauricio.wolff/mcp-obsidian /path/to/your/obsidian/vault
+npx @bitbonsai/mcpvault@latest
+npx @bitbonsai/mcpvault@latest /path/to/your/obsidian/vault
+npx @bitbonsai/mcpvault@latest ./Vault
```
**Developers:**
+
```bash
-bun server.ts /path/to/your/obsidian/vault
+npm start
+npm start /path/to/your/obsidian/vault
+npm start ./Vault
```
### AI Client Configuration
@@ -124,39 +219,51 @@ bun server.ts /path/to/your/obsidian/vault
Add to your Claude Desktop configuration file:
**Single Vault:**
+
```json
{
"mcpServers": {
"obsidian": {
- "command": "bunx",
- "args": ["@mauricio.wolff/mcp-obsidian", "/Users/yourname/Documents/MyVault"]
+ "command": "npx",
+ "args": [
+ "@bitbonsai/mcpvault@latest",
+ "/Users/yourname/Documents/MyVault"
+ ]
}
}
}
```
**Multiple Vaults:**
+
```json
{
"mcpServers": {
"obsidian-personal": {
- "command": "bunx",
- "args": ["@mauricio.wolff/mcp-obsidian", "/Users/yourname/Documents/PersonalVault"]
+ "command": "npx",
+ "args": [
+ "@bitbonsai/mcpvault@latest",
+ "/Users/yourname/Documents/PersonalVault"
+ ]
},
"obsidian-work": {
- "command": "bunx",
- "args": ["@mauricio.wolff/mcp-obsidian", "/Users/yourname/Documents/WorkVault"]
+ "command": "npx",
+ "args": [
+ "@bitbonsai/mcpvault@latest",
+ "/Users/yourname/Documents/WorkVault"
+ ]
}
}
}
```
**Configuration File Locations:**
+
- **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
- **Windows:** `C:\Users\{username}\AppData\Roaming\Claude\claude_desktop_config.json`
- **Linux:** `~/.config/Claude/claude_desktop_config.json`
-*You can also access this through Claude Desktop → Settings → Developer → Edit Config*
+_You can also access this through Claude Desktop → Settings → Developer → Edit Config_
#### ChatGPT Desktop
@@ -168,7 +275,7 @@ ChatGPT uses MCP through Deep Research and developer mode. Configuration is done
2. Configure MCP servers through the built-in MCP client
3. Create custom connectors for your organization
-*Note: ChatGPT Desktop's MCP integration is currently limited to enterprise subscriptions and uses a different setup process than file-based configuration.*
+_Note: ChatGPT Desktop's MCP integration is currently limited to enterprise subscriptions and uses a different setup process than file-based configuration._
#### Claude Code
@@ -176,12 +283,13 @@ Claude Code uses `.claude.json` configuration file:
**User-scoped (recommended):**
Edit `~/.claude.json`:
+
```json
{
"mcpServers": {
"obsidian": {
- "command": "bunx",
- "args": ["@mauricio.wolff/mcp-obsidian", "/path/to/your/vault"],
+ "command": "npx",
+ "args": ["@bitbonsai/mcpvault@latest", "/path/to/your/vault"],
"env": {}
}
}
@@ -190,14 +298,15 @@ Edit `~/.claude.json`:
**Project-scoped:**
Edit `.claude.json` in your project or add to the projects section:
+
```json
{
"projects": {
"/path/to/your/project": {
"mcpServers": {
"obsidian": {
- "command": "bunx",
- "args": ["@mauricio.wolff/mcp-obsidian", "/path/to/your/vault"]
+ "command": "npx",
+ "args": ["@bitbonsai/mcpvault@latest", "/path/to/your/vault"]
}
}
}
@@ -206,15 +315,26 @@ Edit `.claude.json` in your project or add to the projects section:
```
**Using Claude Code CLI:**
+
+```bash
+claude mcp add obsidian --scope user npx @bitbonsai/mcpvault /path/to/your/vault
+```
+
+#### Goose Desktop
+
+On Goose Desktop settings, click **Add custom extension**, and on the command field add:
+
```bash
-claude mcp add obsidian --scope user bunx @mauricio.wolff/mcp-obsidian /path/to/your/vault
+npx @bitbonsai/mcpvault@latest /path/to/your/vault
```
#### Other MCP-Compatible Clients (2025)
**Confirmed MCP Support:**
+
- **IntelliJ IDEA 2025.1+** - Native MCP client support
- **Cursor IDE** - Built-in MCP compatibility
+- **Windsurf IDE** - Full MCP integration
- **Zed, Replit, Codeium, Sourcegraph** - In development
- **Microsoft Copilot Studio** - Native MCP support with one-click server connections
@@ -223,11 +343,13 @@ Most modern MCP clients use similar JSON configuration patterns. Refer to your s
### Examples
#### Ask your AI assistant about your notes:
+
- "What files are in my Obsidian vault?"
- "Read my note called 'project-ideas.md'"
- "Show me all notes with 'AI' in the title"
#### Have your AI assistant help with note management:
+
- "Create a new note called 'meeting-notes.md' with today's date in the frontmatter"
- "Append today's journal entry to my daily note"
- "Prepend an urgent task to my todo list"
@@ -237,27 +359,39 @@ Most modern MCP clients use similar JSON configuration patterns. Refer to your s
- "List all markdown files in my 'Projects' folder"
- "Delete the old draft note 'draft-ideas.md' (with confirmation)"
+#### Advanced Use Cases:
+
+- **Knowledge Synthesis**: "Summarize all my research notes tagged with 'machine-learning' from the last month"
+- **Project Management**: "Update the status in all project notes to 'completed' and add today's date"
+- **Content Analysis**: "Find all notes that mention 'API design' and create a comprehensive guide"
+- **Smart Tagging**: "Review my untagged notes and suggest appropriate tags based on content"
+
## Troubleshooting
### Common Issues
-#### "command not found: bunx"
-- **Solution:** Install Bun runtime from [bun.sh](https://bun.sh)
-- **Alternative:** Use npm: `npx @mauricio.wolff/mcp-obsidian /path/to/vault`
+#### "command not found: npx"
+
+- **Solution:** Install Node.js runtime from [nodejs.org](https://nodejs.org)
+- **Alternative:** Use global install: `npm install -g @bitbonsai/mcpvault`
+
+#### "File not found" when paths look correct
-#### "Usage: bun server.ts /path/to/vault"
-- **Cause:** No vault path provided
-- **Solution:** Specify the full path to your Obsidian vault directory
+- **Cause:** The server is using the wrong vault root
+- **Solution:** Either run the command from your vault directory or pass the vault path explicitly
#### "Permission denied" errors
+
- **Cause:** Insufficient file system permissions
- **Solution:** Ensure the vault directory is readable/writable by your user
#### "Path traversal not allowed"
+
- **Cause:** Trying to access files outside the vault
- **Solution:** All file paths must be relative to the vault root
#### AI client not recognizing the server
+
1. Check the configuration file path is correct for your OS
2. Ensure JSON syntax is valid (use a JSON validator)
3. Restart your AI client after configuration changes
@@ -265,49 +399,68 @@ Most modern MCP clients use similar JSON configuration patterns. Refer to your s
5. Verify your AI client supports MCP (Model Context Protocol)
#### ".obsidian files still showing up"
+
- **Expected:** The path filter automatically excludes `.obsidian/**` patterns
- **If still seeing them:** The filter is working as designed for security
### Debug Mode
Run with error logging:
+
```bash
-bunx @mauricio.wolff/mcp-obsidian /path/to/vault 2>debug.log
+npx @bitbonsai/mcpvault /path/to/vault 2>debug.log
```
### Getting Help
-- [Open an issue](https://github.com/bitbonsai/mcp-obsidian/issues) on GitHub
-- Include your OS, Bun version, and error messages
+- [Open an issue](https://github.com/bitbonsai/mcpvault/issues) on GitHub
+- Include your OS, Node.js version, and error messages
- Provide the vault directory structure (without sensitive content)
## Testing
Run the test suite:
+
```bash
-bun test
+npm test
```
## API Methods
### `read_note`
+
Read a note from the vault with parsed frontmatter.
**Request:**
+
```json
{
"name": "read_note",
"arguments": {
- "path": "project-ideas.md"
+ "path": "project-ideas.md",
+ "prettyPrint": false
}
}
```
-**Response:**
+**Response (optimized for tokens):**
+
+```json
+{
+ "fm": {
+ "title": "Project Ideas",
+ "tags": ["projects", "brainstorming"],
+ "created": "2023-01-15T10:30:00.000Z"
+ },
+ "content": "# Project Ideas\n\n## AI Tools\n- MCP server for Obsidian\n- Voice note transcription\n\n## Web Apps\n- Task management system"
+}
+```
+
+**Response (with prettyPrint: true):**
+
```json
{
- "path": "project-ideas.md",
- "frontmatter": {
+ "fm": {
"title": "Project Ideas",
"tags": ["projects", "brainstorming"],
"created": "2023-01-15T10:30:00.000Z"
@@ -317,14 +470,17 @@ Read a note from the vault with parsed frontmatter.
```
### `write_note`
+
Write a note to the vault with optional frontmatter and write mode.
**Write Modes:**
+
- `overwrite` (default): Replace entire file content
- `append`: Add content to the end of existing file
- `prepend`: Add content to the beginning of existing file
**Request (Overwrite):**
+
```json
{
"name": "write_note",
@@ -342,6 +498,7 @@ Write a note to the vault with optional frontmatter and write mode.
```
**Request (Append):**
+
```json
{
"name": "write_note",
@@ -354,64 +511,114 @@ Write a note to the vault with optional frontmatter and write mode.
```
**Response:**
+
```json
{
"message": "Successfully wrote note: meeting-notes.md (mode: overwrite)"
}
```
+### `patch_note`
+
+Efficiently replace an exact string inside an existing note without rewriting the full file.
+
+**Request:**
+
+```json
+{
+ "name": "patch_note",
+ "arguments": {
+ "path": "meeting-notes.md",
+ "oldString": "- Next milestones",
+ "newString": "- Next milestones (owner: Alex)",
+ "replaceAll": false
+ }
+}
+```
+
+**Response (success):**
+
+```json
+{
+ "success": true,
+ "path": "meeting-notes.md",
+ "message": "Successfully replaced 1 occurrence",
+ "matchCount": 1
+}
+```
+
+**Response (multiple matches with replaceAll=false):**
+
+```json
+{
+ "success": false,
+ "path": "meeting-notes.md",
+ "message": "Found 3 occurrences of the string. Use replaceAll=true to replace all occurrences, or provide a more specific string to match exactly one occurrence.",
+ "matchCount": 3
+}
+```
+
### `list_directory`
+
List files and directories in the vault.
+Note: this includes non-note filenames (for example `pdf`, `png`, `jpg`) so AI assistants can see vault structure, but note tools like `read_note` and `write_note` still operate on note files only (`.md`, `.markdown`, `.txt`, `.base`, `.canvas`).
+
**Request:**
+
```json
{
"name": "list_directory",
"arguments": {
- "path": "Projects"
+ "path": "Projects",
+ "prettyPrint": false
}
}
```
-**Response:**
+**Response (optimized):**
+
```json
{
- "path": "Projects",
- "directories": [
- "AI-Tools",
- "Web-Development"
- ],
- "files": [
- "project-template.md",
- "roadmap.md"
- ]
+ "dirs": ["AI-Tools", "Web-Development"],
+ "files": ["project-template.md", "roadmap.md"]
}
```
### `delete_note`
+
Delete a note from the vault (requires confirmation for safety).
**Request:**
+
```json
{
"name": "delete_note",
"arguments": {
"path": "old-draft.md",
- "confirmPath": "old-draft.md"
+ "confirmPath": "old-draft.md",
+ "trashMode": "local"
}
}
```
**Response (Success):**
+
```json
{
"success": true,
"path": "old-draft.md",
- "message": "Successfully deleted note: old-draft.md. This action cannot be undone."
+ "message": "Successfully moved note to vault trash: old-draft.md"
}
```
+**Trash modes:**
+- `none` (default): permanent delete
+- `local`: move to `.trash` inside the vault, preserving folder structure
+- `system`: move to the OS trash/recycle bin
+
**Response (Confirmation Failed):**
+
```json
{
"success": false,
@@ -423,34 +630,37 @@ Delete a note from the vault (requires confirmation for safety).
**⚠️ Safety Note:** The `confirmPath` parameter must exactly match the `path` parameter to proceed with deletion. This prevents accidental deletions.
### `get_frontmatter`
+
Extract only the frontmatter from a note without reading the full content.
**Request:**
+
```json
{
"name": "get_frontmatter",
"arguments": {
- "path": "project-ideas.md"
+ "path": "project-ideas.md",
+ "prettyPrint": false
}
}
```
-**Response:**
+**Response (optimized, returns frontmatter directly):**
+
```json
{
- "path": "project-ideas.md",
- "frontmatter": {
- "title": "Project Ideas",
- "tags": ["projects", "brainstorming"],
- "created": "2023-01-15T10:30:00.000Z"
- }
+ "title": "Project Ideas",
+ "tags": ["projects", "brainstorming"],
+ "created": "2023-01-15T10:30:00.000Z"
}
```
### `manage_tags`
+
Add, remove, or list tags in a note. Tags are managed in the frontmatter and inline tags are detected.
**Request (List Tags):**
+
```json
{
"name": "manage_tags",
@@ -462,6 +672,7 @@ Add, remove, or list tags in a note. Tags are managed in the frontmatter and inl
```
**Request (Add Tags):**
+
```json
{
"name": "manage_tags",
@@ -474,6 +685,7 @@ Add, remove, or list tags in a note. Tags are managed in the frontmatter and inl
```
**Request (Remove Tags):**
+
```json
{
"name": "manage_tags",
@@ -486,6 +698,7 @@ Add, remove, or list tags in a note. Tags are managed in the frontmatter and inl
```
**Response:**
+
```json
{
"path": "research-notes.md",
@@ -497,9 +710,11 @@ Add, remove, or list tags in a note. Tags are managed in the frontmatter and inl
```
### `search_notes`
-Search for notes in the vault by content or frontmatter.
+
+Search for notes in the vault by content or frontmatter with multi-word matching and BM25 relevance reranking.
**Request:**
+
```json
{
"name": "search_notes",
@@ -508,32 +723,42 @@ Search for notes in the vault by content or frontmatter.
"limit": 5,
"searchContent": true,
"searchFrontmatter": false,
- "caseSensitive": false
+ "caseSensitive": false,
+ "prettyPrint": false
}
}
```
-**Response:**
+**Response (optimized with minified field names):**
+
```json
-{
- "query": "machine learning",
- "resultCount": 3,
- "results": [
- {
- "path": "ai-research.md",
- "title": "AI Research Notes",
- "excerpt": "...machine learning algorithms are...",
- "matchCount": 2,
- "lineNumber": 15
- }
- ]
-}
+[
+ {
+ "p": "ai-research.md",
+ "t": "AI Research Notes",
+ "ex": "...machine learning...",
+ "mc": 2,
+ "ln": 15,
+ "uri": "obsidian://open?vault=MyVault&file=ai-research.md"
+ }
+]
```
+**Field names:**
+
+- `p` = path
+- `t` = title
+- `ex` = excerpt (21 chars context)
+- `mc` = match count
+- `ln` = line number
+- `uri` = Obsidian deep link for quick opening
+
### `move_note`
-Move or rename a note in the vault.
+
+Move or rename a note in the vault (`.md`, `.markdown`, `.txt`, `.base`, `.canvas`).
**Request:**
+
```json
{
"name": "move_note",
@@ -546,6 +771,7 @@ Move or rename a note in the vault.
```
**Response:**
+
```json
{
"success": true,
@@ -555,48 +781,82 @@ Move or rename a note in the vault.
}
```
+### `move_file`
+
+Move or rename any file in the vault with binary-safe file operations (file-only; not recursive directory moves). For safety, this tool requires confirmation of both source and destination paths.
+
+**Request:**
+
+```json
+{
+ "name": "move_file",
+ "arguments": {
+ "oldPath": "Miro/attachments/Pasted image 20250812140124.png",
+ "newPath": "assets/images/Pasted image 20250812140124.png",
+ "confirmOldPath": "Miro/attachments/Pasted image 20250812140124.png",
+ "confirmNewPath": "assets/images/Pasted image 20250812140124.png",
+ "overwrite": false
+ }
+}
+```
+
+**Response:**
+
+```json
+{
+ "success": true,
+ "oldPath": "Miro/attachments/Pasted image 20250812140124.png",
+ "newPath": "assets/images/Pasted image 20250812140124.png",
+ "message": "Successfully moved file from Miro/attachments/Pasted image 20250812140124.png to assets/images/Pasted image 20250812140124.png"
+}
+```
+
+**Safety Note:** `confirmOldPath` must exactly match `oldPath`, and `confirmNewPath` must exactly match `newPath`, otherwise the move is rejected.
+
### `read_multiple_notes`
+
Read multiple notes in a batch (maximum 10 files).
**Request:**
+
```json
{
"name": "read_multiple_notes",
"arguments": {
"paths": ["note1.md", "note2.md", "note3.md"],
"includeContent": true,
- "includeFrontmatter": true
+ "includeFrontmatter": true,
+ "prettyPrint": false
}
}
```
-**Response:**
+**Response (optimized, shortened field names):**
+
```json
{
- "successful": [
+ "ok": [
{
"path": "note1.md",
- "frontmatter": {"title": "Note 1"},
+ "frontmatter": { "title": "Note 1" },
"content": "# Note 1\n\nContent here..."
}
],
- "failed": [
- {
- "path": "note2.md",
- "error": "File not found"
- }
- ],
- "summary": {
- "successCount": 1,
- "failureCount": 1
- }
+ "err": [{ "path": "note2.md", "error": "File not found" }]
}
```
+**Field names:**
+
+- `ok` = successful reads
+- `err` = failed reads
+
### `update_frontmatter`
+
Update frontmatter of a note without changing content.
**Request:**
+
```json
{
"name": "update_frontmatter",
@@ -612,6 +872,7 @@ Update frontmatter of a note without changing content.
```
**Response:**
+
```json
{
"message": "Successfully updated frontmatter for: research-note.md"
@@ -619,30 +880,64 @@ Update frontmatter of a note without changing content.
```
### `get_notes_info`
+
Get metadata for notes without reading full content.
**Request:**
+
```json
{
"name": "get_notes_info",
"arguments": {
- "paths": ["note1.md", "note2.md"]
+ "paths": ["note1.md", "note2.md"],
+ "prettyPrint": false
}
}
```
-**Response:**
+**Response (optimized, returns array directly):**
+
+```json
+[
+ {
+ "path": "note1.md",
+ "size": 1024,
+ "modified": 1695456000000,
+ "hasFrontmatter": true
+ }
+]
+```
+
+### `get_vault_stats`
+
+Get high-level vault statistics without reading note contents.
+
+**Request:**
+
+```json
+{
+ "name": "get_vault_stats",
+ "arguments": {
+ "recentCount": 5,
+ "prettyPrint": false
+ }
+}
+```
+
+**Response (optimized):**
+
```json
{
- "notes": [
+ "notes": 1248,
+ "folders": 76,
+ "size": 18349210,
+ "recent": [
{
- "path": "note1.md",
- "size": 1024,
- "modified": 1695456000000,
- "hasFrontmatter": true
+ "path": "Daily/2026-02-27.md",
+ "modified": 1772188800000,
+ "size": 2814
}
- ],
- "count": 1
+ ]
}
```
@@ -651,27 +946,32 @@ Get metadata for notes without reading full content.
This MCP server implements several security measures to protect your Obsidian vault:
### Path Security
+
- **Path Traversal Protection:** All file paths are validated to prevent access outside the vault
- **Relative Path Enforcement:** Paths are normalized and restricted to the vault directory
- **Symbolic Link Safety:** Resolved paths are checked against vault boundaries
### File Filtering
+
- **Automatic Exclusions:** `.obsidian`, `.git`, `node_modules`, and system files are filtered
-- **Extension Whitelist:** Only `.md`, `.markdown`, and `.txt` files are accessible by default
+- **Extension Whitelist:** Only `.md`, `.markdown`, `.txt`, `.base`, and `.canvas` files are accessible by default
- **Hidden File Protection:** Dot files and system directories are automatically excluded
### Content Validation
+
- **YAML Frontmatter Validation:** Frontmatter is parsed and validated before writing
- **Function/Symbol Prevention:** Dangerous JavaScript objects are blocked from frontmatter
- **Data Type Checking:** Only safe data types (strings, numbers, arrays, objects) allowed
### Best Practices
+
- **Least Privilege:** Server only accesses the specified vault directory
- **Read-Only by Default:** Consider running with read-only permissions for sensitive vaults
- **Backup Recommended:** Always backup your vault before using write operations
- **Network Isolation:** Server uses stdio transport (no network exposure)
### What's NOT Protected
+
- **File Content:** The server can read/write any allowed file content
- **Vault Structure:** Directory structure is visible to AI assistants
- **File Metadata:** Creation times, file sizes, etc. are accessible
@@ -684,6 +984,8 @@ This MCP server implements several security measures to protect your Obsidian va
- `src/frontmatter.ts` - YAML frontmatter handling with gray-matter
- `src/filesystem.ts` - Safe file operations with path validation
- `src/pathfilter.ts` - Directory and file filtering
+- `src/search.ts` - Note search functionality with content and frontmatter support
+- `src/uri.ts` - Obsidian URI generation for deep links
- `src/types.ts` - TypeScript type definitions
## Contributing
@@ -691,9 +993,9 @@ This MCP server implements several security measures to protect your Obsidian va
1. Fork the repository
2. Create a feature branch: `git checkout -b feature-name`
3. Make your changes and add tests
-4. Ensure all tests pass: `bun test`
+4. Ensure all tests pass: `npm test`
5. Submit a pull request
## License
-MIT
\ No newline at end of file
+MIT
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..e32e59e
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,72 @@
+# Security Policy
+
+## Supported Versions
+
+| Version | Supported |
+| ------- | ------------------ |
+| 0.7.x | :white_check_mark: |
+| < 0.7 | :x: |
+
+## Reporting a Vulnerability
+
+**Please do not report security vulnerabilities through public GitHub issues.**
+
+Instead, report them via GitHub's private vulnerability reporting:
+
+1. Go to the [Security tab](https://github.com/bitbonsai/mcpvault/security)
+2. Click "Report a vulnerability"
+3. Provide a detailed description
+
+### What to include
+
+- Type of vulnerability (path traversal, injection, data leak, etc.)
+- Step-by-step reproduction instructions
+- Affected versions
+- Potential impact
+- Suggested fix (if any)
+
+### Response timeline
+
+- **Initial response**: within 72 hours
+- **Status update**: within 7 days
+- **Fix timeline**: depends on severity, typically 30 days for critical issues
+
+## Security Scope
+
+### In scope
+
+Given that this MCP server accesses personal data in Obsidian vaults, we consider the following as security vulnerabilities:
+
+- **Path traversal**: accessing files outside the vault directory
+- **Arbitrary file access**: reading/writing to system files, dotfiles, or `.obsidian/` configuration
+- **Command injection**: executing arbitrary commands via tool parameters
+- **Data exfiltration**: unintended data exposure to unauthorized parties
+- **Frontmatter corruption**: malicious YAML that could exploit parsers
+- **Denial of service**: crashes or resource exhaustion via malformed input
+- **Supply chain**: compromised dependencies or build process
+
+### Out of scope
+
+- Vulnerabilities in the MCP protocol itself (report to [Anthropic](https://github.com/anthropics/modelcontextprotocol))
+- Vulnerabilities in Obsidian (report to [Obsidian](https://obsidian.md/security))
+- Issues requiring physical access to the machine
+- Social engineering attacks
+- Vulnerabilities in dependencies with no realistic exploit path in this context
+
+## Security Measures
+
+This project implements several security controls:
+
+- **Path filtering**: blocks access to `.obsidian/`, `.git/`, `node_modules/`, and system files
+- **Path traversal prevention**: validates all paths stay within vault boundaries
+- **Frontmatter validation**: blocks functions, symbols, and potentially dangerous YAML constructs
+- **Confirmation for destructive ops**: delete operations require explicit path confirmation
+- **Dependency security**: automated updates via Dependabot, npm audit in CI
+- **Static analysis**: CodeQL scans on every PR and weekly
+- **Provenance**: npm packages published with SLSA provenance attestation
+
+## Acknowledgments
+
+We thank the following researchers for responsibly disclosing vulnerabilities:
+
+*No vulnerabilities reported yet.*
diff --git a/bun.lock b/bun.lock
index 2645033..4c9b7a0 100644
--- a/bun.lock
+++ b/bun.lock
@@ -2,41 +2,168 @@
"lockfileVersion": 1,
"workspaces": {
"": {
- "name": "mcp-fs-obsidian",
+ "name": "@mauricio.wolff/mcp-obsidian",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"gray-matter": "^4.0.3",
},
"devDependencies": {
- "@types/bun": "latest",
+ "@types/node": "^20.0.0",
+ "tsx": "^4.0.0",
+ "typescript": "^5.0.0",
+ "vitest": "^1.0.0",
},
},
},
"packages": {
- "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.18.1", "", { "dependencies": { "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-d//GE8/Yh7aC3e7p+kZG8JqqEAwwDUmAfvH1quogtbk+ksS6E0RR6toKKESPYYZVre0meqkJb27zb+dhqE9Sgw=="],
+ "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.10", "", { "os": "aix", "cpu": "ppc64" }, "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw=="],
- "@types/bun": ["@types/bun@1.2.22", "", { "dependencies": { "bun-types": "1.2.22" } }, "sha512-5A/KrKos2ZcN0c6ljRSOa1fYIyCKhZfIVYeuyb4snnvomnpFqC0tTsEkdqNxbAgExV384OETQ//WAjl3XbYqQA=="],
+ "@esbuild/android-arm": ["@esbuild/android-arm@0.25.10", "", { "os": "android", "cpu": "arm" }, "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w=="],
- "@types/node": ["@types/node@24.5.2", "", { "dependencies": { "undici-types": "~7.12.0" } }, "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ=="],
+ "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.10", "", { "os": "android", "cpu": "arm64" }, "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg=="],
- "@types/react": ["@types/react@19.1.13", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ=="],
+ "@esbuild/android-x64": ["@esbuild/android-x64@0.25.10", "", { "os": "android", "cpu": "x64" }, "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg=="],
+
+ "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA=="],
+
+ "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg=="],
+
+ "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.10", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg=="],
+
+ "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA=="],
+
+ "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.10", "", { "os": "linux", "cpu": "arm" }, "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg=="],
+
+ "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ=="],
+
+ "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.10", "", { "os": "linux", "cpu": "ia32" }, "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ=="],
+
+ "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg=="],
+
+ "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA=="],
+
+ "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.10", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA=="],
+
+ "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA=="],
+
+ "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.10", "", { "os": "linux", "cpu": "s390x" }, "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew=="],
+
+ "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.10", "", { "os": "linux", "cpu": "x64" }, "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA=="],
+
+ "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.10", "", { "os": "none", "cpu": "arm64" }, "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A=="],
+
+ "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.10", "", { "os": "none", "cpu": "x64" }, "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig=="],
+
+ "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.10", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw=="],
+
+ "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.10", "", { "os": "openbsd", "cpu": "x64" }, "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw=="],
+
+ "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.10", "", { "os": "none", "cpu": "arm64" }, "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag=="],
+
+ "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.10", "", { "os": "sunos", "cpu": "x64" }, "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ=="],
+
+ "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw=="],
+
+ "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.10", "", { "os": "win32", "cpu": "ia32" }, "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw=="],
+
+ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.10", "", { "os": "win32", "cpu": "x64" }, "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw=="],
+
+ "@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="],
+
+ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
+
+ "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.20.0", "", { "dependencies": { "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-kOQ4+fHuT4KbR2iq2IjeV32HiihueuOf1vJkq18z08CLZ1UQrTc8BXJpVfxZkq45+inLLD+D4xx4nBjUelJa4Q=="],
+
+ "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.2", "", { "os": "android", "cpu": "arm" }, "sha512-o3pcKzJgSGt4d74lSZ+OCnHwkKBeAbFDmbEm5gg70eA8VkyCuC/zV9TwBnmw6VjDlRdF4Pshfb+WE9E6XY1PoQ=="],
+
+ "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.52.2", "", { "os": "android", "cpu": "arm64" }, "sha512-cqFSWO5tX2vhC9hJTK8WAiPIm4Q8q/cU8j2HQA0L3E1uXvBYbOZMhE2oFL8n2pKB5sOCHY6bBuHaRwG7TkfJyw=="],
+
+ "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.52.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vngduywkkv8Fkh3wIZf5nFPXzWsNsVu1kvtLETWxTFf/5opZmflgVSeLgdHR56RQh71xhPhWoOkEBvbehwTlVA=="],
+
+ "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.52.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-h11KikYrUCYTrDj6h939hhMNlqU2fo/X4NB0OZcys3fya49o1hmFaczAiJWVAFgrM1NCP6RrO7lQKeVYSKBPSQ=="],
+
+ "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.52.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-/eg4CI61ZUkLXxMHyVlmlGrSQZ34xqWlZNW43IAU4RmdzWEx0mQJ2mN/Cx4IHLVZFL6UBGAh+/GXhgvGb+nVxw=="],
+
+ "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.52.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QOWgFH5X9+p+S1NAfOqc0z8qEpJIoUHf7OWjNUGOeW18Mx22lAUOiA9b6r2/vpzLdfxi/f+VWsYjUOMCcYh0Ng=="],
+
+ "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.52.2", "", { "os": "linux", "cpu": "arm" }, "sha512-kDWSPafToDd8LcBYd1t5jw7bD5Ojcu12S3uT372e5HKPzQt532vW+rGFFOaiR0opxePyUkHrwz8iWYEyH1IIQA=="],
+
+ "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.52.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gKm7Mk9wCv6/rkzwCiUC4KnevYhlf8ztBrDRT9g/u//1fZLapSRc+eDZj2Eu2wpJ+0RzUKgtNijnVIB4ZxyL+w=="],
+
+ "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.52.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-66lA8vnj5mB/rtDNwPgrrKUOtCLVQypkyDa2gMfOefXK6rcZAxKLO9Fy3GkW8VkPnENv9hBkNOFfGLf6rNKGUg=="],
+
+ "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.52.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-s+OPucLNdJHvuZHuIz2WwncJ+SfWHFEmlC5nKMUgAelUeBUnlB4wt7rXWiyG4Zn07uY2Dd+SGyVa9oyLkVGOjA=="],
+
+ "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.52.2", "", { "os": "linux", "cpu": "none" }, "sha512-8wTRM3+gVMDLLDdaT6tKmOE3lJyRy9NpJUS/ZRWmLCmOPIJhVyXwjBo+XbrrwtV33Em1/eCTd5TuGJm4+DmYjw=="],
+
+ "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.52.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-6yqEfgJ1anIeuP2P/zhtfBlDpXUb80t8DpbYwXQ3bQd95JMvUaqiX+fKqYqUwZXqdJDd8xdilNtsHM2N0cFm6A=="],
+
+ "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.52.2", "", { "os": "linux", "cpu": "none" }, "sha512-sshYUiYVSEI2B6dp4jMncwxbrUqRdNApF2c3bhtLAU0qA8Lrri0p0NauOsTWh3yCCCDyBOjESHMExonp7Nzc0w=="],
+
+ "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.52.2", "", { "os": "linux", "cpu": "none" }, "sha512-duBLgd+3pqC4MMwBrKkFxaZerUxZcYApQVC5SdbF5/e/589GwVvlRUnyqMFbM8iUSb1BaoX/3fRL7hB9m2Pj8Q=="],
+
+ "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.52.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-tzhYJJidDUVGMgVyE+PmxENPHlvvqm1KILjjZhB8/xHYqAGeizh3GBGf9u6WdJpZrz1aCpIIHG0LgJgH9rVjHQ=="],
+
+ "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.52.2", "", { "os": "linux", "cpu": "x64" }, "sha512-opH8GSUuVcCSSyHHcl5hELrmnk4waZoVpgn/4FDao9iyE4WpQhyWJ5ryl5M3ocp4qkRuHfyXnGqg8M9oKCEKRA=="],
+
+ "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.52.2", "", { "os": "linux", "cpu": "x64" }, "sha512-LSeBHnGli1pPKVJ79ZVJgeZWWZXkEe/5o8kcn23M8eMKCUANejchJbF/JqzM4RRjOJfNRhKJk8FuqL1GKjF5oQ=="],
+
+ "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.52.2", "", { "os": "none", "cpu": "arm64" }, "sha512-uPj7MQ6/s+/GOpolavm6BPo+6CbhbKYyZHUDvZ/SmJM7pfDBgdGisFX3bY/CBDMg2ZO4utfhlApkSfZ92yXw7Q=="],
+
+ "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.52.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z9MUCrSgIaUeeHAiNkm3cQyst2UhzjPraR3gYYfOjAuZI7tcFRTOD+4cHLPoS/3qinchth+V56vtqz1Tv+6KPA=="],
+
+ "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.52.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-+GnYBmpjldD3XQd+HMejo+0gJGwYIOfFeoBQv32xF/RUIvccUz20/V6Otdv+57NE70D5pa8W/jVGDoGq0oON4A=="],
+
+ "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.52.2", "", { "os": "win32", "cpu": "x64" }, "sha512-ApXFKluSB6kDQkAqZOKXBjiaqdF1BlKi+/eqnYe9Ee7U2K3pUDKsIyr8EYm/QDHTJIM+4X+lI0gJc3TTRhd+dA=="],
+
+ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.52.2", "", { "os": "win32", "cpu": "x64" }, "sha512-ARz+Bs8kY6FtitYM96PqPEVvPXqEZmPZsSkXvyX19YzDqkCaIlhCieLLMI5hxO9SRZ2XtCtm8wxhy0iJ2jxNfw=="],
+
+ "@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="],
+
+ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
+
+ "@types/node": ["@types/node@20.19.21", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-CsGG2P3I5y48RPMfprQGfy4JPRZ6csfC3ltBZSRItG3ngggmNY/qs2uZKp4p9VbrpqNNSMzUZNFZKzgOGnd/VA=="],
+
+ "@vitest/expect": ["@vitest/expect@1.6.1", "", { "dependencies": { "@vitest/spy": "1.6.1", "@vitest/utils": "1.6.1", "chai": "^4.3.10" } }, "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog=="],
+
+ "@vitest/runner": ["@vitest/runner@1.6.1", "", { "dependencies": { "@vitest/utils": "1.6.1", "p-limit": "^5.0.0", "pathe": "^1.1.1" } }, "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA=="],
+
+ "@vitest/snapshot": ["@vitest/snapshot@1.6.1", "", { "dependencies": { "magic-string": "^0.30.5", "pathe": "^1.1.1", "pretty-format": "^29.7.0" } }, "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ=="],
+
+ "@vitest/spy": ["@vitest/spy@1.6.1", "", { "dependencies": { "tinyspy": "^2.2.0" } }, "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw=="],
+
+ "@vitest/utils": ["@vitest/utils@1.6.1", "", { "dependencies": { "diff-sequences": "^29.6.3", "estree-walker": "^3.0.3", "loupe": "^2.3.7", "pretty-format": "^29.7.0" } }, "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
+ "acorn": ["acorn@8.15.0", "", { "bin": "bin/acorn" }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
+
+ "acorn-walk": ["acorn-walk@8.3.4", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g=="],
+
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
+ "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
+
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
- "body-parser": ["body-parser@2.2.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.0", "http-errors": "^2.0.0", "iconv-lite": "^0.6.3", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.0", "type-is": "^2.0.0" } }, "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg=="],
+ "assertion-error": ["assertion-error@1.1.0", "", {}, "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw=="],
- "bun-types": ["bun-types@1.2.22", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-hwaAu8tct/Zn6Zft4U9BsZcXkYomzpHJX28ofvx7k0Zz2HNz54n1n+tDgxoWFGB4PcFvJXJQloPhaV2eP3Q6EA=="],
+ "body-parser": ["body-parser@2.2.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.0", "http-errors": "^2.0.0", "iconv-lite": "^0.6.3", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.0", "type-is": "^2.0.0" } }, "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
+ "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
+
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
+ "chai": ["chai@4.5.0", "", { "dependencies": { "assertion-error": "^1.1.0", "check-error": "^1.0.3", "deep-eql": "^4.1.3", "get-func-name": "^2.0.2", "loupe": "^2.3.6", "pathval": "^1.1.1", "type-detect": "^4.1.0" } }, "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw=="],
+
+ "check-error": ["check-error@1.0.3", "", { "dependencies": { "get-func-name": "^2.0.2" } }, "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg=="],
+
+ "confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
+
"content-disposition": ["content-disposition@1.0.0", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
@@ -49,12 +176,14 @@
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
- "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
-
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+ "deep-eql": ["deep-eql@4.1.4", "", { "dependencies": { "type-detect": "^4.0.0" } }, "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg=="],
+
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
+ "diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="],
+
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
@@ -67,9 +196,13 @@
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
+ "esbuild": ["esbuild@0.25.10", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.10", "@esbuild/android-arm": "0.25.10", "@esbuild/android-arm64": "0.25.10", "@esbuild/android-x64": "0.25.10", "@esbuild/darwin-arm64": "0.25.10", "@esbuild/darwin-x64": "0.25.10", "@esbuild/freebsd-arm64": "0.25.10", "@esbuild/freebsd-x64": "0.25.10", "@esbuild/linux-arm": "0.25.10", "@esbuild/linux-arm64": "0.25.10", "@esbuild/linux-ia32": "0.25.10", "@esbuild/linux-loong64": "0.25.10", "@esbuild/linux-mips64el": "0.25.10", "@esbuild/linux-ppc64": "0.25.10", "@esbuild/linux-riscv64": "0.25.10", "@esbuild/linux-s390x": "0.25.10", "@esbuild/linux-x64": "0.25.10", "@esbuild/netbsd-arm64": "0.25.10", "@esbuild/netbsd-x64": "0.25.10", "@esbuild/openbsd-arm64": "0.25.10", "@esbuild/openbsd-x64": "0.25.10", "@esbuild/openharmony-arm64": "0.25.10", "@esbuild/sunos-x64": "0.25.10", "@esbuild/win32-arm64": "0.25.10", "@esbuild/win32-ia32": "0.25.10", "@esbuild/win32-x64": "0.25.10" }, "bin": "bin/esbuild" }, "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ=="],
+
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
- "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
+ "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
+
+ "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
@@ -77,6 +210,8 @@
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
+ "execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="],
+
"express": ["express@5.1.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="],
"express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="],
@@ -93,12 +228,20 @@
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
+ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
+ "get-func-name": ["get-func-name@2.0.2", "", {}, "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ=="],
+
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
+ "get-stream": ["get-stream@8.0.1", "", {}, "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA=="],
+
+ "get-tsconfig": ["get-tsconfig@4.10.1", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ=="],
+
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="],
@@ -109,6 +252,8 @@
"http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="],
+ "human-signals": ["human-signals@5.0.0", "", {}, "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ=="],
+
"iconv-lite": ["iconv-lite@0.7.0", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
@@ -119,28 +264,48 @@
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
+ "is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="],
+
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
- "js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="],
+ "js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
+
+ "js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": "bin/js-yaml.js" }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="],
+ "local-pkg": ["local-pkg@0.5.1", "", { "dependencies": { "mlly": "^1.7.3", "pkg-types": "^1.2.1" } }, "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ=="],
+
+ "loupe": ["loupe@2.3.7", "", { "dependencies": { "get-func-name": "^2.0.1" } }, "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA=="],
+
+ "magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="],
+
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
+ "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
+
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"mime-types": ["mime-types@3.0.1", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="],
+ "mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="],
+
+ "mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="],
+
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+ "nanoid": ["nanoid@3.3.11", "", { "bin": "bin/nanoid.cjs" }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
+
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
+ "npm-run-path": ["npm-run-path@5.3.0", "", { "dependencies": { "path-key": "^4.0.0" } }, "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ=="],
+
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
@@ -149,14 +314,30 @@
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
+ "onetime": ["onetime@6.0.0", "", { "dependencies": { "mimic-fn": "^4.0.0" } }, "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ=="],
+
+ "p-limit": ["p-limit@5.0.0", "", { "dependencies": { "yocto-queue": "^1.0.0" } }, "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ=="],
+
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
+ "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
+
+ "pathval": ["pathval@1.1.1", "", {}, "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ=="],
+
+ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
+
"pkce-challenge": ["pkce-challenge@5.0.0", "", {}, "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ=="],
+ "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
+
+ "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
+
+ "pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="],
+
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
@@ -167,6 +348,12 @@
"raw-body": ["raw-body@3.0.1", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", "iconv-lite": "0.7.0", "unpipe": "1.0.0" } }, "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA=="],
+ "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
+
+ "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
+
+ "rollup": ["rollup@4.52.2", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.52.2", "@rollup/rollup-android-arm64": "4.52.2", "@rollup/rollup-darwin-arm64": "4.52.2", "@rollup/rollup-darwin-x64": "4.52.2", "@rollup/rollup-freebsd-arm64": "4.52.2", "@rollup/rollup-freebsd-x64": "4.52.2", "@rollup/rollup-linux-arm-gnueabihf": "4.52.2", "@rollup/rollup-linux-arm-musleabihf": "4.52.2", "@rollup/rollup-linux-arm64-gnu": "4.52.2", "@rollup/rollup-linux-arm64-musl": "4.52.2", "@rollup/rollup-linux-loong64-gnu": "4.52.2", "@rollup/rollup-linux-ppc64-gnu": "4.52.2", "@rollup/rollup-linux-riscv64-gnu": "4.52.2", "@rollup/rollup-linux-riscv64-musl": "4.52.2", "@rollup/rollup-linux-s390x-gnu": "4.52.2", "@rollup/rollup-linux-x64-gnu": "4.52.2", "@rollup/rollup-linux-x64-musl": "4.52.2", "@rollup/rollup-openharmony-arm64": "4.52.2", "@rollup/rollup-win32-arm64-msvc": "4.52.2", "@rollup/rollup-win32-ia32-msvc": "4.52.2", "@rollup/rollup-win32-x64-gnu": "4.52.2", "@rollup/rollup-win32-x64-msvc": "4.52.2", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-I25/2QgoROE1vYV+NQ1En9T9UFB9Cmfm2CJ83zZOlaDpvz29wGQSZXWKw7MiNXau7wYgB/T9fVIdIuEQ+KbiiA=="],
+
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
@@ -193,17 +380,45 @@
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
+ "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
+
+ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
+
+ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
+
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
+ "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
+
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
+ "std-env": ["std-env@3.9.0", "", {}, "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw=="],
+
"strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="],
+ "strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="],
+
+ "strip-literal": ["strip-literal@2.1.1", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q=="],
+
+ "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
+
+ "tinypool": ["tinypool@0.8.4", "", {}, "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ=="],
+
+ "tinyspy": ["tinyspy@2.2.1", "", {}, "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A=="],
+
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
+ "tsx": ["tsx@4.20.6", "", { "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg=="],
+
+ "type-detect": ["type-detect@4.1.0", "", {}, "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw=="],
+
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
- "undici-types": ["undici-types@7.12.0", "", {}, "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ=="],
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
+ "ufo": ["ufo@1.6.1", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="],
+
+ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
@@ -211,10 +426,20 @@
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
- "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
+ "vite": ["vite@5.4.20", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": "bin/vite.js" }, "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g=="],
+
+ "vite-node": ["vite-node@1.6.1", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.3.4", "pathe": "^1.1.1", "picocolors": "^1.0.0", "vite": "^5.0.0" }, "bin": "vite-node.mjs" }, "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA=="],
+
+ "vitest": ["vitest@1.6.1", "", { "dependencies": { "@vitest/expect": "1.6.1", "@vitest/runner": "1.6.1", "@vitest/snapshot": "1.6.1", "@vitest/spy": "1.6.1", "@vitest/utils": "1.6.1", "acorn-walk": "^8.3.2", "chai": "^4.3.10", "debug": "^4.3.4", "execa": "^8.0.1", "local-pkg": "^0.5.0", "magic-string": "^0.30.5", "pathe": "^1.1.1", "picocolors": "^1.0.0", "std-env": "^3.5.0", "strip-literal": "^2.0.0", "tinybench": "^2.5.1", "tinypool": "^0.8.3", "vite": "^5.0.0", "vite-node": "1.6.1", "why-is-node-running": "^2.2.2" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", "@vitest/browser": "1.6.1", "@vitest/ui": "1.6.1", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": "vitest.mjs" }, "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag=="],
+
+ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
+
+ "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": "cli.js" }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
+ "yocto-queue": ["yocto-queue@1.2.1", "", {}, "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg=="],
+
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"zod-to-json-schema": ["zod-to-json-schema@3.24.6", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg=="],
@@ -222,5 +447,59 @@
"body-parser/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"http-errors/statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="],
+
+ "mlly/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
+
+ "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
+
+ "pkg-types/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
+
+ "vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": "bin/esbuild" }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
+
+ "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
+
+ "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
+
+ "vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
+
+ "vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
+
+ "vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
+
+ "vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
+
+ "vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
+
+ "vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
+
+ "vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
+
+ "vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
+
+ "vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
+
+ "vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
+
+ "vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
+
+ "vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
+
+ "vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
+
+ "vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
+
+ "vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
+
+ "vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
+
+ "vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
+
+ "vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
+
+ "vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
+
+ "vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
+
+ "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
}
}
diff --git a/debug-server.js b/debug-server.js
new file mode 100755
index 0000000..a9d72a2
--- /dev/null
+++ b/debug-server.js
@@ -0,0 +1,13 @@
+#!/usr/bin/env node
+
+console.error("Debug: Starting minimal MCP server");
+console.error("Debug: Node version:", process.version);
+console.error("Debug: Process argv:", process.argv);
+console.error("Debug: Process env PATH:", process.env.PATH);
+console.error("Debug: Process env BUN_INSTALL:", process.env.BUN_INSTALL);
+
+// Simple minimal server that just exits after logging
+setTimeout(() => {
+ console.error("Debug: Exiting after 2 seconds");
+ process.exit(0);
+}, 2000);
\ No newline at end of file
diff --git a/dist/server.d.ts b/dist/server.d.ts
new file mode 100644
index 0000000..1d8b488
--- /dev/null
+++ b/dist/server.d.ts
@@ -0,0 +1,3 @@
+#!/usr/bin/env node
+export {};
+//# sourceMappingURL=server.d.ts.map
\ No newline at end of file
diff --git a/dist/server.d.ts.map b/dist/server.d.ts.map
new file mode 100644
index 0000000..242f983
--- /dev/null
+++ b/dist/server.d.ts.map
@@ -0,0 +1 @@
+{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../server.ts"],"names":[],"mappings":""}
\ No newline at end of file
diff --git a/dist/server.js b/dist/server.js
new file mode 100644
index 0000000..442c409
--- /dev/null
+++ b/dist/server.js
@@ -0,0 +1,64 @@
+#!/usr/bin/env node
+import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
+import { createServer } from "./src/createServer.js";
+import { readFileSync } from "fs";
+import { fileURLToPath } from "url";
+import { dirname, join, resolve } from "path";
+// Get package.json version
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+const packageJson = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8"));
+const VERSION = packageJson.version;
+// Handle --version and --help flags
+const cliArgs = process.argv.slice(2);
+const firstArg = cliArgs[0];
+if (firstArg === "--version" || firstArg === "-v") {
+ console.log(VERSION);
+ process.exit(0);
+}
+if (firstArg === "--help" || firstArg === "-h") {
+ console.log(`
+mcpvault v${VERSION}
+
+Universal AI bridge for Obsidian vaults - connect any MCP-compatible assistant
+
+Usage:
+ npx @bitbonsai/mcpvault [vault-path]
+
+Arguments:
+ [vault-path] Optional path to your Obsidian vault directory
+ Defaults to current working directory when omitted
+
+Options:
+ --version, -v Show version number
+ --help, -h Show this help message
+ --exclude Exclude a path or glob from the vault (repeatable)
+
+Examples:
+ npx @bitbonsai/mcpvault
+ npx @bitbonsai/mcpvault ~/Documents/MyVault
+ npx @bitbonsai/mcpvault ./Vault
+ npx @bitbonsai/mcpvault /path/to/obsidian/vault
+ npx @bitbonsai/mcpvault "/path/with spaces/Obsidian Vault"
+ npx @bitbonsai/mcpvault ./Vault --exclude Private --exclude "Private/**"
+`);
+ process.exit(0);
+}
+// Separate --exclude flags from positional args
+const excludePatterns = [];
+const positionalArgs = [];
+for (let i = 0; i < cliArgs.length; i++) {
+ if (cliArgs[i] === '--exclude' && i + 1 < cliArgs.length) {
+ excludePatterns.push(cliArgs[++i]);
+ }
+ else {
+ positionalArgs.push(cliArgs[i]);
+ }
+}
+// Join trailing args to support vault paths with spaces.
+// When omitted, default to current working directory.
+const vaultPathArg = positionalArgs.join(' ').trim();
+const vaultPath = resolve(vaultPathArg || process.cwd());
+const server = createServer(vaultPath, { version: VERSION, excludePatterns });
+const transport = new StdioServerTransport();
+await server.connect(transport);
diff --git a/dist/src/createServer.d.ts b/dist/src/createServer.d.ts
new file mode 100644
index 0000000..a510605
--- /dev/null
+++ b/dist/src/createServer.d.ts
@@ -0,0 +1,12 @@
+import { Server } from "@modelcontextprotocol/sdk/server/index.js";
+import { FrontmatterHandler } from "./frontmatter.js";
+import { PathFilter } from "./pathfilter.js";
+export interface CreateServerOptions {
+ name?: string;
+ version?: string;
+ pathFilter?: PathFilter;
+ excludePatterns?: string[];
+ frontmatterHandler?: FrontmatterHandler;
+}
+export declare function createServer(vaultPath: string, options?: CreateServerOptions): Server;
+//# sourceMappingURL=createServer.d.ts.map
\ No newline at end of file
diff --git a/dist/src/createServer.d.ts.map b/dist/src/createServer.d.ts.map
new file mode 100644
index 0000000..b8ece00
--- /dev/null
+++ b/dist/src/createServer.d.ts.map
@@ -0,0 +1 @@
+{"version":3,"file":"createServer.d.ts","sourceRoot":"","sources":["../../src/createServer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,2CAA2C,CAAC;AAMnE,OAAO,EAAE,kBAAkB,EAAoB,MAAM,kBAAkB,CAAC;AACxE,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAI7C,MAAM,WAAW,mBAAmB;IAClC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAC3B,kBAAkB,CAAC,EAAE,kBAAkB,CAAC;CACzC;AAED,wBAAgB,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,GAAE,mBAAwB,GAAG,MAAM,CA8YzF"}
\ No newline at end of file
diff --git a/dist/src/createServer.js b/dist/src/createServer.js
new file mode 100644
index 0000000..4660109
--- /dev/null
+++ b/dist/src/createServer.js
@@ -0,0 +1,402 @@
+import { Server } from "@modelcontextprotocol/sdk/server/index.js";
+import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
+import { FileSystemService } from "./filesystem.js";
+import { FrontmatterHandler, parseFrontmatter } from "./frontmatter.js";
+import { PathFilter } from "./pathfilter.js";
+import { SearchService } from "./search.js";
+import { resolve } from "path";
+export function createServer(vaultPath, options = {}) {
+ const { name = "mcpvault", version = "0.0.0", pathFilter = new PathFilter(options.excludePatterns ? { ignoredPatterns: options.excludePatterns } : undefined), frontmatterHandler = new FrontmatterHandler(), } = options;
+ const resolvedVaultPath = resolve(vaultPath);
+ const fileSystem = new FileSystemService(resolvedVaultPath, pathFilter, frontmatterHandler);
+ const searchService = new SearchService(resolvedVaultPath, pathFilter);
+ const server = new Server({ name, version }, {
+ capabilities: { tools: {} },
+ });
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
+ return {
+ tools: [
+ {
+ name: "read_note",
+ description: "Read a note from the Obsidian vault",
+ inputSchema: {
+ type: "object",
+ properties: {
+ path: { type: "string", description: "Path to the note relative to vault root" },
+ prettyPrint: { type: "boolean", description: "Format JSON response with indentation (default: false)", default: false }
+ },
+ required: ["path"]
+ }
+ },
+ {
+ name: "write_note",
+ description: "Write a note to the Obsidian vault",
+ inputSchema: {
+ type: "object",
+ properties: {
+ path: { type: "string", description: "Path to the note relative to vault root" },
+ content: { type: "string", description: "Content of the note" },
+ frontmatter: { type: "object", description: "Frontmatter object (optional)" },
+ mode: { type: "string", enum: ["overwrite", "append", "prepend"], description: "Write mode: 'overwrite' (default), 'append', or 'prepend'", default: "overwrite" }
+ },
+ required: ["path", "content"]
+ }
+ },
+ {
+ name: "patch_note",
+ description: "Efficiently update part of a note by replacing a specific string. This is more efficient than rewriting the entire note for small changes.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ path: { type: "string", description: "Path to the note relative to vault root" },
+ oldString: { type: "string", description: "The exact string to replace. Must match exactly including whitespace and line breaks." },
+ newString: { type: "string", description: "The new string to insert in place of oldString" },
+ replaceAll: { type: "boolean", description: "If true, replace all occurrences. If false (default), the operation will fail if multiple matches are found to prevent unintended replacements.", default: false }
+ },
+ required: ["path", "oldString", "newString"]
+ }
+ },
+ {
+ name: "list_directory",
+ description: "List files and directories in the vault (includes non-note filenames, while read/write tools remain note-only)",
+ inputSchema: {
+ type: "object",
+ properties: {
+ path: { type: "string", description: "Path relative to vault root (default: '/')", default: "/" },
+ prettyPrint: { type: "boolean", description: "Format JSON response with indentation (default: false)", default: false }
+ }
+ }
+ },
+ {
+ name: "delete_note",
+ description: "Delete a note from the Obsidian vault (requires confirmation). Supports permanent delete, vault trash, or system trash.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ path: { type: "string", description: "Path to the note relative to vault root" },
+ confirmPath: { type: "string", description: "Confirmation: must exactly match the path parameter to proceed with deletion" },
+ trashMode: { type: "string", enum: ["none", "local", "system"], description: "Deletion mode: 'none' = permanent delete (default), 'local' = move to .trash inside vault, 'system' = move to OS trash", default: "none" }
+ },
+ required: ["path", "confirmPath"]
+ }
+ },
+ {
+ name: "search_notes",
+ description: "Search for notes in the vault by content or frontmatter",
+ inputSchema: {
+ type: "object",
+ properties: {
+ query: { type: "string", description: "Search query text" },
+ limit: { type: "number", description: "Maximum number of results (default: 5, max: 20)", default: 5 },
+ searchContent: { type: "boolean", description: "Search in note content (default: true)", default: true },
+ searchFrontmatter: { type: "boolean", description: "Search in frontmatter (default: false)", default: false },
+ caseSensitive: { type: "boolean", description: "Case sensitive search (default: false)", default: false },
+ prettyPrint: { type: "boolean", description: "Format JSON response with indentation (default: false)", default: false }
+ },
+ required: ["query"]
+ }
+ },
+ {
+ name: "move_note",
+ description: "Move or rename a note in the vault",
+ inputSchema: {
+ type: "object",
+ properties: {
+ oldPath: { type: "string", description: "Current path of the note" },
+ newPath: { type: "string", description: "New path for the note" },
+ overwrite: { type: "boolean", description: "Allow overwriting existing file (default: false)", default: false }
+ },
+ required: ["oldPath", "newPath"]
+ }
+ },
+ {
+ name: "move_file",
+ description: "Move or rename any file in the vault (binary-safe, file-only, requires confirmation)",
+ inputSchema: {
+ type: "object",
+ properties: {
+ oldPath: { type: "string", description: "Current path of the file" },
+ newPath: { type: "string", description: "New path for the file" },
+ confirmOldPath: { type: "string", description: "Confirmation: must exactly match oldPath" },
+ confirmNewPath: { type: "string", description: "Confirmation: must exactly match newPath" },
+ overwrite: { type: "boolean", description: "Allow overwriting existing file (default: false)", default: false }
+ },
+ required: ["oldPath", "newPath", "confirmOldPath", "confirmNewPath"]
+ }
+ },
+ {
+ name: "read_multiple_notes",
+ description: "Read multiple notes in a batch (max 10 files)",
+ inputSchema: {
+ type: "object",
+ properties: {
+ paths: { type: "array", items: { type: "string" }, description: "Array of note paths to read", maxItems: 10 },
+ includeContent: { type: "boolean", description: "Include note content (default: true)", default: true },
+ includeFrontmatter: { type: "boolean", description: "Include frontmatter (default: true)", default: true },
+ prettyPrint: { type: "boolean", description: "Format JSON response with indentation (default: false)", default: false }
+ },
+ required: ["paths"]
+ }
+ },
+ {
+ name: "update_frontmatter",
+ description: "Update frontmatter of a note without changing content",
+ inputSchema: {
+ type: "object",
+ properties: {
+ path: { type: "string", description: "Path to the note" },
+ frontmatter: { type: "object", description: "Frontmatter object to update" },
+ merge: { type: "boolean", description: "Merge with existing frontmatter (default: true)", default: true }
+ },
+ required: ["path", "frontmatter"]
+ }
+ },
+ {
+ name: "get_notes_info",
+ description: "Get metadata for notes without reading full content",
+ inputSchema: {
+ type: "object",
+ properties: {
+ paths: { type: "array", items: { type: "string" }, description: "Array of note paths to get info for" },
+ prettyPrint: { type: "boolean", description: "Format JSON response with indentation (default: false)", default: false }
+ },
+ required: ["paths"]
+ }
+ },
+ {
+ name: "get_frontmatter",
+ description: "Extract frontmatter from a note without reading the content",
+ inputSchema: {
+ type: "object",
+ properties: {
+ path: { type: "string", description: "Path to the note relative to vault root" },
+ prettyPrint: { type: "boolean", description: "Format JSON response with indentation (default: false)", default: false }
+ },
+ required: ["path"]
+ }
+ },
+ {
+ name: "manage_tags",
+ description: "Add, remove, or list tags in a note",
+ inputSchema: {
+ type: "object",
+ properties: {
+ path: { type: "string", description: "Path to the note relative to vault root" },
+ operation: { type: "string", enum: ["add", "remove", "list"], description: "Operation to perform: 'add', 'remove', or 'list'" },
+ tags: { type: "array", items: { type: "string" }, description: "Array of tags (required for 'add' and 'remove' operations)" }
+ },
+ required: ["path", "operation"]
+ }
+ },
+ {
+ name: "get_vault_stats",
+ description: "Get vault statistics including total notes, folders, size, and recently modified files. Useful for understanding vault scope before batch operations.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ recentCount: { type: "number", description: "Number of recently modified files to return (default: 5, max: 20)", default: 5 },
+ prettyPrint: { type: "boolean", description: "Format JSON response with indentation (default: false)", default: false }
+ }
+ }
+ },
+ {
+ name: "list_all_tags",
+ description: "List all tags across the vault with occurrence counts. Returns both frontmatter tags and inline #hashtags, deduplicated and sorted by frequency. Useful for discovering existing tags before creating or organizing notes.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ prettyPrint: { type: "boolean", description: "Format JSON response with indentation (default: false)", default: false }
+ }
+ }
+ }
+ ]
+ };
+ });
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
+ const { name: toolName, arguments: args } = request.params;
+ const trimmedArgs = trimPaths(args);
+ try {
+ switch (toolName) {
+ case "read_note": {
+ const note = await fileSystem.readNote(trimmedArgs.path);
+ const indent = trimmedArgs.prettyPrint ? 2 : undefined;
+ return {
+ content: [{ type: "text", text: JSON.stringify({ fm: note.frontmatter, content: note.content }, null, indent) }]
+ };
+ }
+ case "write_note": {
+ const fm = parseFrontmatter(trimmedArgs.frontmatter);
+ await fileSystem.writeNote({
+ path: trimmedArgs.path,
+ content: trimmedArgs.content,
+ ...(fm !== undefined && { frontmatter: fm }),
+ mode: trimmedArgs.mode || 'overwrite'
+ });
+ return {
+ content: [{ type: "text", text: `Successfully wrote note: ${trimmedArgs.path} (mode: ${trimmedArgs.mode || 'overwrite'})` }]
+ };
+ }
+ case "patch_note": {
+ const result = await fileSystem.patchNote({
+ path: trimmedArgs.path,
+ oldString: trimmedArgs.oldString,
+ newString: trimmedArgs.newString,
+ replaceAll: trimmedArgs.replaceAll
+ });
+ return {
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
+ isError: !result.success
+ };
+ }
+ case "list_directory": {
+ const listing = await fileSystem.listDirectory(trimmedArgs.path || '');
+ const indent = trimmedArgs.prettyPrint ? 2 : undefined;
+ return {
+ content: [{ type: "text", text: JSON.stringify({ dirs: listing.directories, files: listing.files }, null, indent) }]
+ };
+ }
+ case "delete_note": {
+ const result = await fileSystem.deleteNote({
+ path: trimmedArgs.path,
+ confirmPath: trimmedArgs.confirmPath,
+ trashMode: trimmedArgs.trashMode
+ });
+ return {
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
+ isError: !result.success
+ };
+ }
+ case "search_notes": {
+ const results = await searchService.search({
+ query: trimmedArgs.query,
+ limit: trimmedArgs.limit,
+ searchContent: trimmedArgs.searchContent,
+ searchFrontmatter: trimmedArgs.searchFrontmatter,
+ caseSensitive: trimmedArgs.caseSensitive
+ });
+ const indent = trimmedArgs.prettyPrint ? 2 : undefined;
+ return {
+ content: [{ type: "text", text: JSON.stringify(results, null, indent) }]
+ };
+ }
+ case "move_note": {
+ const result = await fileSystem.moveNote({
+ oldPath: trimmedArgs.oldPath,
+ newPath: trimmedArgs.newPath,
+ overwrite: trimmedArgs.overwrite
+ });
+ return {
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
+ isError: !result.success
+ };
+ }
+ case "move_file": {
+ const result = await fileSystem.moveFile({
+ oldPath: trimmedArgs.oldPath,
+ newPath: trimmedArgs.newPath,
+ confirmOldPath: trimmedArgs.confirmOldPath,
+ confirmNewPath: trimmedArgs.confirmNewPath,
+ overwrite: trimmedArgs.overwrite
+ });
+ return {
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
+ isError: !result.success
+ };
+ }
+ case "read_multiple_notes": {
+ const result = await fileSystem.readMultipleNotes({
+ paths: trimmedArgs.paths,
+ includeContent: trimmedArgs.includeContent,
+ includeFrontmatter: trimmedArgs.includeFrontmatter
+ });
+ const indent = trimmedArgs.prettyPrint ? 2 : undefined;
+ return {
+ content: [{ type: "text", text: JSON.stringify({ ok: result.successful, err: result.failed }, null, indent) }]
+ };
+ }
+ case "update_frontmatter": {
+ const fm = parseFrontmatter(trimmedArgs.frontmatter);
+ if (!fm) {
+ throw new Error('frontmatter is required');
+ }
+ await fileSystem.updateFrontmatter({
+ path: trimmedArgs.path,
+ frontmatter: fm,
+ merge: trimmedArgs.merge
+ });
+ return {
+ content: [{ type: "text", text: `Successfully updated frontmatter for: ${trimmedArgs.path}` }]
+ };
+ }
+ case "get_notes_info": {
+ const result = await fileSystem.getNotesInfo(trimmedArgs.paths);
+ const indent = trimmedArgs.prettyPrint ? 2 : undefined;
+ return {
+ content: [{ type: "text", text: JSON.stringify(result, null, indent) }]
+ };
+ }
+ case "get_frontmatter": {
+ const note = await fileSystem.readNote(trimmedArgs.path);
+ const indent = trimmedArgs.prettyPrint ? 2 : undefined;
+ return {
+ content: [{ type: "text", text: JSON.stringify(note.frontmatter, null, indent) }]
+ };
+ }
+ case "manage_tags": {
+ const result = await fileSystem.manageTags({
+ path: trimmedArgs.path,
+ operation: trimmedArgs.operation,
+ tags: trimmedArgs.tags
+ });
+ return {
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
+ isError: !result.success
+ };
+ }
+ case "get_vault_stats": {
+ const recentCount = Math.min(trimmedArgs.recentCount || 5, 20);
+ const stats = await fileSystem.getVaultStats(recentCount);
+ const indent = trimmedArgs.prettyPrint ? 2 : undefined;
+ return {
+ content: [{ type: "text", text: JSON.stringify({ notes: stats.totalNotes, folders: stats.totalFolders, size: stats.totalSize, recent: stats.recentlyModified }, null, indent) }]
+ };
+ }
+ case "list_all_tags": {
+ const tags = await fileSystem.listAllTags();
+ const indent = trimmedArgs.prettyPrint ? 2 : undefined;
+ return {
+ content: [{ type: "text", text: JSON.stringify(tags, null, indent) }]
+ };
+ }
+ default:
+ throw new Error(`Unknown tool: ${toolName}`);
+ }
+ }
+ catch (error) {
+ return {
+ content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}` }],
+ isError: true
+ };
+ }
+ });
+ return server;
+}
+function trimPaths(args) {
+ const trimmed = { ...args };
+ if (trimmed.path && typeof trimmed.path === 'string')
+ trimmed.path = trimmed.path.trim();
+ if (trimmed.oldPath && typeof trimmed.oldPath === 'string')
+ trimmed.oldPath = trimmed.oldPath.trim();
+ if (trimmed.newPath && typeof trimmed.newPath === 'string')
+ trimmed.newPath = trimmed.newPath.trim();
+ if (trimmed.confirmPath && typeof trimmed.confirmPath === 'string')
+ trimmed.confirmPath = trimmed.confirmPath.trim();
+ if (trimmed.confirmOldPath && typeof trimmed.confirmOldPath === 'string')
+ trimmed.confirmOldPath = trimmed.confirmOldPath.trim();
+ if (trimmed.confirmNewPath && typeof trimmed.confirmNewPath === 'string')
+ trimmed.confirmNewPath = trimmed.confirmNewPath.trim();
+ if (trimmed.paths && Array.isArray(trimmed.paths)) {
+ trimmed.paths = trimmed.paths.map((p) => typeof p === 'string' ? p.trim() : p);
+ }
+ return trimmed;
+}
diff --git a/dist/src/filesystem.d.ts b/dist/src/filesystem.d.ts
new file mode 100644
index 0000000..f50ab3e
--- /dev/null
+++ b/dist/src/filesystem.d.ts
@@ -0,0 +1,30 @@
+import { FrontmatterHandler } from './frontmatter.js';
+import { PathFilter } from './pathfilter.js';
+import type { ParsedNote, DirectoryListing, NoteWriteParams, DeleteNoteParams, DeleteResult, MoveNoteParams, MoveFileParams, MoveResult, BatchReadParams, BatchReadResult, UpdateFrontmatterParams, NoteInfo, TagManagementParams, TagManagementResult, PatchNoteParams, PatchNoteResult, VaultStats } from './types.js';
+export declare class FileSystemService {
+ private vaultPath;
+ private frontmatterHandler;
+ private pathFilter;
+ constructor(vaultPath: string, pathFilter?: PathFilter, frontmatterHandler?: FrontmatterHandler);
+ private resolvePath;
+ readNote(path: string): Promise;
+ writeNote(params: NoteWriteParams): Promise;
+ patchNote(params: PatchNoteParams): Promise;
+ listDirectory(path?: string): Promise;
+ exists(path: string): Promise;
+ isDirectory(path: string): Promise;
+ deleteNote(params: DeleteNoteParams): Promise;
+ moveNote(params: MoveNoteParams): Promise;
+ moveFile(params: MoveFileParams): Promise;
+ readMultipleNotes(params: BatchReadParams): Promise;
+ updateFrontmatter(params: UpdateFrontmatterParams): Promise;
+ getNotesInfo(paths: string[]): Promise;
+ manageTags(params: TagManagementParams): Promise;
+ getVaultPath(): string;
+ getVaultStats(recentCount?: number): Promise;
+ listAllTags(): Promise>;
+}
+//# sourceMappingURL=filesystem.d.ts.map
\ No newline at end of file
diff --git a/dist/src/filesystem.d.ts.map b/dist/src/filesystem.d.ts.map
new file mode 100644
index 0000000..535ef02
--- /dev/null
+++ b/dist/src/filesystem.d.ts.map
@@ -0,0 +1 @@
+{"version":3,"file":"filesystem.d.ts","sourceRoot":"","sources":["../../src/filesystem.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAE7C,OAAO,KAAK,EAAE,UAAU,EAAE,gBAAgB,EAAE,eAAe,EAAE,gBAAgB,EAAE,YAAY,EAAE,cAAc,EAAE,cAAc,EAAE,UAAU,EAAE,eAAe,EAAE,eAAe,EAAE,uBAAuB,EAAE,QAAQ,EAAE,mBAAmB,EAAE,mBAAmB,EAAE,eAAe,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAEzT,qBAAa,iBAAiB;IAK1B,OAAO,CAAC,SAAS;IAJnB,OAAO,CAAC,kBAAkB,CAAqB;IAC/C,OAAO,CAAC,UAAU,CAAa;gBAGrB,SAAS,EAAE,MAAM,EACzB,UAAU,CAAC,EAAE,UAAU,EACvB,kBAAkB,CAAC,EAAE,kBAAkB;IAazC,OAAO,CAAC,WAAW;IA6Db,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC;IAgC3C,SAAS,CAAC,MAAM,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC;IAmFjD,SAAS,CAAC,MAAM,EAAE,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC;IA2F5D,aAAa,CAAC,IAAI,GAAE,MAAW,GAAG,OAAO,CAAC,gBAAgB,CAAC;IA8D3D,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAetC,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAe3C,UAAU,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,YAAY,CAAC;IAyG3D,QAAQ,CAAC,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,UAAU,CAAC;IAoFrD,QAAQ,CAAC,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,UAAU,CAAC;IA6HrD,iBAAiB,CAAC,MAAM,EAAE,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC;IAgDpE,iBAAiB,CAAC,MAAM,EAAE,uBAAuB,GAAG,OAAO,CAAC,IAAI,CAAC;IAqCjE,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC;IA2ClD,UAAU,CAAC,MAAM,EAAE,mBAAmB,GAAG,OAAO,CAAC,mBAAmB,CAAC;IAsG3E,YAAY,IAAI,MAAM;IAIhB,aAAa,CAAC,WAAW,GAAE,MAAU,GAAG,OAAO,CAAC,UAAU,CAAC;IAyD3D,WAAW,IAAI,OAAO,CAAC,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAkDpE"}
\ No newline at end of file
diff --git a/dist/src/filesystem.js b/dist/src/filesystem.js
new file mode 100644
index 0000000..2eb44e8
--- /dev/null
+++ b/dist/src/filesystem.js
@@ -0,0 +1,930 @@
+import { join, resolve, relative, dirname } from 'path';
+import { readdir, stat, readFile, writeFile, unlink, mkdir, access, rename, copyFile } from 'node:fs/promises';
+import { constants, realpathSync } from 'node:fs';
+import trash from 'trash';
+import { FrontmatterHandler } from './frontmatter.js';
+import { PathFilter } from './pathfilter.js';
+import { generateObsidianUri } from './uri.js';
+export class FileSystemService {
+ vaultPath;
+ frontmatterHandler;
+ pathFilter;
+ constructor(vaultPath, pathFilter, frontmatterHandler) {
+ this.vaultPath = vaultPath;
+ const resolved = resolve(vaultPath);
+ try {
+ this.vaultPath = realpathSync(resolved);
+ }
+ catch {
+ // Vault path doesn't exist yet or is inaccessible; fall back to lexical resolution
+ this.vaultPath = resolved;
+ }
+ this.pathFilter = pathFilter || new PathFilter();
+ this.frontmatterHandler = frontmatterHandler || new FrontmatterHandler();
+ }
+ resolvePath(relativePath) {
+ // Handle undefined or null path
+ if (!relativePath) {
+ relativePath = '';
+ }
+ // Trim whitespace from path
+ relativePath = relativePath.trim();
+ // Normalize and resolve the path within the vault
+ const normalizedPath = relativePath.startsWith('/')
+ ? relativePath.slice(1)
+ : relativePath;
+ const fullPath = resolve(join(this.vaultPath, normalizedPath));
+ // Security check: ensure path is within vault (lexical)
+ const relativeToVault = relative(this.vaultPath, fullPath);
+ if (relativeToVault.startsWith('..')) {
+ throw new Error(`Path traversal not allowed: ${relativePath}. Paths must be within the vault directory.`);
+ }
+ // Security check: ensure symlinks don't escape vault boundary
+ try {
+ const realPath = realpathSync(fullPath);
+ const realRelative = relative(this.vaultPath, realPath);
+ if (realRelative.startsWith('..')) {
+ throw new Error(`Symlink target is outside vault: ${relativePath}. Symbolic links must resolve to a path within the vault directory.`);
+ }
+ }
+ catch (err) {
+ if (err instanceof Error && 'code' in err) {
+ const code = err.code;
+ if (code === 'ENOENT') {
+ // File doesn't exist yet (e.g. writing a new note). Verify the parent directory resolves inside vault.
+ try {
+ const parentReal = realpathSync(dirname(fullPath));
+ const parentRelative = relative(this.vaultPath, parentReal);
+ if (parentRelative.startsWith('..')) {
+ throw new Error(`Symlink target is outside vault: ${relativePath}. Symbolic links must resolve to a path within the vault directory.`);
+ }
+ }
+ catch (parentErr) {
+ // Parent doesn't exist either (will be created by mkdir). Lexical check above is sufficient.
+ if (parentErr instanceof Error && parentErr.message.includes('outside vault')) {
+ throw parentErr;
+ }
+ }
+ }
+ else if (code === 'ELOOP') {
+ throw new Error(`Circular symlink detected: ${relativePath}. The symbolic link chain forms a loop.`);
+ }
+ else if (code === 'EACCES') {
+ throw new Error(`Permission denied resolving symlink: ${relativePath}. Cannot verify the symbolic link target is within the vault.`);
+ }
+ else {
+ throw err;
+ }
+ }
+ else {
+ throw err;
+ }
+ }
+ return fullPath;
+ }
+ async readNote(path) {
+ const fullPath = this.resolvePath(path);
+ if (!this.pathFilter.isAllowed(path)) {
+ throw new Error(`Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`);
+ }
+ // Check if the path is a directory first
+ const isDir = await this.isDirectory(path);
+ if (isDir) {
+ throw new Error(`Cannot read directory as file: ${path}. Use list_directory tool instead.`);
+ }
+ try {
+ const content = await readFile(fullPath, 'utf-8');
+ return this.frontmatterHandler.parse(content);
+ }
+ catch (error) {
+ if (error instanceof Error && 'code' in error) {
+ if (error.code === 'ENOENT') {
+ throw new Error(`File not found: ${path}. Use list_directory to see available files, or check the path spelling.`);
+ }
+ if (error.code === 'EACCES') {
+ throw new Error(`Permission denied: ${path}. The file exists but cannot be read due to filesystem permissions.`);
+ }
+ if (error.code === 'EISDIR') {
+ throw new Error(`Cannot read directory as file: ${path}. Use list_directory tool instead.`);
+ }
+ }
+ throw new Error(`Failed to read file: ${path} - ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+ }
+ async writeNote(params) {
+ const { path, content, frontmatter, mode = 'overwrite' } = params;
+ const fullPath = this.resolvePath(path);
+ if (!this.pathFilter.isAllowed(path)) {
+ throw new Error(`Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`);
+ }
+ // Validate content is a defined string to prevent writing literal "undefined"
+ if (content === undefined || content === null) {
+ throw new Error(`Content is required for writing a note: ${path}. The content parameter must be a string.`);
+ }
+ // Validate frontmatter if provided
+ if (frontmatter) {
+ const validation = this.frontmatterHandler.validate(frontmatter);
+ if (!validation.isValid) {
+ throw new Error(`Invalid frontmatter: ${validation.errors.join(', ')}`);
+ }
+ }
+ try {
+ let finalContent;
+ if (mode === 'overwrite') {
+ // Original behavior - replace entire content
+ finalContent = frontmatter
+ ? this.frontmatterHandler.stringify(frontmatter, content)
+ : content;
+ }
+ else {
+ // For append/prepend, we need to read existing content
+ let existingNote;
+ try {
+ existingNote = await this.readNote(path);
+ }
+ catch (error) {
+ // File doesn't exist, treat as overwrite
+ finalContent = frontmatter
+ ? this.frontmatterHandler.stringify(frontmatter, content)
+ : content;
+ }
+ if (existingNote) {
+ // Merge frontmatter if provided
+ const mergedFrontmatter = frontmatter
+ ? { ...existingNote.frontmatter, ...frontmatter }
+ : existingNote.frontmatter;
+ const mergedContent = mode === 'append'
+ ? existingNote.content + content
+ : content + existingNote.content;
+ if (existingNote.matter && existingNote.matter.trim() !== '') {
+ // Preserve raw formatting for unmodified fields by only applying explicit updates
+ finalContent = this.frontmatterHandler.preserveStringify(existingNote.matter, frontmatter || {}, mergedContent);
+ }
+ else {
+ finalContent = this.frontmatterHandler.stringify(mergedFrontmatter, mergedContent);
+ }
+ }
+ }
+ // Create directories if they don't exist
+ await mkdir(dirname(fullPath), { recursive: true });
+ await writeFile(fullPath, finalContent, 'utf-8');
+ }
+ catch (error) {
+ if (error instanceof Error) {
+ if (error.message.includes('permission') || error.message.includes('access')) {
+ throw new Error(`Permission denied: ${path}`);
+ }
+ if (error.message.includes('space') || error.message.includes('ENOSPC')) {
+ throw new Error(`No space left on device: ${path}`);
+ }
+ }
+ throw new Error(`Failed to write file: ${path} - ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+ }
+ async patchNote(params) {
+ const { path, oldString, newString, replaceAll = false } = params;
+ if (!this.pathFilter.isAllowed(path)) {
+ return {
+ success: false,
+ path,
+ message: `Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`
+ };
+ }
+ // Validate that strings are not empty
+ if (!oldString || oldString.trim() === '') {
+ return {
+ success: false,
+ path,
+ message: 'oldString cannot be empty'
+ };
+ }
+ if (newString === undefined || newString === null) {
+ return {
+ success: false,
+ path,
+ message: 'newString is required'
+ };
+ }
+ // Validate that oldString and newString are different
+ if (oldString === newString) {
+ return {
+ success: false,
+ path,
+ message: 'oldString and newString must be different'
+ };
+ }
+ try {
+ // Read the existing note
+ const note = await this.readNote(path);
+ // Get the full content with frontmatter
+ const fullContent = note.originalContent;
+ // Count occurrences of oldString
+ const occurrences = fullContent.split(oldString).length - 1;
+ if (occurrences === 0) {
+ return {
+ success: false,
+ path,
+ message: `String not found in note: "${oldString.substring(0, 50)}${oldString.length > 50 ? '...' : ''}"`,
+ matchCount: 0
+ };
+ }
+ // If not replaceAll and multiple occurrences exist, fail
+ if (!replaceAll && occurrences > 1) {
+ return {
+ success: false,
+ path,
+ message: `Found ${occurrences} occurrences of the string. Use replaceAll=true to replace all occurrences, or provide a more specific string to match exactly one occurrence.`,
+ matchCount: occurrences
+ };
+ }
+ // Perform the replacement
+ const updatedContent = replaceAll
+ ? fullContent.split(oldString).join(newString)
+ : fullContent.replace(oldString, newString);
+ // Write the updated content
+ const fullPath = this.resolvePath(path);
+ await writeFile(fullPath, updatedContent, 'utf-8');
+ return {
+ success: true,
+ path,
+ message: `Successfully replaced ${replaceAll ? occurrences : 1} occurrence${occurrences > 1 ? 's' : ''}`,
+ matchCount: occurrences
+ };
+ }
+ catch (error) {
+ return {
+ success: false,
+ path,
+ message: `Failed to patch note: ${error instanceof Error ? error.message : 'Unknown error'}`
+ };
+ }
+ }
+ async listDirectory(path = '') {
+ // Normalize path: treat '.' as root directory
+ const normalizedPath = path === '.' ? '' : path;
+ const fullPath = this.resolvePath(normalizedPath);
+ try {
+ const entries = await readdir(fullPath, { withFileTypes: true });
+ const files = [];
+ const directories = [];
+ for (const entry of entries) {
+ const entryPath = normalizedPath ? `${normalizedPath}/${entry.name}` : entry.name;
+ if (!this.pathFilter.isAllowedForListing(entryPath)) {
+ continue;
+ }
+ if (entry.isSymbolicLink()) {
+ // Follow symlinks that resolve inside the vault
+ try {
+ const entryFullPath = join(fullPath, entry.name);
+ const realPath = realpathSync(entryFullPath);
+ const realRelative = relative(this.vaultPath, realPath);
+ if (realRelative.startsWith('..')) {
+ continue; // Symlink target outside vault, skip silently
+ }
+ const targetStat = await stat(entryFullPath);
+ if (targetStat.isDirectory()) {
+ directories.push(entry.name);
+ }
+ else if (targetStat.isFile()) {
+ files.push(entry.name);
+ }
+ }
+ catch {
+ continue; // Broken/circular/inaccessible symlink, skip silently
+ }
+ }
+ else if (entry.isDirectory()) {
+ directories.push(entry.name);
+ }
+ else if (entry.isFile()) {
+ files.push(entry.name);
+ }
+ }
+ return {
+ files: files.sort(),
+ directories: directories.sort()
+ };
+ }
+ catch (error) {
+ if (error instanceof Error) {
+ if (error.message.includes('not found') || error.message.includes('ENOENT')) {
+ throw new Error(`Directory not found: ${path}. Use list_directory with no path or '/' to see root folders.`);
+ }
+ if (error.message.includes('permission') || error.message.includes('access')) {
+ throw new Error(`Permission denied: ${path}. The directory exists but cannot be read due to filesystem permissions.`);
+ }
+ if (error.message.includes('not a directory') || error.message.includes('ENOTDIR')) {
+ throw new Error(`Not a directory: ${path}. This path points to a file, not a folder. Use read_note to read files.`);
+ }
+ }
+ throw new Error(`Failed to list directory: ${path} - ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+ }
+ async exists(path) {
+ const fullPath = this.resolvePath(path);
+ if (!this.pathFilter.isAllowed(path)) {
+ return false;
+ }
+ try {
+ await access(fullPath, constants.F_OK);
+ return true;
+ }
+ catch {
+ return false;
+ }
+ }
+ async isDirectory(path) {
+ const fullPath = this.resolvePath(path);
+ if (!this.pathFilter.isAllowed(path)) {
+ return false;
+ }
+ try {
+ const stats = await stat(fullPath);
+ return stats.isDirectory();
+ }
+ catch {
+ return false;
+ }
+ }
+ async deleteNote(params) {
+ const { path, confirmPath, trashMode = 'none' } = params;
+ // Confirmation check - paths must match exactly
+ if (path !== confirmPath) {
+ return {
+ success: false,
+ path: path,
+ message: "Deletion cancelled: confirmation path does not match. For safety, both 'path' and 'confirmPath' must be identical."
+ };
+ }
+ const fullPath = this.resolvePath(path);
+ if (!this.pathFilter.isAllowed(path)) {
+ return {
+ success: false,
+ path: path,
+ message: `Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`
+ };
+ }
+ try {
+ // Check if it's a directory first (can't delete directories with this method)
+ const isDir = await this.isDirectory(path);
+ if (isDir) {
+ return {
+ success: false,
+ path: path,
+ message: `Cannot delete: ${path} is not a file`
+ };
+ }
+ if (trashMode === 'local') {
+ const trashDir = join(this.vaultPath, '.trash');
+ const trashPath = join(trashDir, path);
+ // Ensure trash directory exists
+ await mkdir(dirname(trashPath), { recursive: true });
+ // Handle collisions by appending a timestamp
+ let finalTrashPath = trashPath;
+ try {
+ await access(finalTrashPath, constants.F_OK);
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+ const ext = path.endsWith('.md') ? '.md' : '';
+ const base = ext ? path.slice(0, -ext.length) : path;
+ const collidedPath = `${base}-${timestamp}${ext}`;
+ finalTrashPath = join(trashDir, collidedPath);
+ }
+ catch {
+ // File does not exist in trash, no collision
+ }
+ await rename(fullPath, finalTrashPath);
+ return {
+ success: true,
+ path: path,
+ message: `Successfully moved note to vault trash: ${path}`
+ };
+ }
+ if (trashMode === 'system') {
+ await trash(fullPath);
+ return {
+ success: true,
+ path: path,
+ message: `Successfully moved note to system trash: ${path}`
+ };
+ }
+ // Perform the deletion using Node.js native API
+ await unlink(fullPath);
+ return {
+ success: true,
+ path: path,
+ message: `Successfully deleted note: ${path}. This action cannot be undone.`
+ };
+ }
+ catch (error) {
+ if (error instanceof Error && 'code' in error) {
+ if (error.code === 'ENOENT') {
+ return {
+ success: false,
+ path: path,
+ message: `File not found: ${path}. Use list_directory to see available files.`
+ };
+ }
+ if (error.code === 'EACCES') {
+ return {
+ success: false,
+ path: path,
+ message: `Permission denied: ${path}. The file exists but cannot be deleted due to filesystem permissions.`
+ };
+ }
+ }
+ return {
+ success: false,
+ path: path,
+ message: `Failed to delete file: ${path} - ${error instanceof Error ? error.message : 'Unknown error'}`
+ };
+ }
+ }
+ async moveNote(params) {
+ const { oldPath, newPath, overwrite = false } = params;
+ if (!this.pathFilter.isAllowed(oldPath)) {
+ return {
+ success: false,
+ oldPath,
+ newPath,
+ message: `Access denied: ${oldPath}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`
+ };
+ }
+ if (!this.pathFilter.isAllowed(newPath)) {
+ return {
+ success: false,
+ oldPath,
+ newPath,
+ message: `Access denied: ${newPath}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`
+ };
+ }
+ const oldFullPath = this.resolvePath(oldPath);
+ const newFullPath = this.resolvePath(newPath);
+ try {
+ // Read source content (will throw ENOENT if not found)
+ let content;
+ try {
+ content = await readFile(oldFullPath, 'utf-8');
+ }
+ catch (error) {
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
+ return {
+ success: false,
+ oldPath,
+ newPath,
+ message: `Source file not found: ${oldPath}. Use list_directory to see available files.`
+ };
+ }
+ throw error;
+ }
+ // Create directories if needed
+ await mkdir(dirname(newFullPath), { recursive: true });
+ // Write to new location, checking for existing file atomically if !overwrite
+ try {
+ if (overwrite) {
+ await writeFile(newFullPath, content, 'utf-8');
+ }
+ else {
+ // wx flag: write exclusive - fails if file exists
+ await writeFile(newFullPath, content, { encoding: 'utf-8', flag: 'wx' });
+ }
+ }
+ catch (error) {
+ if (error instanceof Error && 'code' in error && error.code === 'EEXIST') {
+ return {
+ success: false,
+ oldPath,
+ newPath,
+ message: `Target file already exists: ${newPath}. Use overwrite=true to replace it.`
+ };
+ }
+ throw error;
+ }
+ // Delete the source file
+ await unlink(oldFullPath);
+ return {
+ success: true,
+ oldPath,
+ newPath,
+ message: `Successfully moved note from ${oldPath} to ${newPath}`
+ };
+ }
+ catch (error) {
+ return {
+ success: false,
+ oldPath,
+ newPath,
+ message: `Failed to move note: ${error instanceof Error ? error.message : 'Unknown error'}`
+ };
+ }
+ }
+ async moveFile(params) {
+ const { oldPath, newPath, confirmOldPath, confirmNewPath, overwrite = false } = params;
+ if (oldPath !== confirmOldPath || newPath !== confirmNewPath) {
+ return {
+ success: false,
+ oldPath,
+ newPath,
+ message: "Move cancelled: confirmation paths do not match. For safety, oldPath must equal confirmOldPath and newPath must equal confirmNewPath."
+ };
+ }
+ if (!this.pathFilter.isAllowedForListing(oldPath)) {
+ return {
+ success: false,
+ oldPath,
+ newPath,
+ message: `Access denied: ${oldPath}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`
+ };
+ }
+ if (!this.pathFilter.isAllowedForListing(newPath)) {
+ return {
+ success: false,
+ oldPath,
+ newPath,
+ message: `Access denied: ${newPath}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`
+ };
+ }
+ const oldFullPath = this.resolvePath(oldPath);
+ const newFullPath = this.resolvePath(newPath);
+ try {
+ const sourceStat = await stat(oldFullPath);
+ if (sourceStat.isDirectory()) {
+ return {
+ success: false,
+ oldPath,
+ newPath,
+ message: `Source path is a directory: ${oldPath}. move_file currently supports files only.`
+ };
+ }
+ }
+ catch (error) {
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
+ return {
+ success: false,
+ oldPath,
+ newPath,
+ message: `Source file not found: ${oldPath}. Use list_directory to see available files.`
+ };
+ }
+ return {
+ success: false,
+ oldPath,
+ newPath,
+ message: `Failed to inspect source file: ${error instanceof Error ? error.message : 'Unknown error'}`
+ };
+ }
+ try {
+ if (!overwrite) {
+ try {
+ await access(newFullPath, constants.F_OK);
+ return {
+ success: false,
+ oldPath,
+ newPath,
+ message: `Target file already exists: ${newPath}. Use overwrite=true to replace it.`
+ };
+ }
+ catch (error) {
+ if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') {
+ throw error;
+ }
+ }
+ }
+ await mkdir(dirname(newFullPath), { recursive: true });
+ if (overwrite) {
+ try {
+ const targetStat = await stat(newFullPath);
+ if (targetStat.isDirectory()) {
+ return {
+ success: false,
+ oldPath,
+ newPath,
+ message: `Target path is a directory: ${newPath}. Please provide a file path.`
+ };
+ }
+ await unlink(newFullPath);
+ }
+ catch (error) {
+ if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') {
+ throw error;
+ }
+ }
+ }
+ try {
+ await rename(oldFullPath, newFullPath);
+ }
+ catch (error) {
+ if (error instanceof Error && 'code' in error && error.code === 'EXDEV') {
+ await copyFile(oldFullPath, newFullPath);
+ await unlink(oldFullPath);
+ }
+ else {
+ throw error;
+ }
+ }
+ return {
+ success: true,
+ oldPath,
+ newPath,
+ message: `Successfully moved file from ${oldPath} to ${newPath}`
+ };
+ }
+ catch (error) {
+ return {
+ success: false,
+ oldPath,
+ newPath,
+ message: `Failed to move file: ${error instanceof Error ? error.message : 'Unknown error'}`
+ };
+ }
+ }
+ async readMultipleNotes(params) {
+ const { paths, includeContent = true, includeFrontmatter = true } = params;
+ if (paths.length > 10) {
+ throw new Error('Maximum 10 files per batch read request');
+ }
+ const results = await Promise.allSettled(paths.map(async (path) => {
+ if (!this.pathFilter.isAllowed(path)) {
+ throw new Error(`Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`);
+ }
+ const note = await this.readNote(path);
+ const result = {
+ path,
+ obsidianUri: generateObsidianUri(this.vaultPath, path)
+ };
+ if (includeFrontmatter) {
+ result.frontmatter = note.frontmatter;
+ }
+ if (includeContent) {
+ result.content = note.content;
+ }
+ return result;
+ }));
+ const successful = [];
+ const failed = [];
+ results.forEach((result, index) => {
+ if (result.status === 'fulfilled') {
+ successful.push(result.value);
+ }
+ else {
+ failed.push({
+ path: paths[index] || '',
+ error: result.reason instanceof Error ? result.reason.message : 'Unknown error'
+ });
+ }
+ });
+ return { successful, failed };
+ }
+ async updateFrontmatter(params) {
+ const { path, frontmatter, merge = true } = params;
+ if (!this.pathFilter.isAllowed(path)) {
+ throw new Error(`Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`);
+ }
+ // Read the existing note
+ const note = await this.readNote(path);
+ // Prepare new frontmatter
+ const newFrontmatter = merge
+ ? { ...note.frontmatter, ...frontmatter }
+ : frontmatter;
+ // Validate the new frontmatter
+ const validation = this.frontmatterHandler.validate(newFrontmatter);
+ if (!validation.isValid) {
+ throw new Error(`Invalid frontmatter: ${validation.errors.join(', ')}`);
+ }
+ const fullPath = this.resolvePath(path);
+ if (merge && note.matter && note.matter.trim() !== '') {
+ // Preserve raw formatting for unmodified fields
+ const updatedContent = this.frontmatterHandler.preserveStringify(note.matter, frontmatter, note.content);
+ await writeFile(fullPath, updatedContent, 'utf-8');
+ }
+ else {
+ // Replace frontmatter entirely (or no existing matter to preserve)
+ await this.writeNote({
+ path,
+ content: note.content,
+ frontmatter: newFrontmatter
+ });
+ }
+ }
+ async getNotesInfo(paths) {
+ const results = await Promise.allSettled(paths.map(async (path) => {
+ if (!this.pathFilter.isAllowed(path)) {
+ throw new Error(`Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`);
+ }
+ const fullPath = this.resolvePath(path);
+ let stats;
+ try {
+ stats = await stat(fullPath);
+ }
+ catch (error) {
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
+ throw new Error(`File not found: ${path}`);
+ }
+ throw error;
+ }
+ const size = stats.size;
+ const lastModified = stats.mtime.getTime();
+ // Quick check for frontmatter without reading full content
+ const file = await readFile(fullPath, 'utf-8');
+ const firstChunk = file.slice(0, 100);
+ const hasFrontmatter = firstChunk.startsWith('---\n');
+ return {
+ path,
+ size,
+ modified: lastModified,
+ hasFrontmatter,
+ obsidianUri: generateObsidianUri(this.vaultPath, path)
+ };
+ }));
+ // Return only successful results, filter out failed ones
+ return results
+ .filter((result) => result.status === 'fulfilled')
+ .map(result => result.value);
+ }
+ async manageTags(params) {
+ const { path, operation, tags = [] } = params;
+ if (!this.pathFilter.isAllowed(path)) {
+ return {
+ path,
+ operation,
+ tags: [],
+ success: false,
+ message: `Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`
+ };
+ }
+ try {
+ const note = await this.readNote(path);
+ let currentTags = [];
+ // Extract tags from frontmatter
+ if (note.frontmatter.tags) {
+ if (Array.isArray(note.frontmatter.tags)) {
+ currentTags = note.frontmatter.tags;
+ }
+ else if (typeof note.frontmatter.tags === 'string') {
+ currentTags = [note.frontmatter.tags];
+ }
+ }
+ // Also extract inline tags from content
+ const inlineTagMatches = note.content.match(/#[a-zA-Z0-9_-]+/g) || [];
+ const inlineTags = inlineTagMatches.map(tag => tag.slice(1)); // Remove #
+ currentTags = [...new Set([...currentTags, ...inlineTags])]; // Deduplicate
+ if (operation === 'list') {
+ return {
+ path,
+ operation,
+ tags: currentTags,
+ success: true
+ };
+ }
+ let newTags = [...currentTags];
+ if (operation === 'add') {
+ for (const tag of tags) {
+ if (!newTags.includes(tag)) {
+ newTags.push(tag);
+ }
+ }
+ }
+ else if (operation === 'remove') {
+ newTags = newTags.filter(tag => !tags.includes(tag));
+ }
+ // Build tag updates for preserveStringify
+ const tagUpdates = {};
+ if (newTags.length > 0) {
+ tagUpdates.tags = newTags;
+ }
+ else {
+ tagUpdates.tags = undefined;
+ }
+ // Write back the note with updated frontmatter, preserving raw formatting for unmodified fields
+ let updatedContent;
+ if (note.matter && note.matter.trim() !== '') {
+ updatedContent = this.frontmatterHandler.preserveStringify(note.matter, tagUpdates, note.content);
+ }
+ else {
+ const updatedFrontmatter = { ...note.frontmatter };
+ if (newTags.length > 0) {
+ updatedFrontmatter.tags = newTags;
+ }
+ else {
+ delete updatedFrontmatter.tags;
+ }
+ updatedContent = this.frontmatterHandler.stringify(updatedFrontmatter, note.content);
+ }
+ const fullPath = this.resolvePath(path);
+ await writeFile(fullPath, updatedContent, 'utf-8');
+ return {
+ path,
+ operation,
+ tags: newTags,
+ success: true,
+ message: `Successfully ${operation === 'add' ? 'added' : 'removed'} tags`
+ };
+ }
+ catch (error) {
+ return {
+ path,
+ operation,
+ tags: [],
+ success: false,
+ message: error instanceof Error ? error.message : 'Unknown error'
+ };
+ }
+ }
+ getVaultPath() {
+ return this.vaultPath;
+ }
+ async getVaultStats(recentCount = 5) {
+ let totalNotes = 0;
+ let totalFolders = 0;
+ let totalSize = 0;
+ const recentFiles = [];
+ const scanDirectory = async (dirPath, relativePath = '') => {
+ const entries = await readdir(dirPath, { withFileTypes: true });
+ for (const entry of entries) {
+ const entryRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
+ const fullEntryPath = join(dirPath, entry.name);
+ if (entry.isDirectory()) {
+ if (!this.pathFilter.isAllowedForListing(entryRelativePath)) {
+ continue;
+ }
+ totalFolders++;
+ await scanDirectory(fullEntryPath, entryRelativePath);
+ }
+ else if (entry.isFile()) {
+ if (!this.pathFilter.isAllowed(entryRelativePath)) {
+ continue;
+ }
+ totalNotes++;
+ const stats = await stat(fullEntryPath);
+ totalSize += stats.size;
+ // Track recent files
+ const fileInfo = { path: entryRelativePath, modified: stats.mtime.getTime() };
+ // Insert in sorted order (most recent first)
+ const insertIndex = recentFiles.findIndex(f => f.modified < fileInfo.modified);
+ if (insertIndex === -1) {
+ if (recentFiles.length < recentCount) {
+ recentFiles.push(fileInfo);
+ }
+ }
+ else {
+ recentFiles.splice(insertIndex, 0, fileInfo);
+ if (recentFiles.length > recentCount) {
+ recentFiles.pop();
+ }
+ }
+ }
+ }
+ };
+ await scanDirectory(this.vaultPath);
+ return {
+ totalNotes,
+ totalFolders,
+ totalSize,
+ recentlyModified: recentFiles
+ };
+ }
+ async listAllTags() {
+ const tagCounts = new Map();
+ const inlineTagRegex = /(?:^|\s)#([a-zA-Z][a-zA-Z0-9_/\-]*)/g;
+ const scanDirectory = async (dirPath, relativePath = '') => {
+ const entries = await readdir(dirPath, { withFileTypes: true });
+ for (const entry of entries) {
+ const entryRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
+ const fullEntryPath = join(dirPath, entry.name);
+ if (entry.isDirectory()) {
+ if (!this.pathFilter.isAllowedForListing(entryRelativePath))
+ continue;
+ await scanDirectory(fullEntryPath, entryRelativePath);
+ }
+ else if (entry.isFile() && this.pathFilter.isAllowed(entryRelativePath)) {
+ try {
+ const content = await readFile(fullEntryPath, 'utf-8');
+ const parsed = this.frontmatterHandler.parse(content);
+ // Frontmatter tags
+ const fmTags = parsed.frontmatter?.tags;
+ if (Array.isArray(fmTags)) {
+ for (const tag of fmTags) {
+ if (typeof tag === 'string' && tag.trim()) {
+ const normalized = tag.trim().toLowerCase();
+ tagCounts.set(normalized, (tagCounts.get(normalized) || 0) + 1);
+ }
+ }
+ }
+ // Inline #tags from body content
+ let match;
+ while ((match = inlineTagRegex.exec(parsed.content)) !== null) {
+ const normalized = match[1].toLowerCase();
+ tagCounts.set(normalized, (tagCounts.get(normalized) || 0) + 1);
+ }
+ }
+ catch {
+ // Skip files that can't be read
+ }
+ }
+ }
+ };
+ await scanDirectory(this.vaultPath);
+ return Array.from(tagCounts.entries())
+ .map(([tag, count]) => ({ tag, count }))
+ .sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
+ }
+}
diff --git a/dist/src/frontmatter.d.ts b/dist/src/frontmatter.d.ts
new file mode 100644
index 0000000..1a5aeaf
--- /dev/null
+++ b/dist/src/frontmatter.d.ts
@@ -0,0 +1,17 @@
+import type { ParsedNote, FrontmatterValidationResult } from './types.js';
+/**
+ * Parse a frontmatter value that may be a JSON string (LLM clients sometimes
+ * pass frontmatter as a serialized JSON string instead of an object).
+ * Returns undefined if the value is null/undefined, or throws if invalid.
+ */
+export declare function parseFrontmatter(value: any): Record | undefined;
+export declare class FrontmatterHandler {
+ parse(content: string): ParsedNote;
+ stringify(frontmatterData: Record, content: string): string;
+ validate(frontmatterData: Record): FrontmatterValidationResult;
+ private checkForProblematicValues;
+ preserveStringify(rawMatter: string, updates: Record, content: string): string;
+ extractFrontmatter(content: string): Record;
+ updateFrontmatter(content: string, updates: Record): string;
+}
+//# sourceMappingURL=frontmatter.d.ts.map
\ No newline at end of file
diff --git a/dist/src/frontmatter.d.ts.map b/dist/src/frontmatter.d.ts.map
new file mode 100644
index 0000000..2559d42
--- /dev/null
+++ b/dist/src/frontmatter.d.ts.map
@@ -0,0 +1 @@
+{"version":3,"file":"frontmatter.d.ts","sourceRoot":"","sources":["../../src/frontmatter.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,2BAA2B,EAAE,MAAM,YAAY,CAAC;AAE1E;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,GAAG,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,CAmB5E;AAED,qBAAa,kBAAkB;IAC7B,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,UAAU;IAoBlC,SAAS,CAAC,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM;IAaxE,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,2BAA2B;IAqB3E,OAAO,CAAC,yBAAyB;IAmDjC,iBAAiB,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM;IAyB3F,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC;IAKxD,iBAAiB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,MAAM;CAWzE"}
\ No newline at end of file
diff --git a/dist/src/frontmatter.js b/dist/src/frontmatter.js
new file mode 100644
index 0000000..f03e052
--- /dev/null
+++ b/dist/src/frontmatter.js
@@ -0,0 +1,157 @@
+import matter from 'gray-matter';
+import { parseDocument } from 'yaml';
+/**
+ * Parse a frontmatter value that may be a JSON string (LLM clients sometimes
+ * pass frontmatter as a serialized JSON string instead of an object).
+ * Returns undefined if the value is null/undefined, or throws if invalid.
+ */
+export function parseFrontmatter(value) {
+ if (value === undefined || value === null) {
+ return undefined;
+ }
+ if (typeof value === 'object' && !Array.isArray(value)) {
+ return value;
+ }
+ if (typeof value === 'string') {
+ try {
+ const parsed = JSON.parse(value);
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
+ return parsed;
+ }
+ }
+ catch {
+ // not valid JSON
+ }
+ throw new Error('frontmatter must be a JSON object, got a string that is not valid JSON');
+ }
+ throw new Error(`frontmatter must be a JSON object, got ${typeof value}`);
+}
+export class FrontmatterHandler {
+ parse(content) {
+ try {
+ const parsed = matter(content);
+ return {
+ frontmatter: parsed.data,
+ content: parsed.content,
+ originalContent: content,
+ matter: parsed.matter
+ };
+ }
+ catch (error) {
+ // If parsing fails, treat as content without frontmatter
+ return {
+ frontmatter: {},
+ content: content,
+ originalContent: content,
+ matter: ''
+ };
+ }
+ }
+ stringify(frontmatterData, content) {
+ try {
+ // If no frontmatter, return content as-is
+ if (!frontmatterData || Object.keys(frontmatterData).length === 0) {
+ return content;
+ }
+ return matter.stringify(content, frontmatterData);
+ }
+ catch (error) {
+ throw new Error(`Failed to stringify frontmatter: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+ }
+ validate(frontmatterData) {
+ const result = {
+ isValid: true,
+ errors: [],
+ warnings: []
+ };
+ try {
+ // Test if the frontmatter can be serialized to valid YAML using gray-matter
+ matter.stringify('', frontmatterData);
+ }
+ catch (error) {
+ result.isValid = false;
+ result.errors.push(`Invalid YAML structure: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+ // Check for problematic values
+ this.checkForProblematicValues(frontmatterData, result, '');
+ return result;
+ }
+ checkForProblematicValues(obj, result, path) {
+ if (obj === null || obj === undefined) {
+ return;
+ }
+ if (typeof obj === 'function') {
+ result.errors.push(`Functions are not allowed in frontmatter at path: ${path}`);
+ result.isValid = false;
+ return;
+ }
+ if (typeof obj === 'symbol') {
+ result.errors.push(`Symbols are not allowed in frontmatter at path: ${path}`);
+ result.isValid = false;
+ return;
+ }
+ if (obj instanceof Date) {
+ // Dates are fine, but warn if they're invalid
+ if (isNaN(obj.getTime())) {
+ result.warnings.push(`Invalid date at path: ${path}`);
+ }
+ return;
+ }
+ if (Array.isArray(obj)) {
+ obj.forEach((item, index) => {
+ this.checkForProblematicValues(item, result, `${path}[${index}]`);
+ });
+ return;
+ }
+ if (typeof obj === 'object' && obj !== null) {
+ for (const [key, value] of Object.entries(obj)) {
+ const currentPath = path ? `${path}.${key}` : key;
+ // Check for problematic keys
+ if (typeof key !== 'string') {
+ result.errors.push(`Non-string keys are not allowed: ${key}`);
+ result.isValid = false;
+ }
+ this.checkForProblematicValues(value, result, currentPath);
+ }
+ }
+ }
+ preserveStringify(rawMatter, updates, content) {
+ try {
+ if (!rawMatter || rawMatter.trim() === '') {
+ // No existing frontmatter to preserve - fall back to regular stringify
+ if (!updates || Object.keys(updates).length === 0) {
+ return content;
+ }
+ return matter.stringify(content, updates);
+ }
+ const doc = parseDocument(rawMatter.trimStart());
+ for (const [key, value] of Object.entries(updates)) {
+ if (value === undefined) {
+ doc.delete(key);
+ }
+ else {
+ doc.set(key, value);
+ }
+ }
+ const yamlContent = doc.toString();
+ return `---\n${yamlContent}---\n${content}`;
+ }
+ catch (error) {
+ throw new Error(`Failed to stringify frontmatter: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+ }
+ extractFrontmatter(content) {
+ const parsed = this.parse(content);
+ return parsed.frontmatter;
+ }
+ updateFrontmatter(content, updates) {
+ const parsed = this.parse(content);
+ const updatedFrontmatter = { ...parsed.frontmatter, ...updates };
+ const validation = this.validate(updatedFrontmatter);
+ if (!validation.isValid) {
+ throw new Error(`Invalid frontmatter: ${validation.errors.join(', ')}`);
+ }
+ return this.preserveStringify(parsed.matter || '', updates, parsed.content);
+ }
+}
diff --git a/dist/src/index.d.ts b/dist/src/index.d.ts
new file mode 100644
index 0000000..a1aaf0e
--- /dev/null
+++ b/dist/src/index.d.ts
@@ -0,0 +1,8 @@
+export { createServer } from './createServer.js';
+export type { CreateServerOptions } from './createServer.js';
+export { FileSystemService } from './filesystem.js';
+export { FrontmatterHandler, parseFrontmatter } from './frontmatter.js';
+export { PathFilter } from './pathfilter.js';
+export { SearchService } from './search.js';
+export * from './types.js';
+//# sourceMappingURL=index.d.ts.map
\ No newline at end of file
diff --git a/dist/src/index.d.ts.map b/dist/src/index.d.ts.map
new file mode 100644
index 0000000..cb85865
--- /dev/null
+++ b/dist/src/index.d.ts.map
@@ -0,0 +1 @@
+{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACjD,YAAY,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AAC7D,OAAO,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACpD,OAAO,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACxE,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAC7C,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,cAAc,YAAY,CAAC"}
\ No newline at end of file
diff --git a/dist/src/index.js b/dist/src/index.js
new file mode 100644
index 0000000..745458e
--- /dev/null
+++ b/dist/src/index.js
@@ -0,0 +1,6 @@
+export { createServer } from './createServer.js';
+export { FileSystemService } from './filesystem.js';
+export { FrontmatterHandler, parseFrontmatter } from './frontmatter.js';
+export { PathFilter } from './pathfilter.js';
+export { SearchService } from './search.js';
+export * from './types.js';
diff --git a/dist/src/pathfilter.d.ts b/dist/src/pathfilter.d.ts
new file mode 100644
index 0000000..afa8fbb
--- /dev/null
+++ b/dist/src/pathfilter.d.ts
@@ -0,0 +1,13 @@
+import type { PathFilterConfig } from "./types.js";
+export declare class PathFilter {
+ private ignoredPatterns;
+ private allowedExtensions;
+ constructor(config?: Partial);
+ private simpleGlobMatch;
+ isAllowed(path: string): boolean;
+ isAllowedForListing(path: string): boolean;
+ private isIgnoredPath;
+ private isFile;
+ filterPaths(paths: string[]): string[];
+}
+//# sourceMappingURL=pathfilter.d.ts.map
\ No newline at end of file
diff --git a/dist/src/pathfilter.d.ts.map b/dist/src/pathfilter.d.ts.map
new file mode 100644
index 0000000..d85758b
--- /dev/null
+++ b/dist/src/pathfilter.d.ts.map
@@ -0,0 +1 @@
+{"version":3,"file":"pathfilter.d.ts","sourceRoot":"","sources":["../../src/pathfilter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAEnD,qBAAa,UAAU;IACrB,OAAO,CAAC,eAAe,CAAW;IAClC,OAAO,CAAC,iBAAiB,CAAW;gBAExB,MAAM,CAAC,EAAE,OAAO,CAAC,gBAAgB,CAAC;IAuB9C,OAAO,CAAC,eAAe;IAkBvB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAqBhC,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAQ1C,OAAO,CAAC,aAAa;IAYrB,OAAO,CAAC,MAAM;IA0Bd,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE;CAGvC"}
\ No newline at end of file
diff --git a/dist/src/pathfilter.js b/dist/src/pathfilter.js
new file mode 100644
index 0000000..afb40ef
--- /dev/null
+++ b/dist/src/pathfilter.js
@@ -0,0 +1,94 @@
+export class PathFilter {
+ ignoredPatterns;
+ allowedExtensions;
+ constructor(config) {
+ this.ignoredPatterns = [
+ '.obsidian',
+ '.obsidian/**',
+ '.git',
+ '.git/**',
+ 'node_modules',
+ 'node_modules/**',
+ '.DS_Store',
+ 'Thumbs.db',
+ ...config?.ignoredPatterns || []
+ ];
+ this.allowedExtensions = [
+ '.md',
+ '.markdown',
+ '.txt',
+ '.base', // Obsidian Bases (YAML)
+ '.canvas', // Obsidian Canvas (JSON)
+ ...config?.allowedExtensions || []
+ ];
+ }
+ simpleGlobMatch(pattern, path) {
+ // Normalize pattern path separators (Windows compatibility)
+ const normalizedPattern = pattern.replace(/\\/g, '/');
+ // Convert glob pattern to regex, escaping special regex chars first
+ let regexPattern = normalizedPattern
+ .replace(/[\\^$.*+?()[\]{}|]/g, '\\$&') // Escape all regex special chars
+ .replace(/\\\*\\\*/g, '.*') // ** matches any number of directories (unescape)
+ .replace(/\\\*/g, '[^/]*') // * matches anything except / (unescape)
+ .replace(/\\\?/g, '[^/]'); // ? matches single character except / (unescape)
+ // Ensure we match the full path
+ regexPattern = '^' + regexPattern + '$';
+ const regex = new RegExp(regexPattern);
+ return regex.test(path);
+ }
+ isAllowed(path) {
+ // Normalize path separators
+ const normalizedPath = path.replace(/\\/g, '/');
+ if (this.isIgnoredPath(normalizedPath)) {
+ return false;
+ }
+ // For files, check extension if allowedExtensions is configured
+ if (this.allowedExtensions.length > 0 && this.isFile(normalizedPath)) {
+ const hasAllowedExtension = this.allowedExtensions.some(ext => normalizedPath.toLowerCase().endsWith(ext.toLowerCase()));
+ if (!hasAllowedExtension) {
+ return false;
+ }
+ }
+ return true;
+ }
+ isAllowedForListing(path) {
+ // Normalize path separators
+ const normalizedPath = path.replace(/\\/g, '/');
+ // Listing includes non-note files, but still blocks restricted system paths
+ return !this.isIgnoredPath(normalizedPath);
+ }
+ isIgnoredPath(normalizedPath) {
+ // Check if path matches any ignored pattern
+ for (const pattern of this.ignoredPatterns) {
+ if (this.simpleGlobMatch(pattern, normalizedPath)) {
+ return true;
+ }
+ }
+ return false;
+ }
+ isFile(path) {
+ // A path is a file if it has a file extension at the end
+ // Paths ending with '/' are always directories
+ if (path.endsWith('/')) {
+ return false;
+ }
+ // Get the last component of the path
+ const lastSlashIndex = path.lastIndexOf('/');
+ const lastComponent = lastSlashIndex === -1 ? path : path.substring(lastSlashIndex + 1);
+ // Check if the last component has a file extension
+ // A file extension is a dot followed by 1-10 alphanumeric characters at the end
+ // This distinguishes "file.md" (file) from "1. Project" (directory with dot in name)
+ const lastDotIndex = lastComponent.lastIndexOf('.');
+ if (lastDotIndex === -1 || lastDotIndex === 0) {
+ // No dot, or dot at the start (like .gitignore) - treat as no extension
+ return false;
+ }
+ const extension = lastComponent.substring(lastDotIndex + 1);
+ // Extension should be 1-10 characters and contain only alphanumeric characters
+ // This allows .md, .txt, .markdown but not ". Project" (space after dot)
+ return extension.length >= 1 && extension.length <= 10 && /^[a-zA-Z0-9]+$/.test(extension);
+ }
+ filterPaths(paths) {
+ return paths.filter(path => this.isAllowed(path));
+ }
+}
diff --git a/dist/src/search.d.ts b/dist/src/search.d.ts
new file mode 100644
index 0000000..5d4dca1
--- /dev/null
+++ b/dist/src/search.d.ts
@@ -0,0 +1,11 @@
+import type { PathFilter } from './pathfilter.js';
+import type { SearchParams, SearchResult } from './types.js';
+export declare class SearchService {
+ private pathFilter;
+ private vaultPath;
+ constructor(vaultPath: string, pathFilter: PathFilter);
+ search(params: SearchParams): Promise;
+ private findMarkdownFiles;
+ private rerank;
+}
+//# sourceMappingURL=search.d.ts.map
\ No newline at end of file
diff --git a/dist/src/search.d.ts.map b/dist/src/search.d.ts.map
new file mode 100644
index 0000000..bdd504e
--- /dev/null
+++ b/dist/src/search.d.ts.map
@@ -0,0 +1 @@
+{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../src/search.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAClD,OAAO,KAAK,EAAiB,YAAY,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAG5E,qBAAa,aAAa;IAKtB,OAAO,CAAC,UAAU;IAJpB,OAAO,CAAC,SAAS,CAAS;gBAGxB,SAAS,EAAE,MAAM,EACT,UAAU,EAAE,UAAU;IAK1B,MAAM,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC;YA8J7C,iBAAiB;IAwB/B,OAAO,CAAC,MAAM;CA0Bf"}
\ No newline at end of file
diff --git a/dist/src/search.js b/dist/src/search.js
new file mode 100644
index 0000000..e0217fd
--- /dev/null
+++ b/dist/src/search.js
@@ -0,0 +1,182 @@
+import { join, resolve } from 'path';
+import { readFile, readdir } from 'node:fs/promises';
+import { generateObsidianUri } from './uri.js';
+export class SearchService {
+ pathFilter;
+ vaultPath;
+ constructor(vaultPath, pathFilter) {
+ this.pathFilter = pathFilter;
+ this.vaultPath = resolve(vaultPath);
+ }
+ async search(params) {
+ const { query, limit = 5, searchContent = true, searchFrontmatter = false, caseSensitive = false } = params;
+ if (!query || query.trim().length === 0) {
+ throw new Error('Search query cannot be empty');
+ }
+ const maxLimit = Math.min(limit, 20);
+ // Corpus stats for reranking
+ let totalDocLength = 0;
+ let docCount = 0;
+ const termDocFreq = new Map();
+ const candidates = [];
+ const searchQuery = caseSensitive ? query : query.toLowerCase();
+ const terms = searchQuery.split(/\s+/).filter(t => t.length > 0);
+ const scoringTerms = terms.length > 1 ? [...terms, searchQuery] : terms;
+ // Recursively find all .md files
+ const markdownFiles = await this.findMarkdownFiles(this.vaultPath);
+ // Pre-filter by pathFilter before I/O
+ const prefixLen = this.vaultPath.length + 1;
+ const allowedFiles = [];
+ for (const fullPath of markdownFiles) {
+ const relativePath = fullPath.substring(prefixLen).replace(/\\/g, '/');
+ if (this.pathFilter.isAllowed(relativePath)) {
+ allowedFiles.push({ fullPath, relativePath });
+ }
+ }
+ // Read files in parallel batches
+ const BATCH_SIZE = 5;
+ for (let start = 0; start < allowedFiles.length; start += BATCH_SIZE) {
+ const batch = allowedFiles.slice(start, start + BATCH_SIZE);
+ const contents = await Promise.all(batch.map(f => readFile(f.fullPath, 'utf-8').catch(() => null)));
+ for (let i = 0; i < batch.length; i++) {
+ const content = contents[i];
+ if (content === null || content === undefined)
+ continue;
+ const { relativePath } = batch[i];
+ let searchableText = '';
+ // Prepare search text based on options
+ if (searchContent && searchFrontmatter) {
+ searchableText = content;
+ }
+ else if (searchContent) {
+ // Remove frontmatter from search
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n/);
+ searchableText = frontmatterMatch ? content.slice(frontmatterMatch[0].length) : content;
+ }
+ else if (searchFrontmatter) {
+ // Search only frontmatter
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n/);
+ searchableText = frontmatterMatch ? frontmatterMatch[1] || '' : '';
+ }
+ const searchIn = caseSensitive ? searchableText : searchableText.toLowerCase();
+ // Collect corpus stats for reranking
+ const docLength = searchIn.split(/\s+/).filter(w => w.length > 0).length;
+ totalDocLength += docLength;
+ docCount++;
+ for (const term of scoringTerms) {
+ if (searchIn.includes(term)) {
+ termDocFreq.set(term, (termDocFreq.get(term) || 0) + 1);
+ }
+ }
+ // Extract title from filename
+ const title = relativePath.split('/').pop()?.replace(/\.md$/, '') || relativePath;
+ // Check filename match (any term)
+ const filenameToSearch = caseSensitive ? title : title.toLowerCase();
+ const filenameMatch = terms.some(term => filenameToSearch.includes(term));
+ // Check content match (any term)
+ const termIndices = terms.map(term => searchIn.indexOf(term));
+ const anyTermFound = termIndices.some(idx => idx !== -1);
+ const firstIndex = anyTermFound
+ ? Math.min(...termIndices.filter(idx => idx !== -1))
+ : -1;
+ if (firstIndex !== -1 || filenameMatch) {
+ let excerpt;
+ let matchCount = 0;
+ let lineNumber = 0;
+ const termFreqs = new Map();
+ if (firstIndex !== -1) {
+ // Find the term that matched first for excerpt
+ const firstTermIdx = termIndices.indexOf(firstIndex);
+ const firstTerm = terms[firstTermIdx];
+ // Extract excerpt around first content match
+ const excerptStart = Math.max(0, firstIndex - 21);
+ const excerptEnd = Math.min(searchableText.length, firstIndex + firstTerm.length + 21);
+ excerpt = searchableText.slice(excerptStart, excerptEnd).trim();
+ // Add ellipsis if excerpt is truncated
+ if (excerptStart > 0)
+ excerpt = '...' + excerpt;
+ if (excerptEnd < searchableText.length)
+ excerpt = excerpt + '...';
+ // Count total content matches across all terms
+ for (const term of scoringTerms) {
+ let count = 0;
+ let searchIndex = 0;
+ while ((searchIndex = searchIn.indexOf(term, searchIndex)) !== -1) {
+ count++;
+ searchIndex += term.length;
+ }
+ termFreqs.set(term, count);
+ matchCount += count;
+ }
+ // Find line number of first match
+ const lines = searchableText.slice(0, firstIndex).split('\n');
+ lineNumber = lines.length;
+ }
+ else {
+ // Filename-only match: use beginning of content as excerpt
+ excerpt = searchableText.slice(0, 50).trim();
+ if (searchableText.length > 50)
+ excerpt = excerpt + '...';
+ matchCount = 0;
+ lineNumber = 0;
+ }
+ // Add filename match to count
+ if (filenameMatch)
+ matchCount++;
+ candidates.push({
+ result: {
+ p: relativePath,
+ t: title,
+ ex: excerpt,
+ mc: matchCount,
+ ln: lineNumber,
+ uri: generateObsidianUri(this.vaultPath, relativePath)
+ },
+ termFreqs,
+ docLength
+ });
+ }
+ }
+ }
+ const results = this.rerank(candidates, scoringTerms, termDocFreq, docCount, totalDocLength, maxLimit);
+ return results;
+ }
+ async findMarkdownFiles(dirPath) {
+ const markdownFiles = [];
+ try {
+ const entries = await readdir(dirPath, { withFileTypes: true });
+ for (const entry of entries) {
+ const fullPath = join(dirPath, entry.name);
+ if (entry.isDirectory()) {
+ // Recursively search subdirectories
+ const subFiles = await this.findMarkdownFiles(fullPath);
+ markdownFiles.push(...subFiles);
+ }
+ else if (entry.isFile() && entry.name.endsWith('.md')) {
+ markdownFiles.push(fullPath);
+ }
+ }
+ }
+ catch (error) {
+ // Skip directories that can't be read
+ }
+ return markdownFiles;
+ }
+ rerank(candidates, terms, termDocFreq, docCount, totalDocLength, maxLimit) {
+ const avgdl = docCount > 0 ? totalDocLength / docCount : 1;
+ const k1 = 1.2;
+ const b = 0.75;
+ const scored = candidates.map(c => {
+ let score = 0;
+ for (const term of terms) {
+ const tf = c.termFreqs.get(term) || 0;
+ const df = termDocFreq.get(term) || 0;
+ const idf = Math.log(1 + (docCount - df + 0.5) / (df + 0.5));
+ score += idf * (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * c.docLength / avgdl));
+ }
+ return { score, result: c.result };
+ });
+ scored.sort((a, b) => b.score - a.score);
+ return scored.slice(0, maxLimit).map(s => s.result);
+ }
+}
diff --git a/dist/src/types.d.ts b/dist/src/types.d.ts
new file mode 100644
index 0000000..ea374f6
--- /dev/null
+++ b/dist/src/types.d.ts
@@ -0,0 +1,136 @@
+export interface ParsedNote {
+ frontmatter: Record;
+ content: string;
+ originalContent: string;
+ matter?: string;
+}
+export interface NoteWriteParams {
+ path: string;
+ content: string;
+ frontmatter?: Record;
+ mode?: 'overwrite' | 'append' | 'prepend';
+}
+export interface PatchNoteParams {
+ path: string;
+ oldString: string;
+ newString: string;
+ replaceAll?: boolean;
+}
+export interface PatchNoteResult {
+ success: boolean;
+ path: string;
+ message: string;
+ matchCount?: number;
+}
+export interface DeleteNoteParams {
+ path: string;
+ confirmPath: string;
+ trashMode?: 'none' | 'local' | 'system';
+}
+export interface DeleteResult {
+ success: boolean;
+ path: string;
+ message: string;
+}
+export interface DirectoryListing {
+ files: string[];
+ directories: string[];
+}
+export interface FrontmatterValidationResult {
+ isValid: boolean;
+ errors: string[];
+ warnings: string[];
+}
+export interface PathFilterConfig {
+ ignoredPatterns: string[];
+ allowedExtensions: string[];
+}
+export interface SearchParams {
+ query: string;
+ limit?: number;
+ searchContent?: boolean;
+ searchFrontmatter?: boolean;
+ caseSensitive?: boolean;
+}
+export interface SearchResult {
+ p: string;
+ t: string;
+ ex: string;
+ mc: number;
+ ln?: number;
+ uri?: string;
+}
+export interface RankCandidate {
+ result: SearchResult;
+ termFreqs: Map;
+ docLength: number;
+}
+export interface MoveNoteParams {
+ oldPath: string;
+ newPath: string;
+ overwrite?: boolean;
+}
+export interface MoveFileParams {
+ oldPath: string;
+ newPath: string;
+ confirmOldPath: string;
+ confirmNewPath: string;
+ overwrite?: boolean;
+}
+export interface MoveResult {
+ success: boolean;
+ oldPath: string;
+ newPath: string;
+ message: string;
+}
+export interface BatchReadParams {
+ paths: string[];
+ includeContent?: boolean;
+ includeFrontmatter?: boolean;
+}
+export interface BatchReadResult {
+ successful: Array<{
+ path: string;
+ frontmatter?: Record;
+ content?: string;
+ obsidianUri?: string;
+ }>;
+ failed: Array<{
+ path: string;
+ error: string;
+ }>;
+}
+export interface UpdateFrontmatterParams {
+ path: string;
+ frontmatter: Record;
+ merge?: boolean;
+}
+export interface NoteInfo {
+ path: string;
+ size: number;
+ modified: number;
+ hasFrontmatter: boolean;
+ obsidianUri?: string;
+}
+export interface TagManagementParams {
+ path: string;
+ operation: 'add' | 'remove' | 'list';
+ tags?: string[];
+}
+export interface TagManagementResult {
+ path: string;
+ operation: string;
+ tags: string[];
+ success: boolean;
+ message?: string;
+}
+export interface VaultStats {
+ totalNotes: number;
+ totalFolders: number;
+ totalSize: number;
+ recentlyModified: Array<{
+ path: string;
+ modified: number;
+ }>;
+}
+//# sourceMappingURL=types.d.ts.map
\ No newline at end of file
diff --git a/dist/src/types.d.ts.map b/dist/src/types.d.ts.map
new file mode 100644
index 0000000..8b0fffd
--- /dev/null
+++ b/dist/src/types.d.ts.map
@@ -0,0 +1 @@
+{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,UAAU;IACzB,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IACjC,OAAO,EAAE,MAAM,CAAC;IAChB,eAAe,EAAE,MAAM,CAAC;IACxB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IAClC,IAAI,CAAC,EAAE,WAAW,GAAG,QAAQ,GAAG,SAAS,CAAC;CAC3C;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAED,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,GAAG,OAAO,GAAG,QAAQ,CAAC;CACzC;AAED,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,WAAW,EAAE,MAAM,EAAE,CAAC;CACvB;AAED,MAAM,WAAW,2BAA2B;IAC1C,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,MAAM,WAAW,gBAAgB;IAC/B,eAAe,EAAE,MAAM,EAAE,CAAC;IAC1B,iBAAiB,EAAE,MAAM,EAAE,CAAC;CAC7B;AAGD,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED,MAAM,WAAW,YAAY;IAC3B,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,EAAE,EAAE,MAAM,CAAC;IACX,EAAE,EAAE,MAAM,CAAC;IACX,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,YAAY,CAAC;IACrB,SAAS,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B,SAAS,EAAE,MAAM,CAAC;CACnB;AAGD,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,cAAc,EAAE,MAAM,CAAC;IACvB,cAAc,EAAE,MAAM,CAAC;IACvB,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;CACjB;AAGD,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,kBAAkB,CAAC,EAAE,OAAO,CAAC;CAC9B;AAED,MAAM,WAAW,eAAe;IAC9B,UAAU,EAAE,KAAK,CAAC;QAChB,IAAI,EAAE,MAAM,CAAC;QACb,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;QAClC,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB,WAAW,CAAC,EAAE,MAAM,CAAC;KACtB,CAAC,CAAC;IACH,MAAM,EAAE,KAAK,CAAC;QACZ,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,EAAE,MAAM,CAAC;KACf,CAAC,CAAC;CACJ;AAGD,MAAM,WAAW,uBAAuB;IACtC,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IACjC,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAGD,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,OAAO,CAAC;IACxB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAGD,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,KAAK,GAAG,QAAQ,GAAG,MAAM,CAAC;IACrC,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAGD,MAAM,WAAW,UAAU;IACzB,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,gBAAgB,EAAE,KAAK,CAAC;QACtB,IAAI,EAAE,MAAM,CAAC;QACb,QAAQ,EAAE,MAAM,CAAC;KAClB,CAAC,CAAC;CACJ"}
\ No newline at end of file
diff --git a/dist/src/types.js b/dist/src/types.js
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/dist/src/types.js
@@ -0,0 +1 @@
+export {};
diff --git a/dist/src/uri.d.ts b/dist/src/uri.d.ts
new file mode 100644
index 0000000..a97b163
--- /dev/null
+++ b/dist/src/uri.d.ts
@@ -0,0 +1,10 @@
+/**
+ * Generates an Obsidian URI for a given note path.
+ * Uses the absolute path format: obsidian:///absolute/path/to/note
+ *
+ * @param vaultPath - The absolute path to the vault root
+ * @param notePath - The relative path to the note within the vault
+ * @returns A properly encoded Obsidian URI
+ */
+export declare function generateObsidianUri(vaultPath: string, notePath: string): string;
+//# sourceMappingURL=uri.d.ts.map
\ No newline at end of file
diff --git a/dist/src/uri.d.ts.map b/dist/src/uri.d.ts.map
new file mode 100644
index 0000000..e5249a5
--- /dev/null
+++ b/dist/src/uri.d.ts.map
@@ -0,0 +1 @@
+{"version":3,"file":"uri.d.ts","sourceRoot":"","sources":["../../src/uri.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAiB/E"}
\ No newline at end of file
diff --git a/dist/src/uri.js b/dist/src/uri.js
new file mode 100644
index 0000000..eb44818
--- /dev/null
+++ b/dist/src/uri.js
@@ -0,0 +1,22 @@
+/**
+ * Generates an Obsidian URI for a given note path.
+ * Uses the absolute path format: obsidian:///absolute/path/to/note
+ *
+ * @param vaultPath - The absolute path to the vault root
+ * @param notePath - The relative path to the note within the vault
+ * @returns A properly encoded Obsidian URI
+ */
+export function generateObsidianUri(vaultPath, notePath) {
+ // Remove leading slash from notePath if present
+ const cleanPath = notePath.startsWith('/') ? notePath.slice(1) : notePath;
+ // Construct absolute path
+ const absolutePath = `${vaultPath}/${cleanPath}`;
+ // Remove .md extension if present (Obsidian handles this automatically)
+ const pathWithoutExtension = absolutePath.replace(/\.md$/, '');
+ // URI encode the path, but keep slashes as slashes
+ const encodedPath = pathWithoutExtension
+ .split('/')
+ .map(segment => encodeURIComponent(segment))
+ .join('/');
+ return `obsidian:///${encodedPath}`;
+}
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..d98bc61
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,3789 @@
+{
+ "name": "@bitbonsai/mcpvault",
+ "version": "0.11.1",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "@bitbonsai/mcpvault",
+ "version": "0.11.1",
+ "license": "MIT",
+ "dependencies": {
+ "@modelcontextprotocol/sdk": "^1.20.0",
+ "gray-matter": "^4.0.3",
+ "trash": "^10.1.1",
+ "yaml": "^2.8.3"
+ },
+ "bin": {
+ "mcpvault": "dist/server.js"
+ },
+ "devDependencies": {
+ "@types/node": "^25.3.3",
+ "tsx": "^4.20.6",
+ "typescript": "^6.0.2",
+ "vitest": "^4.0.15"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@emnapi/core": {
+ "version": "1.9.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
+ "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.1",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.9.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
+ "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
+ "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
+ "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
+ "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
+ "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
+ "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
+ "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz",
+ "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
+ "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
+ "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
+ "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
+ "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
+ "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
+ "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
+ "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
+ "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
+ "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
+ "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz",
+ "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
+ "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
+ "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
+ "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
+ "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
+ "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
+ "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
+ "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
+ "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
+ "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@hono/node-server": {
+ "version": "1.19.11",
+ "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
+ "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.14.1"
+ },
+ "peerDependencies": {
+ "hono": "^4"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@modelcontextprotocol/sdk": {
+ "version": "1.29.0",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
+ "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@hono/node-server": "^1.19.9",
+ "ajv": "^8.17.1",
+ "ajv-formats": "^3.0.1",
+ "content-type": "^1.0.5",
+ "cors": "^2.8.5",
+ "cross-spawn": "^7.0.5",
+ "eventsource": "^3.0.2",
+ "eventsource-parser": "^3.0.0",
+ "express": "^5.2.1",
+ "express-rate-limit": "^8.2.1",
+ "hono": "^4.11.4",
+ "jose": "^6.1.3",
+ "json-schema-typed": "^8.0.2",
+ "pkce-challenge": "^5.0.0",
+ "raw-body": "^3.0.0",
+ "zod": "^3.25 || ^4.0",
+ "zod-to-json-schema": "^3.25.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@cfworker/json-schema": "^4.1.1",
+ "zod": "^3.25 || ^4.0"
+ },
+ "peerDependenciesMeta": {
+ "@cfworker/json-schema": {
+ "optional": true
+ },
+ "zod": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
+ "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.124.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz",
+ "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz",
+ "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz",
+ "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz",
+ "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz",
+ "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz",
+ "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz",
+ "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz",
+ "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz",
+ "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz",
+ "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz",
+ "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz",
+ "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz",
+ "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz",
+ "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "1.9.2",
+ "@emnapi/runtime": "1.9.2",
+ "@napi-rs/wasm-runtime": "^1.1.3"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz",
+ "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz",
+ "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz",
+ "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@sindresorhus/df": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@sindresorhus/df/-/df-3.1.1.tgz",
+ "integrity": "sha512-SME/vtXaJcnQ/HpeV6P82Egy+jThn11IKfwW8+/XVoRD0rmPHVTeKMtww1oWdVnMykzVPjmrDN9S8NBndPEHCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "execa": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@sindresorhus/merge-streams": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz",
+ "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@stroncium/procfs": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@stroncium/procfs/-/procfs-1.2.1.tgz",
+ "integrity": "sha512-X1Iui3FUNZP18EUvysTHxt+Avu2nlVzyf90YM8OYgP6SGzTzzX/0JgObfO1AQQDzuZtNNz29bVh8h5R97JrjxA==",
+ "license": "CC0-1.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.1",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
+ "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "25.6.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
+ "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.19.0"
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz",
+ "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.4",
+ "@vitest/utils": "4.1.4",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz",
+ "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.4",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz",
+ "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz",
+ "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.4",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz",
+ "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.4",
+ "@vitest/utils": "4.1.4",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz",
+ "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz",
+ "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.4",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+ "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "^3.0.0",
+ "negotiator": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
+ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-formats": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/argparse": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
+ "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
+ "license": "MIT",
+ "dependencies": {
+ "sprintf-js": "~1.0.2"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/body-parser": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
+ "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "^3.1.2",
+ "content-type": "^1.0.5",
+ "debug": "^4.4.3",
+ "http-errors": "^2.0.0",
+ "iconv-lite": "^0.7.0",
+ "on-finished": "^2.4.1",
+ "qs": "^6.14.1",
+ "raw-body": "^3.0.1",
+ "type-is": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/chunkify": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/chunkify/-/chunkify-5.0.0.tgz",
+ "integrity": "sha512-G8dj/3/Gm+1yL4oWSdwIxihZWFlgC4V2zYtIApacI0iPIRKBHlBGOGAiDUBZgrj4H8MBA8g8fPFwnJrWF3wl7Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
+ "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+ "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.6.0"
+ }
+ },
+ "node_modules/cors": {
+ "version": "2.8.5",
+ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
+ "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
+ "license": "MIT",
+ "dependencies": {
+ "object-assign": "^4",
+ "vary": "^1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/end-of-stream": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+ "license": "MIT",
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
+ "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
+ "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.2",
+ "@esbuild/android-arm": "0.27.2",
+ "@esbuild/android-arm64": "0.27.2",
+ "@esbuild/android-x64": "0.27.2",
+ "@esbuild/darwin-arm64": "0.27.2",
+ "@esbuild/darwin-x64": "0.27.2",
+ "@esbuild/freebsd-arm64": "0.27.2",
+ "@esbuild/freebsd-x64": "0.27.2",
+ "@esbuild/linux-arm": "0.27.2",
+ "@esbuild/linux-arm64": "0.27.2",
+ "@esbuild/linux-ia32": "0.27.2",
+ "@esbuild/linux-loong64": "0.27.2",
+ "@esbuild/linux-mips64el": "0.27.2",
+ "@esbuild/linux-ppc64": "0.27.2",
+ "@esbuild/linux-riscv64": "0.27.2",
+ "@esbuild/linux-s390x": "0.27.2",
+ "@esbuild/linux-x64": "0.27.2",
+ "@esbuild/netbsd-arm64": "0.27.2",
+ "@esbuild/netbsd-x64": "0.27.2",
+ "@esbuild/openbsd-arm64": "0.27.2",
+ "@esbuild/openbsd-x64": "0.27.2",
+ "@esbuild/openharmony-arm64": "0.27.2",
+ "@esbuild/sunos-x64": "0.27.2",
+ "@esbuild/win32-arm64": "0.27.2",
+ "@esbuild/win32-ia32": "0.27.2",
+ "@esbuild/win32-x64": "0.27.2"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/esprima": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+ "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
+ "license": "BSD-2-Clause",
+ "bin": {
+ "esparse": "bin/esparse.js",
+ "esvalidate": "bin/esvalidate.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/eventsource": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
+ "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
+ "license": "MIT",
+ "dependencies": {
+ "eventsource-parser": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/eventsource-parser": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
+ "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/execa": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/execa/-/execa-2.1.0.tgz",
+ "integrity": "sha512-Y/URAVapfbYy2Xp/gb6A0E7iR8xeqOCXsuuaoMn7A5PzrXUK84E1gyiEfq0wQd/GHA6GsoHWwhNq8anb0mleIw==",
+ "license": "MIT",
+ "dependencies": {
+ "cross-spawn": "^7.0.0",
+ "get-stream": "^5.0.0",
+ "is-stream": "^2.0.0",
+ "merge-stream": "^2.0.0",
+ "npm-run-path": "^3.0.0",
+ "onetime": "^5.1.0",
+ "p-finally": "^2.0.0",
+ "signal-exit": "^3.0.2",
+ "strip-final-newline": "^2.0.0"
+ },
+ "engines": {
+ "node": "^8.12.0 || >=9.7.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/express": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
+ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "^2.0.0",
+ "body-parser": "^2.2.1",
+ "content-disposition": "^1.0.0",
+ "content-type": "^1.0.5",
+ "cookie": "^0.7.1",
+ "cookie-signature": "^1.2.1",
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "finalhandler": "^2.1.0",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "merge-descriptors": "^2.0.0",
+ "mime-types": "^3.0.0",
+ "on-finished": "^2.4.1",
+ "once": "^1.4.0",
+ "parseurl": "^1.3.3",
+ "proxy-addr": "^2.0.7",
+ "qs": "^6.14.0",
+ "range-parser": "^1.2.1",
+ "router": "^2.2.0",
+ "send": "^1.1.0",
+ "serve-static": "^2.2.0",
+ "statuses": "^2.0.1",
+ "type-is": "^2.0.1",
+ "vary": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express-rate-limit": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.0.tgz",
+ "integrity": "sha512-KJzBawY6fB9FiZGdE/0aftepZ91YlaGIrV8vgblRM3J8X+dHx/aiowJWwkx6LIGyuqGiANsjSwwrbb8mifOJ4Q==",
+ "license": "MIT",
+ "dependencies": {
+ "ip-address": "10.1.0"
+ },
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/express-rate-limit"
+ },
+ "peerDependencies": {
+ "express": ">= 4.11"
+ }
+ },
+ "node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "license": "MIT",
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "license": "MIT"
+ },
+ "node_modules/fast-glob": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-uri": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
+ "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/fastq": {
+ "version": "1.20.1",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
+ "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
+ "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "on-finished": "^2.4.1",
+ "parseurl": "^1.3.3",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+ "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/get-stream": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
+ "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
+ "license": "MIT",
+ "dependencies": {
+ "pump": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/get-tsconfig": {
+ "version": "4.10.1",
+ "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz",
+ "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "resolve-pkg-maps": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/globby": {
+ "version": "14.1.0",
+ "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz",
+ "integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==",
+ "license": "MIT",
+ "dependencies": {
+ "@sindresorhus/merge-streams": "^2.1.0",
+ "fast-glob": "^3.3.3",
+ "ignore": "^7.0.3",
+ "path-type": "^6.0.0",
+ "slash": "^5.1.0",
+ "unicorn-magic": "^0.3.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gray-matter": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
+ "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-yaml": "^3.13.1",
+ "kind-of": "^6.0.2",
+ "section-matter": "^1.0.0",
+ "strip-bom-string": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=6.0"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/hono": {
+ "version": "4.12.7",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz",
+ "integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.9.0"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
+ "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "7.0.5",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
+ "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ip-address": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
+ "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-docker": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz",
+ "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==",
+ "license": "MIT",
+ "bin": {
+ "is-docker": "cli.js"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-inside-container": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz",
+ "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==",
+ "license": "MIT",
+ "dependencies": {
+ "is-docker": "^3.0.0"
+ },
+ "bin": {
+ "is-inside-container": "cli.js"
+ },
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/is-path-inside": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz",
+ "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-promise": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+ "license": "MIT"
+ },
+ "node_modules/is-stream": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
+ "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-wsl": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz",
+ "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==",
+ "license": "MIT",
+ "dependencies": {
+ "is-inside-container": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "license": "ISC"
+ },
+ "node_modules/jose": {
+ "version": "6.1.3",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
+ "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
+ "node_modules/js-yaml": {
+ "version": "3.14.2",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
+ "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^1.0.7",
+ "esprima": "^4.0.0"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "license": "MIT"
+ },
+ "node_modules/json-schema-typed": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
+ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/kind-of": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
+ "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
+ "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+ "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/merge-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
+ "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
+ "license": "MIT"
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/micromatch/node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/mimic-fn": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
+ "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/mount-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/mount-point/-/mount-point-3.0.0.tgz",
+ "integrity": "sha512-jAhfD7ZCG+dbESZjcY1SdFVFqSJkh/yGbdsifHcPkvuLRO5ugK0Ssmd9jdATu29BTd4JiN+vkpMzVvsUgP3SZA==",
+ "license": "MIT",
+ "dependencies": {
+ "@sindresorhus/df": "^1.0.1",
+ "pify": "^2.3.0",
+ "pinkie-promise": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/mount-point/node_modules/@sindresorhus/df": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@sindresorhus/df/-/df-1.0.1.tgz",
+ "integrity": "sha512-1Hyp7NQnD/u4DSxR2DGW78TF9k7R0wZ8ev0BpMAIzA6yTQSHqNb5wTuvtcPYf4FWbVse2rW7RgDsyL8ua2vXHw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/move-file": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/move-file/-/move-file-4.1.0.tgz",
+ "integrity": "sha512-YE06K9XLIvMlqSfoZTl32qvbZLPgL70Za41wS8pEhsSOhy71xz2fn8J07nuz/LEEtPSuUzLUFGAJSx499eKDSw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/negotiator": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+ "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/npm-run-path": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-3.1.0.tgz",
+ "integrity": "sha512-Dbl4A/VfiVGLgQv29URL9xshU8XDY1GeLy+fsaZ1AA8JDSfjvr5P5+pzRbWqRSBxk6/DW7MIh8lTM/PaGnP2kg==",
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/obug": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
+ "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT"
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/onetime": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
+ "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
+ "license": "MIT",
+ "dependencies": {
+ "mimic-fn": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/os-homedir": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz",
+ "integrity": "sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/p-finally": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-2.0.1.tgz",
+ "integrity": "sha512-vpm09aKwq6H9phqRQzecoDpD8TmVyGw70qmWlyq5onxY7tqyTTFVvxMykxQSQKILBSFlbXpypIw2T1Ml7+DDtw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/p-map": {
+ "version": "7.0.4",
+ "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz",
+ "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
+ "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/path-type": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz",
+ "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pify": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/pinkie": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz",
+ "integrity": "sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/pinkie-promise": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz",
+ "integrity": "sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==",
+ "license": "MIT",
+ "dependencies": {
+ "pinkie": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/pkce-challenge": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz",
+ "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.10",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
+ "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/powershell-utils": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.0.tgz",
+ "integrity": "sha512-ZlsFlG7MtSFCoc5xreOvBAozCJ6Pf06opgJjh9ONEv418xpZSAzNjstD36C6+JwOnfSqOW/9uDkqKjezTdxZhw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/pump": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
+ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
+ "license": "MIT",
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
+ "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
+ "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.7.0",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/resolve-pkg-maps": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
+ "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+ "license": "MIT",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz",
+ "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.124.0",
+ "@rolldown/pluginutils": "1.0.0-rc.15"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.0.0-rc.15",
+ "@rolldown/binding-darwin-arm64": "1.0.0-rc.15",
+ "@rolldown/binding-darwin-x64": "1.0.0-rc.15",
+ "@rolldown/binding-freebsd-x64": "1.0.0-rc.15",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15",
+ "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15",
+ "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15",
+ "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15",
+ "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15",
+ "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15",
+ "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15",
+ "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15",
+ "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15",
+ "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15",
+ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15"
+ }
+ },
+ "node_modules/router": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+ "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "is-promise": "^4.0.0",
+ "parseurl": "^1.3.3",
+ "path-to-regexp": "^8.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/section-matter": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
+ "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==",
+ "license": "MIT",
+ "dependencies": {
+ "extend-shallow": "^2.0.1",
+ "kind-of": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.1",
+ "mime-types": "^3.0.2",
+ "ms": "^2.1.3",
+ "on-finished": "^2.4.1",
+ "range-parser": "^1.2.1",
+ "statuses": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/serve-static": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
+ "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "parseurl": "^1.3.3",
+ "send": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
+ "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/signal-exit": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+ "license": "ISC"
+ },
+ "node_modules/slash": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz",
+ "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sprintf-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
+ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/std-env": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz",
+ "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/strip-bom-string": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
+ "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/strip-final-newline": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
+ "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
+ "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.16",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+ "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/trash": {
+ "version": "10.1.1",
+ "resolved": "https://registry.npmjs.org/trash/-/trash-10.1.1.tgz",
+ "integrity": "sha512-L/mu8sfblMwaS+exj1MxpmihlIRwVQyB6ieKuTTmBJG0lXWBPfx3pMGQG8i3NT/S8vvNZrflDUOp+j0o7Cnxzw==",
+ "license": "MIT",
+ "dependencies": {
+ "@stroncium/procfs": "^1.2.1",
+ "chunkify": "^5.0.0",
+ "globby": "^14.1.0",
+ "is-path-inside": "^4.0.0",
+ "move-file": "^4.1.0",
+ "p-map": "^7.0.3",
+ "powershell-utils": "^0.2.0",
+ "wsl-utils": "^0.4.0",
+ "xdg-trashdir": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/tsx": {
+ "version": "4.21.0",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
+ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "~0.27.0",
+ "get-tsconfig": "^4.7.5"
+ },
+ "bin": {
+ "tsx": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
+ "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^1.0.5",
+ "media-typer": "^1.1.0",
+ "mime-types": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
+ "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.19.2",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
+ "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/unicorn-magic": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz",
+ "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/user-home": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/user-home/-/user-home-2.0.0.tgz",
+ "integrity": "sha512-KMWqdlOcjCYdtIJpicDSFBQ8nFwS2i9sslAd6f4+CBGcU4gist2REnr2fxj2YocvJFxSF3ZOHLYLVZnUxv4BZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "os-homedir": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.0.8",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz",
+ "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.32.0",
+ "picomatch": "^4.0.4",
+ "postcss": "^8.5.8",
+ "rolldown": "1.0.0-rc.15",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.1.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz",
+ "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.4",
+ "@vitest/mocker": "4.1.4",
+ "@vitest/pretty-format": "4.1.4",
+ "@vitest/runner": "4.1.4",
+ "@vitest/snapshot": "4.1.4",
+ "@vitest/spy": "4.1.4",
+ "@vitest/utils": "4.1.4",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.4",
+ "@vitest/browser-preview": "4.1.4",
+ "@vitest/browser-webdriverio": "4.1.4",
+ "@vitest/coverage-istanbul": "4.1.4",
+ "@vitest/coverage-v8": "4.1.4",
+ "@vitest/ui": "4.1.4",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ },
+ "node_modules/wsl-utils": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.4.0.tgz",
+ "integrity": "sha512-9YmF+2sFEd+T7TkwlmE337F0IVzfDvDknhtpBQxxXzEOfgPphGlFYpyx0cTuCIFj8/p+sqwBYAeGxOMNSzPPDA==",
+ "license": "MIT",
+ "dependencies": {
+ "is-wsl": "^3.1.0",
+ "powershell-utils": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/wsl-utils/node_modules/powershell-utils": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz",
+ "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/xdg-basedir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz",
+ "integrity": "sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/xdg-trashdir": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/xdg-trashdir/-/xdg-trashdir-3.1.0.tgz",
+ "integrity": "sha512-N1XQngeqMBoj9wM4ZFadVV2MymImeiFfYD+fJrNlcVcOHsJFFQe7n3b+aBoTPwARuq2HQxukfzVpQmAk1gN4sQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@sindresorhus/df": "^3.1.1",
+ "mount-point": "^3.0.0",
+ "user-home": "^2.0.0",
+ "xdg-basedir": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/yaml": {
+ "version": "2.8.3",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
+ "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
+ "license": "ISC",
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/eemeli"
+ }
+ },
+ "node_modules/zod": {
+ "version": "4.3.6",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
+ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/zod-to-json-schema": {
+ "version": "3.25.1",
+ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
+ "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==",
+ "license": "ISC",
+ "peerDependencies": {
+ "zod": "^3.25 || ^4"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
index d2f8d28..863162a 100644
--- a/package.json
+++ b/package.json
@@ -1,55 +1,76 @@
{
- "name": "@mauricio.wolff/mcp-obsidian",
- "version": "0.4.1",
- "description": "Lightweight MCP server for safe Obsidian vault access",
+ "name": "@bitbonsai/mcpvault",
+ "version": "0.11.2",
+ "description": "Universal AI bridge for Obsidian vaults - connect any MCP-compatible assistant",
+ "homepage": "https://mcpvault.org",
"author": "bitbonsai",
"license": "MIT",
"type": "module",
- "main": "server.ts",
+ "packageManager": "npm@10.9.0+sha512.65a9c38a8172948f617a53619762cd77e12b9950fe1f9239debcb8d62c652f2081824b986fee7c0af6c0a7df615becebe4bf56e17ec27214a87aa29d9e038b4b",
+ "main": "dist/src/index.js",
+ "types": "dist/src/index.d.ts",
+ "exports": {
+ ".": {
+ "import": "./dist/src/index.js",
+ "types": "./dist/src/index.d.ts"
+ }
+ },
"bin": {
- "mcp-obsidian": "./server.ts",
- "@mauricio.wolff/mcp-obsidian": "./server.ts"
+ "mcpvault": "dist/server.js"
},
"files": [
- "server.ts",
- "src/**/*",
+ "dist/**/*",
"README.md",
"LICENSE"
],
"scripts": {
- "start": "bun run server.ts",
- "test": "bun test",
- "test:watch": "bun test --watch",
- "prepublishOnly": "bun test",
- "prepack": "bun install",
+ "start": "tsx server.ts",
+ "website": "cd website && bun dev",
+ "build": "tsc --project tsconfig.build.json",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "prepublishOnly": "npm run build && npm test",
+ "prepack": "npm install",
"publish:dry": "npm publish --dry-run",
"publish:beta": "npm publish --tag beta",
"publish:latest": "npm publish"
},
"dependencies": {
- "@modelcontextprotocol/sdk": "^1.0.0",
- "gray-matter": "^4.0.3"
+ "@modelcontextprotocol/sdk": "^1.20.0",
+ "gray-matter": "^4.0.3",
+ "trash": "^10.1.1",
+ "yaml": "^2.8.3"
},
"devDependencies": {
- "@types/bun": "latest"
+ "@types/node": "^25.3.3",
+ "tsx": "^4.20.6",
+ "typescript": "^6.0.2",
+ "vitest": "^4.0.15"
},
"engines": {
- "bun": ">=1.0.0"
+ "node": ">=20.0.0"
},
"repository": {
"type": "git",
- "url": "https://github.com/bitbonsai/mcp-obsidian.git"
+ "url": "git+https://github.com/bitbonsai/mcpvault.git"
},
"keywords": [
"mcp",
"obsidian",
"model-context-protocol",
+ "universal",
+ "ai-bridge",
+ "mcp-server",
"claude",
+ "chatgpt",
"ai",
- "bun",
- "filesystem",
- "frontmatter",
- "yaml"
+ "knowledge-management",
+ "pkm",
+ "vault",
+ "notes",
+ "markdown",
+ "future-proof",
+ "open-standard"
],
"publishConfig": {
"access": "public"
diff --git a/server.ts b/server.ts
index 04d67f8..90ec398 100755
--- a/server.ts
+++ b/server.ts
@@ -1,516 +1,74 @@
-#!/usr/bin/env bun
+#!/usr/bin/env node
-import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
-import {
- CallToolRequestSchema,
- ListToolsRequestSchema,
-} from "@modelcontextprotocol/sdk/types.js";
-import { FileSystemService } from "./src/filesystem.js";
-import { FrontmatterHandler } from "./src/frontmatter.js";
-import { PathFilter } from "./src/pathfilter.js";
-import { SearchService } from "./src/search.js";
-
-const vaultPath = process.argv[2];
-if (!vaultPath) {
- console.error("Usage: bun server.ts /path/to/vault");
- process.exit(1);
+import { createServer } from "./src/createServer.js";
+import { readFileSync } from "fs";
+import { fileURLToPath } from "url";
+import { dirname, join, resolve } from "path";
+
+// Get package.json version
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+const packageJson = JSON.parse(
+ readFileSync(join(__dirname, "../package.json"), "utf-8")
+);
+const VERSION = packageJson.version;
+
+// Handle --version and --help flags
+const cliArgs = process.argv.slice(2);
+const firstArg = cliArgs[0];
+
+if (firstArg === "--version" || firstArg === "-v") {
+ console.log(VERSION);
+ process.exit(0);
}
-// Initialize services
-const pathFilter = new PathFilter();
-const frontmatterHandler = new FrontmatterHandler();
-const fileSystem = new FileSystemService(vaultPath, pathFilter, frontmatterHandler);
-const searchService = new SearchService(vaultPath, pathFilter);
-
-const server = new Server({
- name: "mcp-obsidian",
- version: "0.3.0"
-}, {
- capabilities: {
- tools: {},
- },
-});
-
-server.setRequestHandler(ListToolsRequestSchema, async () => {
- return {
- tools: [
- {
- name: "read_note",
- description: "Read a note from the Obsidian vault",
- inputSchema: {
- type: "object",
- properties: {
- path: {
- type: "string",
- description: "Path to the note relative to vault root"
- }
- },
- required: ["path"]
- }
- },
- {
- name: "write_note",
- description: "Write a note to the Obsidian vault",
- inputSchema: {
- type: "object",
- properties: {
- path: {
- type: "string",
- description: "Path to the note relative to vault root"
- },
- content: {
- type: "string",
- description: "Content of the note"
- },
- frontmatter: {
- type: "object",
- description: "Frontmatter object (optional)"
- },
- mode: {
- type: "string",
- enum: ["overwrite", "append", "prepend"],
- description: "Write mode: 'overwrite' (default), 'append', or 'prepend'",
- default: "overwrite"
- }
- },
- required: ["path", "content"]
- }
- },
- {
- name: "list_directory",
- description: "List files and directories in the vault",
- inputSchema: {
- type: "object",
- properties: {
- path: {
- type: "string",
- description: "Path relative to vault root (default: '/')",
- default: "/"
- }
- }
- }
- },
- {
- name: "delete_note",
- description: "Delete a note from the Obsidian vault (requires confirmation)",
- inputSchema: {
- type: "object",
- properties: {
- path: {
- type: "string",
- description: "Path to the note relative to vault root"
- },
- confirmPath: {
- type: "string",
- description: "Confirmation: must exactly match the path parameter to proceed with deletion"
- }
- },
- required: ["path", "confirmPath"]
- }
- },
- {
- name: "search_notes",
- description: "Search for notes in the vault by content or frontmatter",
- inputSchema: {
- type: "object",
- properties: {
- query: {
- type: "string",
- description: "Search query text"
- },
- limit: {
- type: "number",
- description: "Maximum number of results (default: 5, max: 20)",
- default: 5
- },
- searchContent: {
- type: "boolean",
- description: "Search in note content (default: true)",
- default: true
- },
- searchFrontmatter: {
- type: "boolean",
- description: "Search in frontmatter (default: false)",
- default: false
- },
- caseSensitive: {
- type: "boolean",
- description: "Case sensitive search (default: false)",
- default: false
- }
- },
- required: ["query"]
- }
- },
- {
- name: "move_note",
- description: "Move or rename a note in the vault",
- inputSchema: {
- type: "object",
- properties: {
- oldPath: {
- type: "string",
- description: "Current path of the note"
- },
- newPath: {
- type: "string",
- description: "New path for the note"
- },
- overwrite: {
- type: "boolean",
- description: "Allow overwriting existing file (default: false)",
- default: false
- }
- },
- required: ["oldPath", "newPath"]
- }
- },
- {
- name: "read_multiple_notes",
- description: "Read multiple notes in a batch (max 10 files)",
- inputSchema: {
- type: "object",
- properties: {
- paths: {
- type: "array",
- items: { type: "string" },
- description: "Array of note paths to read",
- maxItems: 10
- },
- includeContent: {
- type: "boolean",
- description: "Include note content (default: true)",
- default: true
- },
- includeFrontmatter: {
- type: "boolean",
- description: "Include frontmatter (default: true)",
- default: true
- }
- },
- required: ["paths"]
- }
- },
- {
- name: "update_frontmatter",
- description: "Update frontmatter of a note without changing content",
- inputSchema: {
- type: "object",
- properties: {
- path: {
- type: "string",
- description: "Path to the note"
- },
- frontmatter: {
- type: "object",
- description: "Frontmatter object to update"
- },
- merge: {
- type: "boolean",
- description: "Merge with existing frontmatter (default: true)",
- default: true
- }
- },
- required: ["path", "frontmatter"]
- }
- },
- {
- name: "get_notes_info",
- description: "Get metadata for notes without reading full content",
- inputSchema: {
- type: "object",
- properties: {
- paths: {
- type: "array",
- items: { type: "string" },
- description: "Array of note paths to get info for"
- }
- },
- required: ["paths"]
- }
- },
- {
- name: "get_frontmatter",
- description: "Extract frontmatter from a note without reading the content",
- inputSchema: {
- type: "object",
- properties: {
- path: {
- type: "string",
- description: "Path to the note relative to vault root"
- }
- },
- required: ["path"]
- }
- },
- {
- name: "manage_tags",
- description: "Add, remove, or list tags in a note",
- inputSchema: {
- type: "object",
- properties: {
- path: {
- type: "string",
- description: "Path to the note relative to vault root"
- },
- operation: {
- type: "string",
- enum: ["add", "remove", "list"],
- description: "Operation to perform: 'add', 'remove', or 'list'"
- },
- tags: {
- type: "array",
- items: { type: "string" },
- description: "Array of tags (required for 'add' and 'remove' operations)"
- }
- },
- required: ["path", "operation"]
- }
- }
- ]
- };
-});
-
-// Helper function to trim path arguments
-function trimPaths(args: any): any {
- const trimmed = { ...args };
+if (firstArg === "--help" || firstArg === "-h") {
+ console.log(`
+mcpvault v${VERSION}
+
+Universal AI bridge for Obsidian vaults - connect any MCP-compatible assistant
+
+Usage:
+ npx @bitbonsai/mcpvault [vault-path]
+
+Arguments:
+ [vault-path] Optional path to your Obsidian vault directory
+ Defaults to current working directory when omitted
+
+Options:
+ --version, -v Show version number
+ --help, -h Show this help message
+ --exclude Exclude a path or glob from the vault (repeatable)
+
+Examples:
+ npx @bitbonsai/mcpvault
+ npx @bitbonsai/mcpvault ~/Documents/MyVault
+ npx @bitbonsai/mcpvault ./Vault
+ npx @bitbonsai/mcpvault /path/to/obsidian/vault
+ npx @bitbonsai/mcpvault "/path/with spaces/Obsidian Vault"
+ npx @bitbonsai/mcpvault ./Vault --exclude Private --exclude "Private/**"
+`);
+ process.exit(0);
+}
- // Trim single path properties
- if (trimmed.path && typeof trimmed.path === 'string') {
- trimmed.path = trimmed.path.trim();
- }
- if (trimmed.oldPath && typeof trimmed.oldPath === 'string') {
- trimmed.oldPath = trimmed.oldPath.trim();
- }
- if (trimmed.newPath && typeof trimmed.newPath === 'string') {
- trimmed.newPath = trimmed.newPath.trim();
- }
- if (trimmed.confirmPath && typeof trimmed.confirmPath === 'string') {
- trimmed.confirmPath = trimmed.confirmPath.trim();
- }
+// Separate --exclude flags from positional args
+const excludePatterns: string[] = [];
+const positionalArgs: string[] = [];
- // Trim path arrays
- if (trimmed.paths && Array.isArray(trimmed.paths)) {
- trimmed.paths = trimmed.paths.map((p: any) =>
- typeof p === 'string' ? p.trim() : p
- );
+for (let i = 0; i < cliArgs.length; i++) {
+ if (cliArgs[i] === '--exclude' && i + 1 < cliArgs.length) {
+ excludePatterns.push(cliArgs[++i]!);
+ } else {
+ positionalArgs.push(cliArgs[i]!);
}
-
- return trimmed;
}
-server.setRequestHandler(CallToolRequestSchema, async (request) => {
- const { name, arguments: args } = request.params;
- const trimmedArgs = trimPaths(args);
-
- try {
- switch (name) {
- case "read_note": {
- const note = await fileSystem.readNote(trimmedArgs.path);
- return {
- content: [
- {
- type: "text",
- text: JSON.stringify({
- path: trimmedArgs.path,
- frontmatter: note.frontmatter,
- content: note.content
- }, null, 2)
- }
- ]
- };
- }
-
- case "write_note": {
- await fileSystem.writeNote({
- path: trimmedArgs.path,
- content: trimmedArgs.content,
- frontmatter: trimmedArgs.frontmatter,
- mode: trimmedArgs.mode || 'overwrite'
- });
- return {
- content: [
- {
- type: "text",
- text: `Successfully wrote note: ${trimmedArgs.path} (mode: ${trimmedArgs.mode || 'overwrite'})`
- }
- ]
- };
- }
-
- case "list_directory": {
- const listing = await fileSystem.listDirectory(trimmedArgs.path || '');
- return {
- content: [
- {
- type: "text",
- text: JSON.stringify({
- path: trimmedArgs.path || '/',
- directories: listing.directories,
- files: listing.files
- }, null, 2)
- }
- ]
- };
- }
-
- case "delete_note": {
- const result = await fileSystem.deleteNote({
- path: trimmedArgs.path,
- confirmPath: trimmedArgs.confirmPath
- });
- return {
- content: [
- {
- type: "text",
- text: JSON.stringify(result, null, 2)
- }
- ],
- isError: !result.success
- };
- }
-
- case "search_notes": {
- const results = await searchService.search({
- query: trimmedArgs.query,
- limit: trimmedArgs.limit,
- searchContent: trimmedArgs.searchContent,
- searchFrontmatter: trimmedArgs.searchFrontmatter,
- caseSensitive: trimmedArgs.caseSensitive
- });
- return {
- content: [
- {
- type: "text",
- text: JSON.stringify({
- query: trimmedArgs.query,
- resultCount: results.length,
- results: results
- }, null, 2)
- }
- ]
- };
- }
-
- case "move_note": {
- const result = await fileSystem.moveNote({
- oldPath: trimmedArgs.oldPath,
- newPath: trimmedArgs.newPath,
- overwrite: trimmedArgs.overwrite
- });
- return {
- content: [
- {
- type: "text",
- text: JSON.stringify(result, null, 2)
- }
- ],
- isError: !result.success
- };
- }
-
- case "read_multiple_notes": {
- const result = await fileSystem.readMultipleNotes({
- paths: trimmedArgs.paths,
- includeContent: trimmedArgs.includeContent,
- includeFrontmatter: trimmedArgs.includeFrontmatter
- });
- return {
- content: [
- {
- type: "text",
- text: JSON.stringify({
- successful: result.successful,
- failed: result.failed,
- summary: {
- successCount: result.successful.length,
- failureCount: result.failed.length
- }
- }, null, 2)
- }
- ]
- };
- }
-
- case "update_frontmatter": {
- await fileSystem.updateFrontmatter({
- path: trimmedArgs.path,
- frontmatter: trimmedArgs.frontmatter,
- merge: trimmedArgs.merge
- });
- return {
- content: [
- {
- type: "text",
- text: `Successfully updated frontmatter for: ${trimmedArgs.path}`
- }
- ]
- };
- }
-
- case "get_notes_info": {
- const result = await fileSystem.getNotesInfo(trimmedArgs.paths);
- return {
- content: [
- {
- type: "text",
- text: JSON.stringify({
- notes: result,
- count: result.length
- }, null, 2)
- }
- ]
- };
- }
-
- case "get_frontmatter": {
- const note = await fileSystem.readNote(trimmedArgs.path);
- return {
- content: [
- {
- type: "text",
- text: JSON.stringify({
- path: trimmedArgs.path,
- frontmatter: note.frontmatter
- }, null, 2)
- }
- ]
- };
- }
-
- case "manage_tags": {
- const result = await fileSystem.manageTags({
- path: trimmedArgs.path,
- operation: trimmedArgs.operation,
- tags: trimmedArgs.tags
- });
- return {
- content: [
- {
- type: "text",
- text: JSON.stringify(result, null, 2)
- }
- ],
- isError: !result.success
- };
- }
-
- default:
- throw new Error(`Unknown tool: ${name}`);
- }
- } catch (error) {
- return {
- content: [
- {
- type: "text",
- text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`
- }
- ],
- isError: true
- };
- }
-});
+// Join trailing args to support vault paths with spaces.
+// When omitted, default to current working directory.
+const vaultPathArg = positionalArgs.join(' ').trim();
+const vaultPath = resolve(vaultPathArg || process.cwd());
+const server = createServer(vaultPath, { version: VERSION, excludePatterns });
const transport = new StdioServerTransport();
-await server.connect(transport);
\ No newline at end of file
+await server.connect(transport);
diff --git a/skills/obsidian/SKILL.md b/skills/obsidian/SKILL.md
new file mode 100644
index 0000000..4d62e85
--- /dev/null
+++ b/skills/obsidian/SKILL.md
@@ -0,0 +1,186 @@
+---
+name: obsidian
+description: >
+ Activate when the user mentions their Obsidian vault, notes, tags,
+ frontmatter, daily notes, backup, or sync. Route operations across MCP,
+ Obsidian CLI/app actions, and git sync with safe defaults.
+metadata:
+ version: "2.0"
+ author: bitbonsai
+---
+
+# Obsidian Skill
+
+## Routing Policy
+
+Use the backend that best matches user intent:
+
+1. **MCP (default for vault data operations)**
+ - Read/write/patch/move/search notes
+ - Frontmatter and tag updates
+ - Metadata and batch note operations
+
+2. **Obsidian CLI/App context (only when app context is needed)**
+ - Open a note in Obsidian from URI
+ - Trigger app/plugin workflows that MCP cannot perform
+
+3. **CLI git (sync/backup workflows)**
+ - Initialize repo, configure remote, commit, pull, push
+ - Periodic or manual vault backup/sync requests
+
+When a request is ambiguous, pick MCP first unless the user explicitly asks for sync/backup/git/app behavior.
+
+## Gotchas
+
+1. **patch_note rejects multi-match by default.** With `replaceAll: false`, if `oldString` appears more than once the call fails and returns `matchCount`. Set `replaceAll: true` only when you mean it, or add surrounding context to make the match unique.
+
+2. **patch_note matches inside frontmatter.** The replacement runs against the full file including the YAML block. A generic string like `title:` will match frontmatter fields. Include enough context to target the right occurrence.
+
+3. **patch_note forbids empty strings.** Both `oldString` and `newString` must be non-empty and non-whitespace. To delete text, use `newString` with a single space or restructure the note with `write_note`.
+
+4. **search_notes returns minified JSON.** Fields are abbreviated: `p` (path), `t` (title), `ex` (excerpt), `mc` (matchCount), `ln` (lineNumber), `uri` (obsidianUri). Hard cap of 20 results regardless of `limit`.
+
+5. **search_notes multi-word queries score terms individually AND as a phrase.** Each term is OR-matched, so a document matching any term appears in results. The full phrase gets an additional scoring boost.
+
+6. **write_note auto-creates directories.** Parent folders are created recursively. In `append`/`prepend` mode, if the note doesn't exist it's created. Frontmatter is merged (new keys override) in append/prepend; replaced entirely in overwrite.
+
+7. **delete_note requires exact path confirmation.** `confirmPath` must be character-identical to `path`. No normalization, no trailing-slash tolerance. Mismatch silently fails with `success: false`.
+
+8. **move_file needs double confirmation.** Both `confirmOldPath` and `confirmNewPath` must exactly match their counterparts. Use `move_note` for markdown renames (text-aware, no confirmation needed); use `move_file` only for binary files or when you need binary-safe moves.
+
+9. **manage_tags reads from two sources but writes to one.** `list` merges frontmatter tags + inline `#hashtags`. `add`/`remove` only modify the frontmatter `tags` array. Inline tags are never touched.
+
+10. **read_multiple_notes never rejects.** Uses `allSettled` internally. Failed files appear in the `err` array; successful ones in `ok`. Always check both. Hard limit of 10 paths per call.
+
+## Error Recovery
+
+| Error | Next step |
+|-------|-----------|
+| patch_note "Found N occurrences" | Add surrounding lines to `oldString` to make it unique, or set `replaceAll: true` |
+| delete_note / move_file confirmation mismatch | Re-read the note path with `read_note` or `list_directory`, then retry with the exact string |
+| search_notes returns 0 results | Try single keywords instead of phrases, toggle `searchFrontmatter`, or broaden with partial terms |
+| read_multiple_notes partial `err` | Verify failed paths with `list_directory`, fix typos or missing extensions, retry only failed ones |
+
+## Git Sync Mode
+
+When the user asks to "sync", "backup", or "store my vault with git", use CLI git with this behavior:
+
+1. Run a **preflight** before changing anything:
+ - `git` available
+ - current directory is a git repo (or prompt to initialize)
+ - `git config user.name` and `git config user.email` are set
+ - at least one remote exists for push/pull sync
+
+2. If preflight is incomplete, ask exactly one targeted question with a recommended default.
+ - Use askuserquestion for decisions that materially change behavior.
+ - Good examples:
+ - "No git repo found. Initialize one in this vault now? (Recommended: Yes)"
+ - "No remote configured. Set up GitHub remote now via gh if available, or provide remote URL? (Recommended: Set up via gh)"
+ - "Local and remote diverged. Try `git pull --rebase` now? (Recommended: Yes)"
+
+3. Safe sync sequence (never force push by default):
+ - `git add -A`
+ - `git commit -m "vault sync: YYYY-MM-DD HH:mm"` (skip commit if no changes)
+ - `git pull --rebase`
+ - `git push`
+
+4. `gh` is optional:
+ - Use `gh` only for remote bootstrapping (create repo / set origin) when requested.
+ - Do not require `gh` for normal sync once remote is configured.
+
+5. Stop on conflicts and report clear next steps.
+ - Do not auto-resolve merge conflicts silently.
+ - Explain what failed and what user should run next.
+
+## Obsidian CLI Mode
+
+When the user asks for app-context operations (active file, open in editor, daily notes with templates, backlinks), use the Obsidian CLI directly via shell commands.
+
+1. Run a **preflight** before first CLI use:
+ - Resolve the CLI binary using the first match from these candidates:
+
+ | Priority | macOS | Linux | Windows |
+ |----------|-------|-------|---------|
+ | 1 | `obsidian` (PATH) | `obsidian` (PATH) | `obsidian.exe` or `Obsidian.com` (PATH) |
+ | 2 | `/Applications/Obsidian.app/Contents/MacOS/obsidian-cli` | — | — |
+ | 3 | `/Applications/Obsidian.app/Contents/MacOS/Obsidian` | — | — |
+
+ > **Obsidian 1.12.7+ installer** bundles a dedicated `obsidian-cli` binary (~10x
+ > faster than the legacy Electron-based CLI: ~25ms vs ~250ms per call). On macOS,
+ > after installing the 1.12.7+ installer, disable then re-enable the CLI in
+ > Settings > General > Advanced to update PATH registration. This replaces the old
+ > `~/.zprofile` PATH entry with a `/usr/local/bin/obsidian` symlink pointing to
+ > `obsidian-cli`.
+ >
+ > On Linux, PATH registration creates a symlink at `/usr/local/bin/obsidian`
+ > (or `~/.local/bin/obsidian` as fallback). On Windows, the installer places an
+ > `Obsidian.com` terminal redirector alongside `Obsidian.exe`.
+ >
+ > **Note:** The priority table and stale PATH check are verified on macOS only.
+ > Linux and Windows may also bundle `obsidian-cli` with the 1.12.7+ installer,
+ > but this has not been confirmed. Contributions welcome via issue or PR.
+
+ - **Stale PATH check (macOS):** If priority 1 resolved `obsidian` on PATH, check
+ whether it points to the fast binary or the slow Electron launcher:
+
+ | Resolved path | Meaning | Action |
+ |---------------|---------|--------|
+ | `/usr/local/bin/obsidian` → `obsidian-cli` | 1.12.7 symlink registration | None — fast binary |
+ | `/Applications/.../MacOS/obsidian` | Old `~/.zprofile` entry (pre-1.12.7 registration or 1.12.7 installer without re-registering) | Check if `obsidian-cli` exists in the bundle |
+
+ If `obsidian` resolves to the MacOS directory (not `/usr/local/bin`) AND
+ `/Applications/Obsidian.app/Contents/MacOS/obsidian-cli` exists, tell the user:
+ _"Obsidian 1.12.7+ is installed but PATH still points to the slower Electron
+ binary. In Obsidian, go to Settings > General > Advanced and disable then
+ re-enable the CLI to update PATH registration."_
+ Continue with whichever priority matched — this is advisory, not blocking.
+
+ - Check Obsidian is running: `pgrep -xiq obsidian` (macOS/Linux) or `tasklist /FI "IMAGENAME eq Obsidian.exe" /NH` (Windows)
+ - If either fails, tell the user and fall back to MCP tools + `obsidian://` URIs
+
+2. Vault targeting: `obsidian vault="VaultName" `. The vault name is the folder basename unless `OBSIDIAN_VAULT_NAME` is set.
+
+3. Key commands:
+ ```bash
+ # Read the currently active file
+ obsidian read
+
+ # Read a specific file
+ obsidian read file="My Note"
+
+ # Open a file in Obsidian
+ obsidian open path="Notes/example.md"
+
+ # Open today's daily note
+ obsidian daily
+
+ # Append to daily note
+ obsidian daily:append content="- [ ] New task"
+
+ # Search (Obsidian's own search, different from MCP's BM25)
+ obsidian search query="meeting notes" limit=10
+
+ # List all tags with frequency
+ obsidian tags sort=count counts
+
+ # Get backlinks for a note
+ obsidian backlinks file="My Note"
+
+ # Find unresolved links
+ obsidian unresolved
+ ```
+
+4. Run `obsidian help` for the full command reference. The CLI evolves with Obsidian releases.
+
+5. **When to use CLI vs MCP:**
+ - MCP for reads/writes/search/tags/frontmatter (sandboxed, validated, works headless)
+ - CLI for active file, daily notes with template expansion, backlinks, open in editor, plugin commands
+ - If unsure, prefer MCP
+
+## Resources
+
+Load these only when needed, not on every invocation.
+
+- [Tool Patterns](resources/tool-patterns.md) - read when you need a tool's response shape, mode details, or the move_note vs move_file decision
+- [Obsidian Conventions](resources/obsidian-conventions.md) - read when creating/writing note content (link syntax, frontmatter fields, daily note format, template variables)
+- [Git Sync](resources/git-sync.md) - read when user asks for backup/sync/store-vault workflows with git/gh
diff --git a/skills/obsidian/resources/git-sync.md b/skills/obsidian/resources/git-sync.md
new file mode 100644
index 0000000..e305986
--- /dev/null
+++ b/skills/obsidian/resources/git-sync.md
@@ -0,0 +1,113 @@
+# Git Sync
+
+Practical playbook for handling user requests like:
+- "sync my vault"
+- "backup my vault"
+- "use git to store my vault"
+
+## Routing Rule
+
+- Use **MCP tools** for note/content operations.
+- Use **CLI git** for sync/backup/versioning operations.
+- Use **Obsidian app/URI actions** only when user needs editor/plugin behavior.
+
+## Preflight Checklist
+
+Run these checks before setup or sync:
+
+1. `git --version`
+2. `git rev-parse --is-inside-work-tree`
+3. `git config user.name`
+4. `git config user.email`
+5. `git remote -v`
+6. `git status --porcelain`
+
+Interpretation:
+- Missing git binary: cannot continue sync.
+- Not a repo: offer `git init`.
+- Missing name/email: ask user to set identity.
+- No remote: sync can commit locally, but cannot push until remote is configured.
+
+## AskUserQuestion Patterns
+
+Use a single targeted question when a decision changes behavior.
+
+1. Repo missing:
+ - "No git repo found in this vault. Initialize one now?"
+ - Recommended default: **Yes**
+
+2. Remote missing:
+ - "No remote is configured. Do you want GitHub auto-setup via `gh`, or provide a remote URL?"
+ - Recommended default: **GitHub auto-setup via gh** (if `gh auth status` passes)
+
+3. Diverged history / rebase needed:
+ - "Local and remote branches diverged. Run `git pull --rebase` now?"
+ - Recommended default: **Yes**
+
+4. Identity missing:
+ - "Git user.name/email are not configured. Configure now for this repo?"
+ - Recommended default: **Yes (repo-local config)**
+
+## Standard Sync Action
+
+Use this order for safe, transparent sync:
+
+1. `git add -A`
+2. `git commit -m "vault sync: YYYY-MM-DD HH:mm"` (skip if nothing to commit)
+3. `git pull --rebase`
+4. `git push`
+
+Safety defaults:
+- Never use `push --force` unless user explicitly requests it.
+- Never use destructive reset commands.
+- If conflicts occur, stop and explain exactly what needs manual resolution.
+
+## Setup Flows
+
+### A) Existing repo + remote (fast path)
+
+- Preflight passes -> run Standard Sync Action.
+
+### B) Not a repo yet
+
+1. `git init`
+2. `git add -A`
+3. `git commit -m "chore: initialize vault repository"`
+4. Configure remote (see C or D)
+5. Run Standard Sync Action
+
+### C) Configure GitHub remote using gh (optional)
+
+Preconditions:
+- `gh --version`
+- `gh auth status` succeeds
+
+Example:
+1. `gh repo create --private --source=. --remote=origin --push`
+2. Set upstream if needed: `git push -u origin `
+
+### D) Configure remote manually (no gh)
+
+1. `git remote add origin `
+2. `git push -u origin `
+
+## User-Facing Success Messages
+
+Keep output practical and clear:
+- "Sync complete: 4 files changed, pushed to origin/main."
+- "Vault already up to date: no local changes to commit."
+- "Local commit created, but push skipped because no remote is configured."
+
+## Automation Recipes
+
+For recurring backups, recommend platform scheduler:
+- macOS: launchd
+- Linux: cron
+- Windows: Task Scheduler
+
+Minimal script logic:
+1. pull with rebase
+2. add/commit if changes
+3. push
+
+Avoid scheduling if frequent merge conflicts are expected (multi-device concurrent edits).
diff --git a/skills/obsidian/resources/obsidian-conventions.md b/skills/obsidian/resources/obsidian-conventions.md
new file mode 100644
index 0000000..2538584
--- /dev/null
+++ b/skills/obsidian/resources/obsidian-conventions.md
@@ -0,0 +1,110 @@
+# Obsidian Conventions
+
+Knowledge about Obsidian's data model and conventions that the MCP server doesn't enforce but agents should follow.
+
+## Vault Structure
+
+A vault is a plain directory of markdown files. No database, no proprietary format.
+
+```
+my-vault/
+ .obsidian/ # App config (plugins, themes, hotkeys), not accessible via MCP
+ Daily Notes/ # Common convention, configurable in app
+ Templates/ # Template files, also configurable
+ Attachments/ # Images, PDFs, often set in app settings
+ Projects/
+ project-a.md
+ README.md
+```
+
+`.obsidian/` is blocked by the MCP server's path sandbox. You cannot read or write app config files.
+
+## Internal Links
+
+Obsidian uses `[[wikilinks]]`, not standard markdown links. When writing or patching note content, prefer wikilink syntax.
+
+| Syntax | Result |
+|--------|--------|
+| `[[Note Name]]` | Link to note |
+| `[[Note Name|Display Text]]` | Link with alias (pipe separates name from display text) |
+| `[[Note Name#Heading]]` | Link to heading |
+| `[[Note Name#^block-id]]` | Link to block |
+| `![[Note Name]]` | Embed (transclude) entire note |
+| `![[image.png]]` | Embed image |
+| `![[Note Name#Heading]]` | Embed specific section |
+
+Standard `[markdown](links)` work but won't participate in Obsidian's graph view, backlinks, or rename refactoring.
+
+## Daily Notes
+
+Common convention: one note per day in a `Daily Notes/` folder. Default filename format: `YYYY-MM-DD` (e.g., `2024-03-15.md`). The folder name and date format are configurable per-vault in `.obsidian/daily-notes.json`.
+
+When creating daily notes via MCP, use the `YYYY-MM-DD.md` format unless the user specifies otherwise.
+
+## Frontmatter
+
+YAML block delimited by `---` at the top of the file. Common standard fields:
+
+```yaml
+---
+title: Note Title
+tags:
+ - project
+ - status/active
+aliases:
+ - alternate name
+date: 2024-03-15
+cssclasses:
+ - custom-class
+---
+```
+
+- `tags`: array of strings, supports nested tags (`parent/child`)
+- `aliases`: alternative names for wikilink resolution
+- `cssclasses`: Obsidian-specific styling
+- `date`, `created`, `modified`: no enforced format, but ISO 8601 is conventional
+
+The MCP server validates frontmatter before writing (no functions, no symbols, string keys only).
+
+## Tags
+
+Two sources, both valid in Obsidian:
+
+1. **Frontmatter tags**: `tags: [foo, bar]` in YAML block
+2. **Inline tags**: `#foo` anywhere in the body text
+
+Nested tags use `/`: `#project/active`, `#status/done`. The MCP `manage_tags` tool merges both sources for `list` but only modifies frontmatter for `add`/`remove`.
+
+## Templates
+
+Obsidian's core Templates plugin uses these variables:
+
+| Variable | Expands to |
+|----------|-----------|
+| `{{title}}` | Note title (filename without extension) |
+| `{{date}}` | Current date (format configurable in settings) |
+| `{{time}}` | Current time (format configurable in settings) |
+| `{{date:FORMAT}}` | Date with custom Moment.js format, e.g. `{{date:YYYY-MM-DD}}` |
+| `{{time:FORMAT}}` | Time with custom format |
+
+**The MCP server does not expand template variables.** If you write `{{date}}` via `write_note`, it stays as literal text. Template expansion only happens when inserting templates through the Obsidian app.
+
+## Obsidian URIs
+
+Format: `obsidian://open?vault=VaultName&file=path/to/note`
+
+The MCP server includes `obsidianUri` in search results and read responses. These URIs only work when the Obsidian desktop app is running. They open the note in the app's editor.
+
+URL encoding rules apply: spaces become `%20`, special characters are percent-encoded.
+
+## Common Folder Patterns
+
+| Pattern | Usage |
+|---------|-------|
+| `Daily Notes/` | One note per day |
+| `Templates/` | Template files for new notes |
+| `Attachments/` or `assets/` | Images, PDFs, other media |
+| `Archive/` | Completed or inactive notes |
+| `Inbox/` | Quick capture, unsorted notes |
+
+These are conventions, not requirements. Every vault is different. Use `list_directory` to discover the actual structure before assuming folder names.
diff --git a/skills/obsidian/resources/tool-patterns.md b/skills/obsidian/resources/tool-patterns.md
new file mode 100644
index 0000000..271e791
--- /dev/null
+++ b/skills/obsidian/resources/tool-patterns.md
@@ -0,0 +1,119 @@
+# Tool Patterns
+
+Per-tool behavioral knowledge beyond what tool descriptions provide.
+
+## read_note
+
+Response includes `content` (full markdown body) and `frontmatter` (parsed YAML object). `prettyPrint: true` indents the JSON response for readability but costs extra tokens.
+
+## write_note
+
+**Modes:**
+- `overwrite` (default): replaces entire file; frontmatter is set to exactly what you pass (or omitted if null)
+- `append`: adds content after existing body; merges frontmatter (new keys override existing)
+- `prepend`: adds content before existing body; same frontmatter merge as append
+
+Auto-creates parent directories recursively. In append/prepend, creates the file if it doesn't exist.
+
+**Frontmatter validation** runs before writing. Functions, symbols, and non-string keys are rejected. Invalid dates produce warnings but don't block the write.
+
+## patch_note
+
+**Response shape:**
+```json
+{ "success": bool, "path": str, "message": str, "matchCount": int }
+```
+
+- Rejects if `oldString === newString`
+
+**Recipe, safe single replacement:** Include the line before and after your target text in `oldString` to guarantee uniqueness.
+
+## search_notes
+
+**Response fields (minified):**
+- `p`: path
+- `t`: title (filename without `.md`)
+- `ex`: excerpt (context around first match, truncated with `...`)
+- `mc`: matchCount (total occurrences across all terms + filename)
+- `ln`: lineNumber (1-based, position of first match)
+- `uri`: Obsidian URI
+
+**Scoring:** BM25 with k1=1.2, b=0.75. Multi-word queries score each term individually plus the full phrase as a bonus term.
+
+**Limits:** Default 5, hard cap 20. `caseSensitive: false` by default (both query and corpus lowercased).
+
+**Frontmatter/content toggle:**
+- `searchContent: true` + `searchFrontmatter: false` (default): strips frontmatter before searching
+- `searchFrontmatter: true`: includes YAML block in searchable text
+- Both false: no results
+
+## delete_note
+
+**Response:** `{ "success": bool, "path": str, "message": str }`
+
+`confirmPath` must be character-identical to `path`. No undo. Files only; directories return "Cannot delete: path is not a file."
+
+## move_note
+
+Text-aware move for markdown files. Reads source as UTF-8, writes to destination with `wx` flag (fails if target exists unless `overwrite: true`), then deletes source. No confirmation parameters needed.
+
+**Response:** `{ "success": bool, "oldPath": str, "newPath": str, "message": str }`
+
+## move_file
+
+Binary-safe move. Requires double confirmation: `confirmOldPath === oldPath` AND `confirmNewPath === newPath`. Rejects directories. Falls back to copy+unlink for cross-filesystem moves.
+
+**When to use which:**
+- Renaming/moving `.md` files → `move_note`
+- Moving images, PDFs, attachments → `move_file`
+
+## read_multiple_notes
+
+**Response:** `{ "ok": [...], "err": [...] }`
+
+Hard limit: 10 paths. Uses `Promise.allSettled`, so it never throws on individual failures. Each `ok` entry has `path`, `obsidianUri`, and optionally `frontmatter`/`content` based on include flags. Each `err` entry has `path` and `error` message.
+
+## manage_tags
+
+**Operations:**
+- `list`: returns merged set of frontmatter `tags` array + inline `#hashtags` (deduplicated)
+- `add`: appends to frontmatter `tags` array only
+- `remove`: removes from frontmatter `tags` array only; if no tags remain, deletes the `tags` field
+
+Inline `#hashtag` occurrences in the note body are never modified.
+
+**Response:** `{ "path": str, "operation": str, "tags": [str], "success": bool }`
+
+## update_frontmatter
+
+- `merge: true` (default): spreads existing frontmatter first, new values override: `{...existing, ...new}`
+- `merge: false`: complete replacement of frontmatter
+
+Content body is always preserved. Validates resulting frontmatter before writing. File must already exist.
+
+## get_vault_stats
+
+Metadata-only. Returns total notes, folders, vault size, and recently modified files. No file content read. Use this for vault overview before batch operations.
+
+## get_notes_info
+
+Metadata-only alternative to reading notes. Returns `path`, `size` (bytes), `modified` (ms timestamp), `hasFrontmatter` (heuristic: checks if file starts with `---\n`), and `obsidianUri`. Failed reads are silently omitted from results.
+
+## list_directory
+
+Returns files and directories. Non-note filenames (images, PDFs) are included. Hidden directories (`.obsidian/`, `.git/`) are filtered out.
+
+## get_frontmatter
+
+Extracts parsed frontmatter without reading body content. Lighter than `read_note` when you only need YAML fields.
+
+## list_all_tags
+
+Scans all notes in the vault for frontmatter `tags` arrays and inline `#hashtags`. Returns deduplicated list sorted by frequency descending.
+
+**Response shape:**
+```json
+[{"tag": "project", "count": 12}, {"tag": "status/active", "count": 5}]
+```
+
+Tags are case-normalized (lowercase). Nested tags like `status/active` are preserved. No parameters required (scans the whole vault). Use this before creating or organizing notes to see what tags already exist.
diff --git a/src/createServer.test.ts b/src/createServer.test.ts
new file mode 100644
index 0000000..9c8f039
--- /dev/null
+++ b/src/createServer.test.ts
@@ -0,0 +1,180 @@
+import { test, expect, beforeEach, afterEach } from "vitest";
+import { createServer } from "./createServer.js";
+import { mkdtemp, rm, mkdir, writeFile } from "fs/promises";
+import { join } from "path";
+import { tmpdir } from "os";
+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
+import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
+
+let testVaultPath: string;
+
+beforeEach(async () => {
+ testVaultPath = await mkdtemp(join(tmpdir(), "mcpvault-test-"));
+});
+
+afterEach(async () => {
+ try {
+ await rm(testVaultPath, { recursive: true });
+ } catch {
+ // Ignore cleanup errors
+ }
+});
+
+test("createServer returns a Server instance", () => {
+ const server = createServer(testVaultPath, { version: "1.0.0" });
+ expect(server).toBeDefined();
+ expect(typeof server.connect).toBe("function");
+});
+
+test("server registers 15 tools", async () => {
+ const server = createServer(testVaultPath, { version: "1.0.0" });
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
+
+ const client = new Client({ name: "test-client", version: "1.0.0" });
+
+ await Promise.all([
+ client.connect(clientTransport),
+ server.connect(serverTransport),
+ ]);
+
+ const result = await client.listTools();
+ expect(result.tools).toHaveLength(15);
+
+ const toolNames = result.tools.map((t) => t.name).sort();
+ expect(toolNames).toEqual([
+ "delete_note",
+ "get_frontmatter",
+ "get_notes_info",
+ "get_vault_stats",
+ "list_all_tags",
+ "list_directory",
+ "manage_tags",
+ "move_file",
+ "move_note",
+ "patch_note",
+ "read_multiple_notes",
+ "read_note",
+ "search_notes",
+ "update_frontmatter",
+ "write_note",
+ ]);
+
+ await client.close();
+ await server.close();
+});
+
+test("server can read and write notes via tools", async () => {
+ const server = createServer(testVaultPath, { version: "1.0.0" });
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
+
+ const client = new Client({ name: "test-client", version: "1.0.0" });
+
+ await Promise.all([
+ client.connect(clientTransport),
+ server.connect(serverTransport),
+ ]);
+
+ // Write a note
+ await client.callTool({ name: "write_note", arguments: { path: "test.md", content: "# Hello World" } });
+
+ // Read it back
+ const result = await client.callTool({ name: "read_note", arguments: { path: "test.md" } });
+ const parsed = JSON.parse((result.content as any)[0].text);
+ expect(parsed.content).toContain("Hello World");
+
+ await client.close();
+ await server.close();
+});
+
+test("custom options are applied", () => {
+ const server = createServer(testVaultPath, {
+ name: "custom-name",
+ version: "2.0.0",
+ });
+ expect(server).toBeDefined();
+});
+
+test("excludePatterns blocks notes in excluded folders", async () => {
+ const server = createServer(testVaultPath, {
+ version: "1.0.0",
+ excludePatterns: ["Private", "Private/**"]
+ });
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
+
+ const client = new Client({ name: "test-client", version: "1.0.0" });
+
+ await Promise.all([
+ client.connect(clientTransport),
+ server.connect(serverTransport),
+ ]);
+
+ // Write directly to disk (bypassing the server) so the file exists
+ const { join } = await import("path");
+ await mkdir(join(testVaultPath, "Private"), { recursive: true });
+ await writeFile(join(testVaultPath, "Private", "secret.md"), "secret content");
+
+ // Server should refuse to read it
+ const result = await client.callTool({ name: "read_note", arguments: { path: "Private/secret.md" } });
+ expect((result as any).isError).toBe(true);
+
+ await client.close();
+ await server.close();
+});
+
+test("excludePatterns hides excluded notes from search_notes", async () => {
+ const server = createServer(testVaultPath, {
+ version: "1.0.0",
+ excludePatterns: ["Private", "Private/**"]
+ });
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
+ const client = new Client({ name: "test-client", version: "1.0.0" });
+
+ await Promise.all([
+ client.connect(clientTransport),
+ server.connect(serverTransport),
+ ]);
+
+ const { join } = await import("path");
+ await mkdir(join(testVaultPath, "Private"), { recursive: true });
+ await writeFile(join(testVaultPath, "Private", "secret.md"), "# Secret\n\ncontains secretkeyword");
+ await writeFile(join(testVaultPath, "public.md"), "# Public\n\ncontains secretkeyword");
+
+ const result = await client.callTool({ name: "search_notes", arguments: { query: "secretkeyword", limit: 10 } });
+ const parsed = JSON.parse((result.content as any)[0].text);
+
+ const paths = parsed.map((r: any) => r.p);
+ expect(paths).not.toContain("Private/secret.md");
+ expect(paths).toContain("public.md");
+
+ await client.close();
+ await server.close();
+});
+
+test("excludePatterns hides excluded dirs from list_directory", async () => {
+ const server = createServer(testVaultPath, {
+ version: "1.0.0",
+ excludePatterns: ["Private", "Private/**"]
+ });
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
+ const client = new Client({ name: "test-client", version: "1.0.0" });
+
+ await Promise.all([
+ client.connect(clientTransport),
+ server.connect(serverTransport),
+ ]);
+
+ const { join } = await import("path");
+ await mkdir(join(testVaultPath, "Private"), { recursive: true });
+ await mkdir(join(testVaultPath, "public"), { recursive: true });
+ await writeFile(join(testVaultPath, "Private", "secret.md"), "secret");
+ await writeFile(join(testVaultPath, "public", "note.md"), "public");
+
+ const result = await client.callTool({ name: "list_directory", arguments: {} });
+ const parsed = JSON.parse((result.content as any)[0].text);
+
+ expect(parsed.dirs).not.toContain("Private");
+ expect(parsed.dirs).toContain("public");
+
+ await client.close();
+ await server.close();
+});
\ No newline at end of file
diff --git a/src/createServer.ts b/src/createServer.ts
new file mode 100644
index 0000000..c39ecaa
--- /dev/null
+++ b/src/createServer.ts
@@ -0,0 +1,437 @@
+import { Server } from "@modelcontextprotocol/sdk/server/index.js";
+import {
+ CallToolRequestSchema,
+ ListToolsRequestSchema,
+} from "@modelcontextprotocol/sdk/types.js";
+import { FileSystemService } from "./filesystem.js";
+import { FrontmatterHandler, parseFrontmatter } from "./frontmatter.js";
+import { PathFilter } from "./pathfilter.js";
+import { SearchService } from "./search.js";
+import { resolve } from "path";
+
+export interface CreateServerOptions {
+ name?: string;
+ version?: string;
+ pathFilter?: PathFilter;
+ excludePatterns?: string[]; // Ignored if pathFilter provided.
+ frontmatterHandler?: FrontmatterHandler;
+}
+
+export function createServer(vaultPath: string, options: CreateServerOptions = {}): Server {
+ const {
+ name = "mcpvault",
+ version = "0.0.0",
+ pathFilter = new PathFilter(options.excludePatterns ? {ignoredPatterns: options.excludePatterns } : undefined),
+ frontmatterHandler = new FrontmatterHandler(),
+ } = options;
+
+ const resolvedVaultPath = resolve(vaultPath);
+ const fileSystem = new FileSystemService(resolvedVaultPath, pathFilter, frontmatterHandler);
+ const searchService = new SearchService(resolvedVaultPath, pathFilter);
+
+ const server = new Server({ name, version }, {
+ capabilities: { tools: {} },
+ });
+
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
+ return {
+ tools: [
+ {
+ name: "read_note",
+ description: "Read a note from the Obsidian vault",
+ inputSchema: {
+ type: "object",
+ properties: {
+ path: { type: "string", description: "Path to the note relative to vault root" },
+ prettyPrint: { type: "boolean", description: "Format JSON response with indentation (default: false)", default: false }
+ },
+ required: ["path"]
+ }
+ },
+ {
+ name: "write_note",
+ description: "Write a note to the Obsidian vault",
+ inputSchema: {
+ type: "object",
+ properties: {
+ path: { type: "string", description: "Path to the note relative to vault root" },
+ content: { type: "string", description: "Content of the note" },
+ frontmatter: { type: "object", description: "Frontmatter object (optional)" },
+ mode: { type: "string", enum: ["overwrite", "append", "prepend"], description: "Write mode: 'overwrite' (default), 'append', or 'prepend'", default: "overwrite" }
+ },
+ required: ["path", "content"]
+ }
+ },
+ {
+ name: "patch_note",
+ description: "Efficiently update part of a note by replacing a specific string. This is more efficient than rewriting the entire note for small changes.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ path: { type: "string", description: "Path to the note relative to vault root" },
+ oldString: { type: "string", description: "The exact string to replace. Must match exactly including whitespace and line breaks." },
+ newString: { type: "string", description: "The new string to insert in place of oldString" },
+ replaceAll: { type: "boolean", description: "If true, replace all occurrences. If false (default), the operation will fail if multiple matches are found to prevent unintended replacements.", default: false }
+ },
+ required: ["path", "oldString", "newString"]
+ }
+ },
+ {
+ name: "list_directory",
+ description: "List files and directories in the vault (includes non-note filenames, while read/write tools remain note-only)",
+ inputSchema: {
+ type: "object",
+ properties: {
+ path: { type: "string", description: "Path relative to vault root (default: '/')", default: "/" },
+ prettyPrint: { type: "boolean", description: "Format JSON response with indentation (default: false)", default: false }
+ }
+ }
+ },
+ {
+ name: "delete_note",
+ description: "Delete a note from the Obsidian vault (requires confirmation). Supports permanent delete, vault trash, or system trash.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ path: { type: "string", description: "Path to the note relative to vault root" },
+ confirmPath: { type: "string", description: "Confirmation: must exactly match the path parameter to proceed with deletion" },
+ trashMode: { type: "string", enum: ["none", "local", "system"], description: "Deletion mode: 'none' = permanent delete (default), 'local' = move to .trash inside vault, 'system' = move to OS trash", default: "none" }
+ },
+ required: ["path", "confirmPath"]
+ }
+ },
+ {
+ name: "search_notes",
+ description: "Search for notes in the vault by content or frontmatter",
+ inputSchema: {
+ type: "object",
+ properties: {
+ query: { type: "string", description: "Search query text" },
+ limit: { type: "number", description: "Maximum number of results (default: 5, max: 20)", default: 5 },
+ searchContent: { type: "boolean", description: "Search in note content (default: true)", default: true },
+ searchFrontmatter: { type: "boolean", description: "Search in frontmatter (default: false)", default: false },
+ caseSensitive: { type: "boolean", description: "Case sensitive search (default: false)", default: false },
+ prettyPrint: { type: "boolean", description: "Format JSON response with indentation (default: false)", default: false }
+ },
+ required: ["query"]
+ }
+ },
+ {
+ name: "move_note",
+ description: "Move or rename a note in the vault",
+ inputSchema: {
+ type: "object",
+ properties: {
+ oldPath: { type: "string", description: "Current path of the note" },
+ newPath: { type: "string", description: "New path for the note" },
+ overwrite: { type: "boolean", description: "Allow overwriting existing file (default: false)", default: false }
+ },
+ required: ["oldPath", "newPath"]
+ }
+ },
+ {
+ name: "move_file",
+ description: "Move or rename any file in the vault (binary-safe, file-only, requires confirmation)",
+ inputSchema: {
+ type: "object",
+ properties: {
+ oldPath: { type: "string", description: "Current path of the file" },
+ newPath: { type: "string", description: "New path for the file" },
+ confirmOldPath: { type: "string", description: "Confirmation: must exactly match oldPath" },
+ confirmNewPath: { type: "string", description: "Confirmation: must exactly match newPath" },
+ overwrite: { type: "boolean", description: "Allow overwriting existing file (default: false)", default: false }
+ },
+ required: ["oldPath", "newPath", "confirmOldPath", "confirmNewPath"]
+ }
+ },
+ {
+ name: "read_multiple_notes",
+ description: "Read multiple notes in a batch (max 10 files)",
+ inputSchema: {
+ type: "object",
+ properties: {
+ paths: { type: "array", items: { type: "string" }, description: "Array of note paths to read", maxItems: 10 },
+ includeContent: { type: "boolean", description: "Include note content (default: true)", default: true },
+ includeFrontmatter: { type: "boolean", description: "Include frontmatter (default: true)", default: true },
+ prettyPrint: { type: "boolean", description: "Format JSON response with indentation (default: false)", default: false }
+ },
+ required: ["paths"]
+ }
+ },
+ {
+ name: "update_frontmatter",
+ description: "Update frontmatter of a note without changing content",
+ inputSchema: {
+ type: "object",
+ properties: {
+ path: { type: "string", description: "Path to the note" },
+ frontmatter: { type: "object", description: "Frontmatter object to update" },
+ merge: { type: "boolean", description: "Merge with existing frontmatter (default: true)", default: true }
+ },
+ required: ["path", "frontmatter"]
+ }
+ },
+ {
+ name: "get_notes_info",
+ description: "Get metadata for notes without reading full content",
+ inputSchema: {
+ type: "object",
+ properties: {
+ paths: { type: "array", items: { type: "string" }, description: "Array of note paths to get info for" },
+ prettyPrint: { type: "boolean", description: "Format JSON response with indentation (default: false)", default: false }
+ },
+ required: ["paths"]
+ }
+ },
+ {
+ name: "get_frontmatter",
+ description: "Extract frontmatter from a note without reading the content",
+ inputSchema: {
+ type: "object",
+ properties: {
+ path: { type: "string", description: "Path to the note relative to vault root" },
+ prettyPrint: { type: "boolean", description: "Format JSON response with indentation (default: false)", default: false }
+ },
+ required: ["path"]
+ }
+ },
+ {
+ name: "manage_tags",
+ description: "Add, remove, or list tags in a note",
+ inputSchema: {
+ type: "object",
+ properties: {
+ path: { type: "string", description: "Path to the note relative to vault root" },
+ operation: { type: "string", enum: ["add", "remove", "list"], description: "Operation to perform: 'add', 'remove', or 'list'" },
+ tags: { type: "array", items: { type: "string" }, description: "Array of tags (required for 'add' and 'remove' operations)" }
+ },
+ required: ["path", "operation"]
+ }
+ },
+ {
+ name: "get_vault_stats",
+ description: "Get vault statistics including total notes, folders, size, and recently modified files. Useful for understanding vault scope before batch operations.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ recentCount: { type: "number", description: "Number of recently modified files to return (default: 5, max: 20)", default: 5 },
+ prettyPrint: { type: "boolean", description: "Format JSON response with indentation (default: false)", default: false }
+ }
+ }
+ },
+ {
+ name: "list_all_tags",
+ description: "List all tags across the vault with occurrence counts. Returns both frontmatter tags and inline #hashtags, deduplicated and sorted by frequency. Useful for discovering existing tags before creating or organizing notes.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ prettyPrint: { type: "boolean", description: "Format JSON response with indentation (default: false)", default: false }
+ }
+ }
+ }
+ ]
+ };
+ });
+
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
+ const { name: toolName, arguments: args } = request.params;
+ const trimmedArgs = trimPaths(args);
+
+ try {
+ switch (toolName) {
+ case "read_note": {
+ const note = await fileSystem.readNote(trimmedArgs.path);
+ const indent = trimmedArgs.prettyPrint ? 2 : undefined;
+ return {
+ content: [{ type: "text", text: JSON.stringify({ fm: note.frontmatter, content: note.content }, null, indent) }]
+ };
+ }
+
+ case "write_note": {
+ const fm = parseFrontmatter(trimmedArgs.frontmatter);
+ await fileSystem.writeNote({
+ path: trimmedArgs.path,
+ content: trimmedArgs.content,
+ ...(fm !== undefined && { frontmatter: fm }),
+ mode: trimmedArgs.mode || 'overwrite'
+ });
+ return {
+ content: [{ type: "text", text: `Successfully wrote note: ${trimmedArgs.path} (mode: ${trimmedArgs.mode || 'overwrite'})` }]
+ };
+ }
+
+ case "patch_note": {
+ const result = await fileSystem.patchNote({
+ path: trimmedArgs.path,
+ oldString: trimmedArgs.oldString,
+ newString: trimmedArgs.newString,
+ replaceAll: trimmedArgs.replaceAll
+ });
+ return {
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
+ isError: !result.success
+ };
+ }
+
+ case "list_directory": {
+ const listing = await fileSystem.listDirectory(trimmedArgs.path || '');
+ const indent = trimmedArgs.prettyPrint ? 2 : undefined;
+ return {
+ content: [{ type: "text", text: JSON.stringify({ dirs: listing.directories, files: listing.files }, null, indent) }]
+ };
+ }
+
+ case "delete_note": {
+ const result = await fileSystem.deleteNote({
+ path: trimmedArgs.path,
+ confirmPath: trimmedArgs.confirmPath,
+ trashMode: trimmedArgs.trashMode
+ });
+ return {
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
+ isError: !result.success
+ };
+ }
+
+ case "search_notes": {
+ const results = await searchService.search({
+ query: trimmedArgs.query,
+ limit: trimmedArgs.limit,
+ searchContent: trimmedArgs.searchContent,
+ searchFrontmatter: trimmedArgs.searchFrontmatter,
+ caseSensitive: trimmedArgs.caseSensitive
+ });
+ const indent = trimmedArgs.prettyPrint ? 2 : undefined;
+ return {
+ content: [{ type: "text", text: JSON.stringify(results, null, indent) }]
+ };
+ }
+
+ case "move_note": {
+ const result = await fileSystem.moveNote({
+ oldPath: trimmedArgs.oldPath,
+ newPath: trimmedArgs.newPath,
+ overwrite: trimmedArgs.overwrite
+ });
+ return {
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
+ isError: !result.success
+ };
+ }
+
+ case "move_file": {
+ const result = await fileSystem.moveFile({
+ oldPath: trimmedArgs.oldPath,
+ newPath: trimmedArgs.newPath,
+ confirmOldPath: trimmedArgs.confirmOldPath,
+ confirmNewPath: trimmedArgs.confirmNewPath,
+ overwrite: trimmedArgs.overwrite
+ });
+ return {
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
+ isError: !result.success
+ };
+ }
+
+ case "read_multiple_notes": {
+ const result = await fileSystem.readMultipleNotes({
+ paths: trimmedArgs.paths,
+ includeContent: trimmedArgs.includeContent,
+ includeFrontmatter: trimmedArgs.includeFrontmatter
+ });
+ const indent = trimmedArgs.prettyPrint ? 2 : undefined;
+ return {
+ content: [{ type: "text", text: JSON.stringify({ ok: result.successful, err: result.failed }, null, indent) }]
+ };
+ }
+
+ case "update_frontmatter": {
+ const fm = parseFrontmatter(trimmedArgs.frontmatter);
+ if (!fm) {
+ throw new Error('frontmatter is required');
+ }
+ await fileSystem.updateFrontmatter({
+ path: trimmedArgs.path,
+ frontmatter: fm,
+ merge: trimmedArgs.merge
+ });
+ return {
+ content: [{ type: "text", text: `Successfully updated frontmatter for: ${trimmedArgs.path}` }]
+ };
+ }
+
+ case "get_notes_info": {
+ const result = await fileSystem.getNotesInfo(trimmedArgs.paths);
+ const indent = trimmedArgs.prettyPrint ? 2 : undefined;
+ return {
+ content: [{ type: "text", text: JSON.stringify(result, null, indent) }]
+ };
+ }
+
+ case "get_frontmatter": {
+ const note = await fileSystem.readNote(trimmedArgs.path);
+ const indent = trimmedArgs.prettyPrint ? 2 : undefined;
+ return {
+ content: [{ type: "text", text: JSON.stringify(note.frontmatter, null, indent) }]
+ };
+ }
+
+ case "manage_tags": {
+ const result = await fileSystem.manageTags({
+ path: trimmedArgs.path,
+ operation: trimmedArgs.operation,
+ tags: trimmedArgs.tags
+ });
+ return {
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
+ isError: !result.success
+ };
+ }
+
+ case "get_vault_stats": {
+ const recentCount = Math.min(trimmedArgs.recentCount || 5, 20);
+ const stats = await fileSystem.getVaultStats(recentCount);
+ const indent = trimmedArgs.prettyPrint ? 2 : undefined;
+ return {
+ content: [{ type: "text", text: JSON.stringify({ notes: stats.totalNotes, folders: stats.totalFolders, size: stats.totalSize, recent: stats.recentlyModified }, null, indent) }]
+ };
+ }
+
+ case "list_all_tags": {
+ const tags = await fileSystem.listAllTags();
+ const indent = trimmedArgs.prettyPrint ? 2 : undefined;
+ return {
+ content: [{ type: "text", text: JSON.stringify(tags, null, indent) }]
+ };
+ }
+
+ default:
+ throw new Error(`Unknown tool: ${toolName}`);
+ }
+ } catch (error) {
+ return {
+ content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}` }],
+ isError: true
+ };
+ }
+ });
+
+ return server;
+}
+
+function trimPaths(args: any): any {
+ const trimmed = { ...args };
+
+ if (trimmed.path && typeof trimmed.path === 'string') trimmed.path = trimmed.path.trim();
+ if (trimmed.oldPath && typeof trimmed.oldPath === 'string') trimmed.oldPath = trimmed.oldPath.trim();
+ if (trimmed.newPath && typeof trimmed.newPath === 'string') trimmed.newPath = trimmed.newPath.trim();
+ if (trimmed.confirmPath && typeof trimmed.confirmPath === 'string') trimmed.confirmPath = trimmed.confirmPath.trim();
+ if (trimmed.confirmOldPath && typeof trimmed.confirmOldPath === 'string') trimmed.confirmOldPath = trimmed.confirmOldPath.trim();
+ if (trimmed.confirmNewPath && typeof trimmed.confirmNewPath === 'string') trimmed.confirmNewPath = trimmed.confirmNewPath.trim();
+
+ if (trimmed.paths && Array.isArray(trimmed.paths)) {
+ trimmed.paths = trimmed.paths.map((p: any) => typeof p === 'string' ? p.trim() : p);
+ }
+
+ return trimmed;
+}
diff --git a/src/filesystem.test.ts b/src/filesystem.test.ts
new file mode 100644
index 0000000..1f6c693
--- /dev/null
+++ b/src/filesystem.test.ts
@@ -0,0 +1,1415 @@
+import { test, expect, beforeEach, afterEach } from "vitest";
+import { FileSystemService } from "./filesystem.js";
+import { PathFilter } from "./pathfilter.js";
+import { writeFile, readFile, mkdir, mkdtemp, rm, symlink } from "fs/promises";
+import { join } from "path";
+import { tmpdir } from "os";
+
+let testVaultPath: string;
+let fileSystem: FileSystemService;
+
+beforeEach(async () => {
+ testVaultPath = await mkdtemp(join(tmpdir(), "mcpvault-test-"));
+ fileSystem = new FileSystemService(testVaultPath);
+});
+
+afterEach(async () => {
+ try {
+ await rm(testVaultPath, { recursive: true });
+ } catch {
+ // Ignore cleanup errors
+ }
+});
+
+// ============================================================================
+// PATCH TESTS
+// ============================================================================
+
+test("patch note with single occurrence", async () => {
+ const testPath = "test-note.md";
+ const content = "# Test Note\n\nThis is the old content.\n\nMore text here.";
+
+ await writeFile(join(testVaultPath, testPath), content);
+
+ const result = await fileSystem.patchNote({
+ path: testPath,
+ oldString: "old content",
+ newString: "new content",
+ replaceAll: false
+ });
+
+ expect(result.success).toBe(true);
+ expect(result.matchCount).toBe(1);
+ expect(result.message).toContain("Successfully replaced 1 occurrence");
+
+ const updatedNote = await fileSystem.readNote(testPath);
+ expect(updatedNote.content).toContain("new content");
+ expect(updatedNote.content).not.toContain("old content");
+});
+
+test("patch note with multiple occurrences requires replaceAll", async () => {
+ const testPath = "test-note.md";
+ const content = "# Test\n\nrepeat word repeat word repeat";
+
+ await writeFile(join(testVaultPath, testPath), content);
+
+ const result = await fileSystem.patchNote({
+ path: testPath,
+ oldString: "repeat",
+ newString: "unique",
+ replaceAll: false
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.matchCount).toBe(3);
+ expect(result.message).toContain("Found 3 occurrences");
+ expect(result.message).toContain("Use replaceAll=true");
+});
+
+test("patch note with replaceAll replaces all occurrences", async () => {
+ const testPath = "test-note.md";
+ const content = "# Test\n\nrepeat word repeat word repeat";
+
+ await writeFile(join(testVaultPath, testPath), content);
+
+ const result = await fileSystem.patchNote({
+ path: testPath,
+ oldString: "repeat",
+ newString: "unique",
+ replaceAll: true
+ });
+
+ expect(result.success).toBe(true);
+ expect(result.matchCount).toBe(3);
+ expect(result.message).toContain("Successfully replaced 3 occurrences");
+
+ const updatedNote = await fileSystem.readNote(testPath);
+ expect(updatedNote.content).not.toContain("repeat");
+ expect(updatedNote.content.match(/unique/g)?.length).toBe(3);
+});
+
+test("patch note fails when string not found", async () => {
+ const testPath = "test-note.md";
+ const content = "# Test Note\n\nSome content here.";
+
+ await writeFile(join(testVaultPath, testPath), content);
+
+ const result = await fileSystem.patchNote({
+ path: testPath,
+ oldString: "non-existent string",
+ newString: "replacement",
+ replaceAll: false
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.matchCount).toBe(0);
+ expect(result.message).toContain("String not found");
+});
+
+test("patch note with multiline replacement", async () => {
+ const testPath = "test-note.md";
+ const content = "# Test\n\n## Section A\nOld content\nOld lines\n\n## Section B\nOther content";
+
+ await writeFile(join(testVaultPath, testPath), content);
+
+ const result = await fileSystem.patchNote({
+ path: testPath,
+ oldString: "## Section A\nOld content\nOld lines",
+ newString: "## Section A\nNew content\nNew improved lines",
+ replaceAll: false
+ });
+
+ expect(result.success).toBe(true);
+ expect(result.matchCount).toBe(1);
+
+ const updatedNote = await fileSystem.readNote(testPath);
+ expect(updatedNote.content).toContain("New content");
+ expect(updatedNote.content).toContain("New improved lines");
+ expect(updatedNote.content).not.toContain("Old content");
+});
+
+test("patch note with frontmatter preserved", async () => {
+ const testPath = "test-note.md";
+ const content = `---
+title: My Note
+tags: [test]
+---
+
+# Content
+
+Old text here.`;
+
+ await writeFile(join(testVaultPath, testPath), content);
+
+ const result = await fileSystem.patchNote({
+ path: testPath,
+ oldString: "Old text here.",
+ newString: "New text here.",
+ replaceAll: false
+ });
+
+ expect(result.success).toBe(true);
+
+ const updatedNote = await fileSystem.readNote(testPath);
+ expect(updatedNote.frontmatter.title).toBe("My Note");
+ expect(updatedNote.frontmatter.tags).toEqual(["test"]);
+ expect(updatedNote.content).toContain("New text here.");
+});
+
+test("patch note fails when oldString equals newString", async () => {
+ const testPath = "test-note.md";
+ const content = "# Test\n\nSome content";
+
+ await writeFile(join(testVaultPath, testPath), content);
+
+ const result = await fileSystem.patchNote({
+ path: testPath,
+ oldString: "same",
+ newString: "same",
+ replaceAll: false
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.message).toContain("must be different");
+});
+
+test("patch note fails for filtered paths", async () => {
+ const testPath = ".obsidian/config.json";
+
+ const result = await fileSystem.patchNote({
+ path: testPath,
+ oldString: "old",
+ newString: "new",
+ replaceAll: false
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.message).toContain("Access denied");
+});
+
+test("patch note fails when file doesn't exist", async () => {
+ const testPath = "non-existent-note.md";
+
+ const result = await fileSystem.patchNote({
+ path: testPath,
+ oldString: "old",
+ newString: "new",
+ replaceAll: false
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.message).toContain("File not found");
+});
+
+test("patch note fails with empty oldString", async () => {
+ const testPath = "test-note.md";
+ const content = "# Test Note\n\nSome content.";
+
+ await writeFile(join(testVaultPath, testPath), content);
+
+ const result = await fileSystem.patchNote({
+ path: testPath,
+ oldString: "",
+ newString: "new",
+ replaceAll: false
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.message).toMatch(/empty|filled|required/i);
+});
+
+test("patch note allows empty newString to delete matched text", async () => {
+ const testPath = "test-note.md";
+ const content = "# Test Note\n\nSome content.";
+
+ await writeFile(join(testVaultPath, testPath), content);
+
+ const result = await fileSystem.patchNote({
+ path: testPath,
+ oldString: "content",
+ newString: "",
+ replaceAll: false
+ });
+
+ expect(result.success).toBe(true);
+ expect(result.matchCount).toBe(1);
+
+ const note = await fileSystem.readNote(testPath);
+ expect(note.content).toBe("# Test Note\n\nSome .");
+});
+
+test("patch note fails with undefined newString", async () => {
+ const testPath = "test-note.md";
+ const content = "# Test Note\n\nSome content.";
+
+ await writeFile(join(testVaultPath, testPath), content);
+
+ const result = await fileSystem.patchNote({
+ path: testPath,
+ oldString: "content",
+ newString: undefined as any,
+ replaceAll: false
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.message).toMatch(/empty|filled|required/i);
+
+ // Verify the note was NOT corrupted
+ const note = await fileSystem.readNote(testPath);
+ expect(note.content).not.toContain("undefined");
+ expect(note.content).toContain("Some content.");
+});
+
+test("patch note fails with null newString", async () => {
+ const testPath = "test-note.md";
+ const content = "# Test Note\n\nSome content.";
+
+ await writeFile(join(testVaultPath, testPath), content);
+
+ const result = await fileSystem.patchNote({
+ path: testPath,
+ oldString: "content",
+ newString: null as any,
+ replaceAll: false
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.message).toMatch(/empty|filled|required/i);
+
+ // Verify the note was NOT corrupted
+ const note = await fileSystem.readNote(testPath);
+ expect(note.content).not.toContain("null");
+ expect(note.content).toContain("Some content.");
+});
+
+test("writeNote rejects undefined content", async () => {
+ const testPath = "test-note.md";
+
+ await expect(fileSystem.writeNote({
+ path: testPath,
+ content: undefined as any
+ })).rejects.toThrow(/Content is required/);
+});
+
+test("writeNote rejects null content", async () => {
+ const testPath = "test-note.md";
+
+ await expect(fileSystem.writeNote({
+ path: testPath,
+ content: null as any
+ })).rejects.toThrow(/Content is required/);
+});
+
+test("writeNote append with undefined content does not corrupt note", async () => {
+ const testPath = "test-note.md";
+ const content = "# Test Note\n\nOriginal content.";
+
+ await writeFile(join(testVaultPath, testPath), content);
+
+ await expect(fileSystem.writeNote({
+ path: testPath,
+ content: undefined as any,
+ mode: 'append'
+ })).rejects.toThrow(/Content is required/);
+
+ // Verify the note was NOT corrupted
+ const note = await fileSystem.readNote(testPath);
+ expect(note.content).not.toContain("undefined");
+ expect(note.content).toContain("Original content.");
+});
+
+test("patch note handles regex special characters literally", async () => {
+ const testPath = "test-note.md";
+ const content = "Price: $10.50 (special)";
+
+ await writeFile(join(testVaultPath, testPath), content);
+
+ const result = await fileSystem.patchNote({
+ path: testPath,
+ oldString: "$10.50",
+ newString: "$15.75",
+ replaceAll: false
+ });
+
+ expect(result.success).toBe(true);
+
+ const updatedNote = await fileSystem.readNote(testPath);
+ expect(updatedNote.content).toContain("$15.75");
+ expect(updatedNote.content).not.toContain("$10.50");
+});
+
+test("patch note works with fenced code blocks", async () => {
+ const testPath = "code-fence-test.md";
+ const content = "# Example\n\n```rust\nfn main() {\n println!(\"hello\");\n}\n```\n";
+
+ await writeFile(join(testVaultPath, testPath), content);
+
+ const result = await fileSystem.patchNote({
+ path: testPath,
+ oldString: "println!(\"hello\");",
+ newString: "println!(\"hello world\");",
+ replaceAll: false
+ });
+
+ expect(result.success).toBe(true);
+
+ const updatedNote = await fileSystem.readNote(testPath);
+ expect(updatedNote.originalContent).toContain("println!(\"hello world\");");
+});
+
+test("patch note works with markdown tables", async () => {
+ const testPath = "table-test.md";
+ const content = "| Tool | Status |\n|---|---|\n| patch_note | flaky |\n";
+
+ await writeFile(join(testVaultPath, testPath), content);
+
+ const result = await fileSystem.patchNote({
+ path: testPath,
+ oldString: "| patch_note | flaky |",
+ newString: "| patch_note | stable |",
+ replaceAll: false
+ });
+
+ expect(result.success).toBe(true);
+
+ const updatedNote = await fileSystem.readNote(testPath);
+ expect(updatedNote.originalContent).toContain("| patch_note | stable |");
+});
+
+test("patch note preserves tabs and spaces", async () => {
+ const testPath = "test-note.md";
+ const content = "Line with\ttabs\n Line with spaces\n\tTabbed line";
+
+ await writeFile(join(testVaultPath, testPath), content);
+
+ const result = await fileSystem.patchNote({
+ path: testPath,
+ oldString: "tabs",
+ newString: "TABS",
+ replaceAll: false
+ });
+
+ expect(result.success).toBe(true);
+
+ const updatedNote = await fileSystem.readNote(testPath);
+ expect(updatedNote.content).toContain("Line with\tTABS");
+ expect(updatedNote.content).toContain("\tTabbed line");
+ expect(updatedNote.content).toContain(" Line with spaces");
+});
+
+test("patch note is case sensitive", async () => {
+ const testPath = "test-note.md";
+ const content = "Hello world, hello again";
+
+ await writeFile(join(testVaultPath, testPath), content);
+
+ const result = await fileSystem.patchNote({
+ path: testPath,
+ oldString: "hello",
+ newString: "hi",
+ replaceAll: false
+ });
+
+ expect(result.success).toBe(true);
+
+ const updatedNote = await fileSystem.readNote(testPath);
+ expect(updatedNote.content).toContain("Hello world");
+ expect(updatedNote.content).toContain("hi again");
+});
+
+test("patch note handles many replacements efficiently", async () => {
+ const testPath = "test-note.md";
+ const lines = Array.from({ length: 100 }, (_, i) => `Line ${i}: replace_me`);
+ const content = lines.join("\n");
+
+ await writeFile(join(testVaultPath, testPath), content);
+
+ const startTime = Date.now();
+ const result = await fileSystem.patchNote({
+ path: testPath,
+ oldString: "replace_me",
+ newString: "replaced",
+ replaceAll: true
+ });
+ const duration = Date.now() - startTime;
+
+ expect(result.success).toBe(true);
+ expect(result.matchCount).toBe(100);
+ expect(duration).toBeLessThan(1000);
+
+ const updatedNote = await fileSystem.readNote(testPath);
+ expect(updatedNote.content).not.toContain("replace_me");
+ expect(updatedNote.content.match(/replaced/g)?.length).toBe(100);
+});
+
+test("patch note works with path containing spaces", async () => {
+ const testPath = "folder name/note with spaces.md";
+ const content = "# Test Note\n\nOld content here.";
+
+ await mkdir(join(testVaultPath, "folder name"), { recursive: true });
+ await writeFile(join(testVaultPath, testPath), content);
+
+ const result = await fileSystem.patchNote({
+ path: testPath,
+ oldString: "Old content",
+ newString: "New content",
+ replaceAll: false
+ });
+
+ expect(result.success).toBe(true);
+
+ const updatedNote = await fileSystem.readNote(testPath);
+ expect(updatedNote.content).toContain("New content");
+});
+
+// ============================================================================
+// DELETE TESTS
+// ============================================================================
+
+test("delete note with correct confirmation", async () => {
+ const testPath = "test-note.md";
+ const content = "# Test Note\n\nThis is a test note to be deleted.";
+
+ await writeFile(join(testVaultPath, testPath), content);
+
+ const result = await fileSystem.deleteNote({
+ path: testPath,
+ confirmPath: testPath
+ });
+
+ expect(result.success).toBe(true);
+ expect(result.path).toBe(testPath);
+ expect(result.message).toContain("Successfully deleted");
+ expect(result.message).toContain("cannot be undone");
+});
+
+test("reject deletion with incorrect confirmation", async () => {
+ const testPath = "test-note.md";
+ const content = "# Test Note\n\nThis note should not be deleted.";
+
+ await writeFile(join(testVaultPath, testPath), content);
+
+ const result = await fileSystem.deleteNote({
+ path: testPath,
+ confirmPath: "wrong-path.md"
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.path).toBe(testPath);
+ expect(result.message).toContain("confirmation path does not match");
+
+ const fileStillExists = await fileSystem.exists(testPath);
+ expect(fileStillExists).toBe(true);
+});
+
+test("handle deletion of non-existent file", async () => {
+ const testPath = "non-existent.md";
+
+ const result = await fileSystem.deleteNote({
+ path: testPath,
+ confirmPath: testPath
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.path).toBe(testPath);
+ expect(result.message).toContain("File not found");
+});
+
+test("reject deletion of filtered paths", async () => {
+ const testPath = ".obsidian/app.json";
+
+ const result = await fileSystem.deleteNote({
+ path: testPath,
+ confirmPath: testPath
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.path).toBe(testPath);
+ expect(result.message).toContain("Access denied");
+});
+
+test("handle directory deletion attempt", async () => {
+ const testPath = "test-directory";
+
+ await mkdir(join(testVaultPath, testPath));
+
+ const result = await fileSystem.deleteNote({
+ path: testPath,
+ confirmPath: testPath
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.path).toBe(testPath);
+ expect(result.message).toContain("is not a file");
+});
+
+test("delete note with local trash mode", async () => {
+ const testPath = "trash-test.md";
+ const content = "# Trash Test\n\nThis note should be moved to vault trash.";
+
+ await writeFile(join(testVaultPath, testPath), content);
+
+ const result = await fileSystem.deleteNote({
+ path: testPath,
+ confirmPath: testPath,
+ trashMode: 'local'
+ });
+
+ expect(result.success).toBe(true);
+ expect(result.message).toContain("vault trash");
+
+ const originalExists = await fileSystem.exists(testPath);
+ expect(originalExists).toBe(false);
+
+ const trashedExists = await fileSystem.exists(".trash/trash-test.md");
+ expect(trashedExists).toBe(true);
+});
+
+test("delete note with system trash mode", async () => {
+ const testPath = "system-trash-test.md";
+ const content = "# System Trash Test\n\nThis note should be moved to system trash.";
+
+ await writeFile(join(testVaultPath, testPath), content);
+
+ const result = await fileSystem.deleteNote({
+ path: testPath,
+ confirmPath: testPath,
+ trashMode: 'system'
+ });
+
+ expect(result.success).toBe(true);
+ expect(result.message).toContain("system trash");
+
+ const originalExists = await fileSystem.exists(testPath);
+ expect(originalExists).toBe(false);
+});
+
+test("delete note with frontmatter", async () => {
+ const testPath = "note-with-frontmatter.md";
+ const content = `---
+title: Test Note
+tags: [test, delete]
+---
+
+# Test Note
+
+This note has frontmatter and should be deleted successfully.`;
+
+ await writeFile(join(testVaultPath, testPath), content);
+
+ const result = await fileSystem.deleteNote({
+ path: testPath,
+ confirmPath: testPath
+ });
+
+ expect(result.success).toBe(true);
+ expect(result.path).toBe(testPath);
+ expect(result.message).toContain("Successfully deleted");
+});
+
+// ============================================================================
+// FRONTMATTER INTEGRATION TESTS
+// ============================================================================
+
+test("write_note with frontmatter", async () => {
+ await fileSystem.writeNote({
+ path: "test.md",
+ content: "This is test content.",
+ frontmatter: {
+ title: "Test Note",
+ tags: ["test", "example"],
+ created: "2023-01-01"
+ }
+ });
+
+ const note = await fileSystem.readNote("test.md");
+
+ expect(note.frontmatter.title).toBe("Test Note");
+ expect(note.frontmatter.tags).toEqual(["test", "example"]);
+ expect(note.frontmatter.created).toBe("2023-01-01");
+ expect(note.content.trim()).toBe("This is test content.");
+});
+
+test("write_note with append mode preserves frontmatter", async () => {
+ await fileSystem.writeNote({
+ path: "append-test.md",
+ content: "Original content.",
+ frontmatter: { title: "Original", status: "draft" }
+ });
+
+ await fileSystem.writeNote({
+ path: "append-test.md",
+ content: "\nAppended content.",
+ frontmatter: { updated: "2023-12-01" },
+ mode: "append"
+ });
+
+ const note = await fileSystem.readNote("append-test.md");
+
+ expect(note.frontmatter.title).toBe("Original");
+ expect(note.frontmatter.status).toBe("draft");
+ // Verify raw file preserves plain date format (gray-matter parses unquoted dates as Date objects)
+ const rawFile = await readFile(join(testVaultPath, "append-test.md"), "utf-8");
+ expect(rawFile).toContain("updated: 2023-12-01");
+ expect(rawFile).not.toContain("T00:00:00.000Z");
+ expect(note.content.trim()).toBe("Original content.\n\nAppended content.");
+});
+
+test("update_frontmatter merges with existing", async () => {
+ await fileSystem.writeNote({
+ path: "update-test.md",
+ content: "Test content.",
+ frontmatter: {
+ title: "Original Title",
+ tags: ["original"],
+ status: "draft"
+ }
+ });
+
+ await fileSystem.updateFrontmatter({
+ path: "update-test.md",
+ frontmatter: {
+ title: "Updated Title",
+ priority: "high"
+ },
+ merge: true
+ });
+
+ const note = await fileSystem.readNote("update-test.md");
+
+ expect(note.frontmatter.title).toBe("Updated Title");
+ expect(note.frontmatter.tags).toEqual(["original"]);
+ expect(note.frontmatter.status).toBe("draft");
+ expect(note.frontmatter.priority).toBe("high");
+ expect(note.content.trim()).toBe("Test content.");
+});
+
+test("update_frontmatter replaces when merge is false", async () => {
+ await fileSystem.writeNote({
+ path: "replace-test.md",
+ content: "Test content.",
+ frontmatter: {
+ title: "Original Title",
+ tags: ["original"],
+ status: "draft"
+ }
+ });
+
+ await fileSystem.updateFrontmatter({
+ path: "replace-test.md",
+ frontmatter: {
+ title: "New Title",
+ priority: "high"
+ },
+ merge: false
+ });
+
+ const note = await fileSystem.readNote("replace-test.md");
+
+ expect(note.frontmatter.title).toBe("New Title");
+ expect(note.frontmatter.priority).toBe("high");
+ expect(note.frontmatter.tags).toBeUndefined();
+ expect(note.frontmatter.status).toBeUndefined();
+});
+
+test("manage_tags add operation", async () => {
+ await fileSystem.writeNote({
+ path: "tags-add-test.md",
+ content: "Test content.",
+ frontmatter: {
+ title: "Test",
+ tags: ["existing"]
+ }
+ });
+
+ const result = await fileSystem.manageTags({
+ path: "tags-add-test.md",
+ operation: "add",
+ tags: ["new", "important"]
+ });
+
+ expect(result.success).toBe(true);
+ expect(result.tags).toEqual(["existing", "new", "important"]);
+
+ const note = await fileSystem.readNote("tags-add-test.md");
+ expect(note.frontmatter.tags).toEqual(["existing", "new", "important"]);
+});
+
+test("manage_tags remove operation", async () => {
+ await fileSystem.writeNote({
+ path: "tags-remove-test.md",
+ content: "Test content.",
+ frontmatter: {
+ title: "Test",
+ tags: ["keep", "remove1", "remove2"]
+ }
+ });
+
+ const result = await fileSystem.manageTags({
+ path: "tags-remove-test.md",
+ operation: "remove",
+ tags: ["remove1", "remove2"]
+ });
+
+ expect(result.success).toBe(true);
+ expect(result.tags).toEqual(["keep"]);
+
+ const note = await fileSystem.readNote("tags-remove-test.md");
+ expect(note.frontmatter.tags).toEqual(["keep"]);
+});
+
+test("manage_tags list operation", async () => {
+ await fileSystem.writeNote({
+ path: "tags-list-test.md",
+ content: "Test content with #inline-tag.",
+ frontmatter: {
+ title: "Test",
+ tags: ["frontmatter-tag"]
+ }
+ });
+
+ const result = await fileSystem.manageTags({
+ path: "tags-list-test.md",
+ operation: "list"
+ });
+
+ expect(result.success).toBe(true);
+ expect(result.tags).toContain("frontmatter-tag");
+ expect(result.tags).toContain("inline-tag");
+});
+
+test("manage_tags removes tags array when empty", async () => {
+ await fileSystem.writeNote({
+ path: "tags-empty-test.md",
+ content: "Test content.",
+ frontmatter: {
+ title: "Test",
+ tags: ["remove-me"]
+ }
+ });
+
+ await fileSystem.manageTags({
+ path: "tags-empty-test.md",
+ operation: "remove",
+ tags: ["remove-me"]
+ });
+
+ const note = await fileSystem.readNote("tags-empty-test.md");
+ expect(note.frontmatter.tags).toBeUndefined();
+ expect(note.frontmatter.title).toBe("Test");
+});
+
+test("frontmatter validation with invalid data", async () => {
+ await expect(fileSystem.writeNote({
+ path: "invalid-test.md",
+ content: "Test content.",
+ frontmatter: {
+ title: "Test",
+ invalidFunction: () => "not allowed"
+ }
+ })).rejects.toThrow(/Invalid frontmatter/);
+});
+
+test("listDirectory includes non-note files but readNote still blocks them", async () => {
+ const imagePath = "assets/diagram.png";
+ await mkdir(join(testVaultPath, "assets"), { recursive: true });
+ await writeFile(join(testVaultPath, imagePath), "fake-png-content");
+
+ const listing = await fileSystem.listDirectory("assets");
+ expect(listing.files).toContain("diagram.png");
+
+ await expect(fileSystem.readNote(imagePath)).rejects.toThrow(/Access denied/);
+});
+
+// ============================================================================
+// NON-EXISTENT VAULT TESTS
+// ============================================================================
+
+test("read from non-existent vault throws error", async () => {
+ const nonExistentFs = new FileSystemService("/non/existent/vault/path");
+
+ await expect(nonExistentFs.readNote("test.md"))
+ .rejects.toThrow(/File not found|ENOENT/);
+});
+
+test("write to non-existent vault creates directories", async () => {
+ const tempVault = await mkdtemp(join(tmpdir(), "mcpvault-new-vault-"));
+ const newFs = new FileSystemService(tempVault);
+
+ try {
+ await newFs.writeNote({
+ path: "new-folder/nested/note.md",
+ content: "Test content"
+ });
+
+ const note = await newFs.readNote("new-folder/nested/note.md");
+ expect(note.content).toContain("Test content");
+ } finally {
+ await rm(tempVault, { recursive: true });
+ }
+});
+
+test("list directory in non-existent vault", async () => {
+ const nonExistentFs = new FileSystemService("/non/existent/vault/path");
+
+ await expect(nonExistentFs.listDirectory("/"))
+ .rejects.toThrow();
+});
+
+// ============================================================================
+// PATH TRAVERSAL WITH SPECIAL CHARACTERS
+// ============================================================================
+
+test("path traversal attempt with encoded dots blocked", async () => {
+ // Path traversal should be blocked even with URL encoding
+ await expect(fileSystem.readNote("..%2F..%2Fetc%2Fpasswd"))
+ .rejects.toThrow(/Path traversal not allowed/);
+});
+
+test("path traversal with .. is blocked", async () => {
+ await expect(fileSystem.readNote("../outside.md"))
+ .rejects.toThrow(/Path traversal not allowed/);
+});
+
+test("path traversal with nested .. is blocked", async () => {
+ await expect(fileSystem.readNote("folder/../../outside.md"))
+ .rejects.toThrow(/Path traversal not allowed/);
+});
+
+// ============================================================================
+// SYMLINK SECURITY
+// ============================================================================
+
+test("symlink to file outside vault is blocked", async () => {
+ const outsideDir = await mkdtemp(join(tmpdir(), "mcpvault-outside-"));
+ const outsideFile = join(outsideDir, "secret.txt");
+ await writeFile(outsideFile, "SECRET DATA");
+
+ try {
+ await symlink(outsideFile, join(testVaultPath, "evil-link.md"));
+ await expect(fileSystem.readNote("evil-link.md"))
+ .rejects.toThrow(/Symlink target is outside vault/);
+ } finally {
+ await rm(outsideDir, { recursive: true });
+ }
+});
+
+test("symlink to file inside vault works", async () => {
+ const content = "# Real Note\n\nThis is inside the vault.";
+ await mkdir(join(testVaultPath, "deep"), { recursive: true });
+ await writeFile(join(testVaultPath, "deep/real-note.md"), content);
+ await symlink(join(testVaultPath, "deep/real-note.md"), join(testVaultPath, "shortcut.md"));
+
+ const note = await fileSystem.readNote("shortcut.md");
+ expect(note.content).toContain("This is inside the vault.");
+});
+
+test("symlink to directory outside vault is skipped in listDirectory", async () => {
+ const outsideDir = await mkdtemp(join(tmpdir(), "mcpvault-outside-"));
+ await writeFile(join(outsideDir, "secret.txt"), "SECRET");
+
+ try {
+ await symlink(outsideDir, join(testVaultPath, "evil-dir"));
+ const listing = await fileSystem.listDirectory("");
+ expect(listing.directories).not.toContain("evil-dir");
+ expect(listing.files).not.toContain("evil-dir");
+ } finally {
+ await rm(outsideDir, { recursive: true });
+ }
+});
+
+test("symlink to directory inside vault is listed", async () => {
+ await mkdir(join(testVaultPath, "real-folder"), { recursive: true });
+ await writeFile(join(testVaultPath, "real-folder/note.md"), "# Note");
+ await symlink(join(testVaultPath, "real-folder"), join(testVaultPath, "linked-folder"));
+
+ const listing = await fileSystem.listDirectory("");
+ expect(listing.directories).toContain("linked-folder");
+});
+
+test("broken symlink is handled gracefully", async () => {
+ await symlink("/nonexistent/path/file.md", join(testVaultPath, "broken-link.md"));
+
+ await expect(fileSystem.readNote("broken-link.md"))
+ .rejects.toThrow(/File not found/);
+});
+
+test("symlinked file outside vault is skipped in listDirectory", async () => {
+ const outsideDir = await mkdtemp(join(tmpdir(), "mcpvault-outside-"));
+ const outsideFile = join(outsideDir, "secret.txt");
+ await writeFile(outsideFile, "SECRET");
+
+ try {
+ await symlink(outsideFile, join(testVaultPath, "evil-file-link.md"));
+ const listing = await fileSystem.listDirectory("");
+ expect(listing.files).not.toContain("evil-file-link.md");
+ } finally {
+ await rm(outsideDir, { recursive: true });
+ }
+});
+
+test("write to new file in vault works (no symlink, ENOENT path)", async () => {
+ await fileSystem.writeNote({ path: "brand-new.md", content: "# New Note" });
+ const note = await fileSystem.readNote("brand-new.md");
+ expect(note.content).toContain("New Note");
+});
+
+test("path with regex special chars is treated literally", async () => {
+ const testPath = "folder (copy)/note [1].md";
+ const content = "# Test with special chars";
+
+ await mkdir(join(testVaultPath, "folder (copy)"), { recursive: true });
+ await writeFile(join(testVaultPath, testPath), content);
+
+ const note = await fileSystem.readNote(testPath);
+ expect(note.content).toContain("Test with special chars");
+});
+
+test("path with dollar sign works", async () => {
+ const testPath = "$special/price$100.md";
+ const content = "# Price note";
+
+ await mkdir(join(testVaultPath, "$special"), { recursive: true });
+ await writeFile(join(testVaultPath, testPath), content);
+
+ const note = await fileSystem.readNote(testPath);
+ expect(note.content).toContain("Price note");
+});
+
+test("path with plus sign works", async () => {
+ const testPath = "C++/notes.md";
+ const content = "# C++ notes";
+
+ await mkdir(join(testVaultPath, "C++"), { recursive: true });
+ await writeFile(join(testVaultPath, testPath), content);
+
+ const note = await fileSystem.readNote(testPath);
+ expect(note.content).toContain("C++ notes");
+});
+
+test("path with pipe character works", async () => {
+ const testPath = "choice|option.md";
+ const content = "# Choice note";
+
+ await writeFile(join(testVaultPath, testPath), content);
+
+ const note = await fileSystem.readNote(testPath);
+ expect(note.content).toContain("Choice note");
+});
+
+test("delete note with special chars in path", async () => {
+ const testPath = "folder (archive)/note [old].md";
+ const content = "# Old note";
+
+ await mkdir(join(testVaultPath, "folder (archive)"), { recursive: true });
+ await writeFile(join(testVaultPath, testPath), content);
+
+ const result = await fileSystem.deleteNote({
+ path: testPath,
+ confirmPath: testPath
+ });
+
+ expect(result.success).toBe(true);
+});
+
+test("move note with special chars in both paths", async () => {
+ const oldPath = "source (1)/note [a].md";
+ const newPath = "dest (2)/note [b].md";
+ const content = "# Moving note";
+
+ await mkdir(join(testVaultPath, "source (1)"), { recursive: true });
+ await mkdir(join(testVaultPath, "dest (2)"), { recursive: true });
+ await writeFile(join(testVaultPath, oldPath), content);
+
+ const result = await fileSystem.moveNote({
+ oldPath,
+ newPath
+ });
+
+ expect(result.success).toBe(true);
+
+ const note = await fileSystem.readNote(newPath);
+ expect(note.content).toContain("Moving note");
+});
+
+test("move_file moves binary files without corruption", async () => {
+ const oldPath = "attachments/original image.png";
+ const newPath = "assets/original image.png";
+ const binaryContent = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0xff, 0x10, 0x42]);
+
+ await mkdir(join(testVaultPath, "attachments"), { recursive: true });
+ await writeFile(join(testVaultPath, oldPath), binaryContent);
+
+ const result = await fileSystem.moveFile({
+ oldPath,
+ newPath,
+ confirmOldPath: oldPath,
+ confirmNewPath: newPath
+ });
+ expect(result.success).toBe(true);
+
+ const moved = await readFile(join(testVaultPath, newPath));
+ expect(Buffer.compare(moved, binaryContent)).toBe(0);
+
+ await expect(readFile(join(testVaultPath, oldPath))).rejects.toMatchObject({ code: "ENOENT" });
+});
+
+test("move_file respects overwrite=false", async () => {
+ const oldPath = "attachments/image.png";
+ const newPath = "assets/image.png";
+
+ await mkdir(join(testVaultPath, "attachments"), { recursive: true });
+ await mkdir(join(testVaultPath, "assets"), { recursive: true });
+ await writeFile(join(testVaultPath, oldPath), Buffer.from([0x01, 0x02, 0x03]));
+ await writeFile(join(testVaultPath, newPath), Buffer.from([0xaa, 0xbb]));
+
+ const result = await fileSystem.moveFile({
+ oldPath,
+ newPath,
+ confirmOldPath: oldPath,
+ confirmNewPath: newPath,
+ overwrite: false
+ });
+ expect(result.success).toBe(false);
+ expect(result.message).toContain("Target file already exists");
+});
+
+test("move_file overwrites existing file when overwrite=true", async () => {
+ const oldPath = "attachments/image.png";
+ const newPath = "assets/image.png";
+ const replacement = Buffer.from([0xde, 0xad, 0xbe, 0xef]);
+
+ await mkdir(join(testVaultPath, "attachments"), { recursive: true });
+ await mkdir(join(testVaultPath, "assets"), { recursive: true });
+ await writeFile(join(testVaultPath, oldPath), replacement);
+ await writeFile(join(testVaultPath, newPath), Buffer.from([0x00]));
+
+ const result = await fileSystem.moveFile({
+ oldPath,
+ newPath,
+ confirmOldPath: oldPath,
+ confirmNewPath: newPath,
+ overwrite: true
+ });
+ expect(result.success).toBe(true);
+
+ const moved = await readFile(join(testVaultPath, newPath));
+ expect(Buffer.compare(moved, replacement)).toBe(0);
+});
+
+test("move_file rejects directory sources", async () => {
+ await mkdir(join(testVaultPath, "attachments/folder"), { recursive: true });
+
+ const result = await fileSystem.moveFile({
+ oldPath: "attachments/folder",
+ newPath: "assets/folder",
+ confirmOldPath: "attachments/folder",
+ confirmNewPath: "assets/folder"
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.message).toContain("supports files only");
+});
+
+test("move_file blocks restricted system paths", async () => {
+ const result = await fileSystem.moveFile({
+ oldPath: ".obsidian/plugins/data.json",
+ newPath: "assets/data.json",
+ confirmOldPath: ".obsidian/plugins/data.json",
+ confirmNewPath: "assets/data.json"
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.message).toContain("Access denied");
+});
+
+test("move_file requires matching confirmation paths", async () => {
+ const oldPath = "attachments/check.png";
+ const newPath = "assets/check.png";
+
+ await mkdir(join(testVaultPath, "attachments"), { recursive: true });
+ await writeFile(join(testVaultPath, oldPath), Buffer.from([0x11, 0x22]));
+
+ const result = await fileSystem.moveFile({
+ oldPath,
+ newPath,
+ confirmOldPath: "attachments/other.png",
+ confirmNewPath: newPath
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.message).toContain("confirmation paths do not match");
+
+ const stillExists = await readFile(join(testVaultPath, oldPath));
+ expect(Buffer.compare(stillExists, Buffer.from([0x11, 0x22]))).toBe(0);
+});
+
+test("patch note with regex special chars in oldString", async () => {
+ const testPath = "regex-test.md";
+ const content = "Price: $10.50 (discount)";
+
+ await writeFile(join(testVaultPath, testPath), content);
+
+ const result = await fileSystem.patchNote({
+ path: testPath,
+ oldString: "$10.50 (discount)",
+ newString: "$15.00 (regular)",
+ replaceAll: false
+ });
+
+ expect(result.success).toBe(true);
+
+ const note = await fileSystem.readNote(testPath);
+ expect(note.content).toContain("$15.00 (regular)");
+});
+
+// Note: searchNotes is in SearchService, not FileSystemService
+// Search tests with regex special chars should be in search.test.ts
+
+// ============================================================================
+// UNICODE AND INTERNATIONAL PATHS
+// ============================================================================
+
+test("handles unicode in file paths", async () => {
+ const testPath = "日本語/ノート.md";
+ const content = "# Japanese note";
+
+ await mkdir(join(testVaultPath, "日本語"), { recursive: true });
+ await writeFile(join(testVaultPath, testPath), content);
+
+ const note = await fileSystem.readNote(testPath);
+ expect(note.content).toContain("Japanese note");
+});
+
+test("handles emoji in file paths", async () => {
+ const testPath = "📁/🎉.md";
+ const content = "# Emoji note";
+
+ await mkdir(join(testVaultPath, "📁"), { recursive: true });
+ await writeFile(join(testVaultPath, testPath), content);
+
+ const note = await fileSystem.readNote(testPath);
+ expect(note.content).toContain("Emoji note");
+});
+
+// ============================================================================
+// VAULT STATS TESTS
+// ============================================================================
+
+test("get vault stats with empty vault", async () => {
+ const stats = await fileSystem.getVaultStats();
+
+ expect(stats.totalNotes).toBe(0);
+ expect(stats.totalFolders).toBe(0);
+ expect(stats.totalSize).toBe(0);
+ expect(stats.recentlyModified).toHaveLength(0);
+});
+
+test("get vault stats counts notes and folders", async () => {
+ await mkdir(join(testVaultPath, "folder1"), { recursive: true });
+ await mkdir(join(testVaultPath, "folder2/nested"), { recursive: true });
+ await writeFile(join(testVaultPath, "note1.md"), "# Note 1");
+ await writeFile(join(testVaultPath, "folder1/note2.md"), "# Note 2");
+ await writeFile(join(testVaultPath, "folder2/nested/note3.md"), "# Note 3");
+
+ const stats = await fileSystem.getVaultStats();
+
+ expect(stats.totalNotes).toBe(3);
+ expect(stats.totalFolders).toBe(3); // folder1, folder2, folder2/nested
+ expect(stats.totalSize).toBeGreaterThan(0);
+});
+
+test("get vault stats returns recently modified files in order", async () => {
+ // Create files with slight delays to ensure different modification times
+ await writeFile(join(testVaultPath, "old.md"), "# Old");
+ await new Promise(resolve => setTimeout(resolve, 10));
+ await writeFile(join(testVaultPath, "middle.md"), "# Middle");
+ await new Promise(resolve => setTimeout(resolve, 10));
+ await writeFile(join(testVaultPath, "recent.md"), "# Recent");
+
+ const stats = await fileSystem.getVaultStats(3);
+
+ expect(stats.recentlyModified).toHaveLength(3);
+ expect(stats.recentlyModified[0]?.path).toBe("recent.md");
+ expect(stats.recentlyModified[1]?.path).toBe("middle.md");
+ expect(stats.recentlyModified[2]?.path).toBe("old.md");
+});
+
+test("get vault stats respects recentCount limit", async () => {
+ await writeFile(join(testVaultPath, "note1.md"), "# Note 1");
+ await writeFile(join(testVaultPath, "note2.md"), "# Note 2");
+ await writeFile(join(testVaultPath, "note3.md"), "# Note 3");
+
+ const stats = await fileSystem.getVaultStats(2);
+
+ expect(stats.recentlyModified).toHaveLength(2);
+});
+
+test("get vault stats excludes filtered paths", async () => {
+ await mkdir(join(testVaultPath, ".obsidian"), { recursive: true });
+ await mkdir(join(testVaultPath, ".git"), { recursive: true });
+ await writeFile(join(testVaultPath, ".obsidian/config.json"), "{}");
+ await writeFile(join(testVaultPath, ".git/config"), "git config");
+ await writeFile(join(testVaultPath, "visible.md"), "# Visible");
+
+ const stats = await fileSystem.getVaultStats();
+
+ expect(stats.totalNotes).toBe(1);
+ expect(stats.totalFolders).toBe(0); // .obsidian and .git are filtered
+ expect(stats.recentlyModified.map(f => f.path)).toContain("visible.md");
+ expect(stats.recentlyModified.map(f => f.path)).not.toContain(".obsidian/config.json");
+});
+
+test("get vault stats excludes files matched by custom ** ignored patterns", async () => {
+ const customFilter = new PathFilter({
+ ignoredPatterns: ["ignored/**"]
+ });
+ const customFileSystem = new FileSystemService(testVaultPath, customFilter);
+
+ await mkdir(join(testVaultPath, "ignored"), { recursive: true });
+ await mkdir(join(testVaultPath, "ignored/nested"), { recursive: true });
+ await writeFile(join(testVaultPath, "ignored/something.md"), "# Disallowed 1");
+ await writeFile(join(testVaultPath, "ignored/nested/something.md"), "# Disallowed 2");
+ await writeFile(join(testVaultPath, "visible.md"), "# Visible");
+
+ const stats = await customFileSystem.getVaultStats(10);
+ const recentPaths = stats.recentlyModified.map(file => file.path);
+
+ expect(stats.totalNotes).toBe(1);
+ expect(recentPaths).toContain("visible.md");
+ expect(recentPaths).not.toContain("ignored/something.md");
+ expect(recentPaths).not.toContain("ignored/nested/something.md");
+});
+
+test("get vault stats includes notes inside directories that contain dots", async () => {
+ await mkdir(join(testVaultPath, "2026.03"), { recursive: true });
+ await writeFile(join(testVaultPath, "2026.03/nested.md"), "# Nested");
+ await writeFile(join(testVaultPath, "root.md"), "# Root");
+
+ const stats = await fileSystem.getVaultStats(10);
+ const recentPaths = stats.recentlyModified.map(file => file.path);
+
+ expect(stats.totalNotes).toBe(2);
+ expect(stats.totalFolders).toBe(1);
+ expect(recentPaths).toContain("2026.03/nested.md");
+ expect(recentPaths).toContain("root.md");
+});
+
+test("get vault stats calculates total size correctly", async () => {
+ const content1 = "# Note 1 with some content";
+ const content2 = "# Note 2 with more content here";
+ await writeFile(join(testVaultPath, "note1.md"), content1);
+ await writeFile(join(testVaultPath, "note2.md"), content2);
+
+ const stats = await fileSystem.getVaultStats();
+
+ const expectedSize = Buffer.byteLength(content1) + Buffer.byteLength(content2);
+ expect(stats.totalSize).toBe(expectedSize);
+});
+
+// ============================================================================
+// ERROR MESSAGE TESTS
+// ============================================================================
+
+test("error messages include remediation suggestions for file not found", async () => {
+ await expect(fileSystem.readNote("nonexistent.md"))
+ .rejects.toThrow(/list_directory/);
+});
+
+test("error messages include remediation suggestions for access denied", async () => {
+ await expect(fileSystem.readNote(".obsidian/config.json"))
+ .rejects.toThrow(/restricted/);
+});
+
+test("error messages include remediation suggestions for path traversal", async () => {
+ await expect(fileSystem.readNote("../outside.md"))
+ .rejects.toThrow(/within the vault/);
+});
+
+// ============================================================================
+// LIST ALL TAGS
+// ============================================================================
+
+test("listAllTags returns frontmatter tags with counts", async () => {
+ await writeFile(join(testVaultPath, "note1.md"), "---\ntags:\n - project\n - active\n---\n# Note 1");
+ await writeFile(join(testVaultPath, "note2.md"), "---\ntags:\n - project\n - done\n---\n# Note 2");
+
+ const tags = await fileSystem.listAllTags();
+ const projectTag = tags.find(t => t.tag === "project");
+ const activeTag = tags.find(t => t.tag === "active");
+ const doneTag = tags.find(t => t.tag === "done");
+
+ expect(projectTag?.count).toBe(2);
+ expect(activeTag?.count).toBe(1);
+ expect(doneTag?.count).toBe(1);
+});
+
+test("listAllTags returns inline hashtags with counts", async () => {
+ await writeFile(join(testVaultPath, "note1.md"), "# Note\nSome text #idea and #project here");
+ await writeFile(join(testVaultPath, "note2.md"), "# Note\nAnother #idea");
+
+ const tags = await fileSystem.listAllTags();
+ const ideaTag = tags.find(t => t.tag === "idea");
+ const projectTag = tags.find(t => t.tag === "project");
+
+ expect(ideaTag?.count).toBe(2);
+ expect(projectTag?.count).toBe(1);
+});
+
+test("listAllTags merges frontmatter and inline tags", async () => {
+ await writeFile(join(testVaultPath, "note1.md"), "---\ntags:\n - project\n---\n# Note\nAlso #project inline");
+
+ const tags = await fileSystem.listAllTags();
+ const projectTag = tags.find(t => t.tag === "project");
+
+ expect(projectTag?.count).toBe(2);
+});
+
+test("listAllTags normalizes case", async () => {
+ await writeFile(join(testVaultPath, "note1.md"), "---\ntags:\n - Project\n---\n# Note");
+ await writeFile(join(testVaultPath, "note2.md"), "# Note\n#project here");
+
+ const tags = await fileSystem.listAllTags();
+ const projectTag = tags.find(t => t.tag === "project");
+
+ expect(projectTag?.count).toBe(2);
+});
+
+test("listAllTags handles nested tags", async () => {
+ await writeFile(join(testVaultPath, "note1.md"), "---\ntags:\n - status/active\n---\n# Note\n#status/done");
+
+ const tags = await fileSystem.listAllTags();
+ const activeTag = tags.find(t => t.tag === "status/active");
+ const doneTag = tags.find(t => t.tag === "status/done");
+
+ expect(activeTag?.count).toBe(1);
+ expect(doneTag?.count).toBe(1);
+});
+
+test("listAllTags returns sorted by count descending", async () => {
+ await writeFile(join(testVaultPath, "note1.md"), "---\ntags:\n - rare\n - common\n---\n# Note");
+ await writeFile(join(testVaultPath, "note2.md"), "---\ntags:\n - common\n---\n# Note\n#common again");
+
+ const tags = await fileSystem.listAllTags();
+
+ expect(tags[0]?.tag).toBe("common");
+ expect(tags[0]?.count).toBe(3);
+});
+
+test("listAllTags returns empty array for vault with no tags", async () => {
+ await writeFile(join(testVaultPath, "note1.md"), "# Just a heading\nNo tags here");
+
+ const tags = await fileSystem.listAllTags();
+ expect(tags).toEqual([]);
+});
+
+test("listAllTags skips system directories", async () => {
+ await mkdir(join(testVaultPath, ".obsidian"), { recursive: true });
+ await writeFile(join(testVaultPath, ".obsidian/config.json"), '{"tags": ["hidden"]}');
+ await writeFile(join(testVaultPath, "note.md"), "---\ntags:\n - visible\n---\n# Note");
+
+ const tags = await fileSystem.listAllTags();
+
+ expect(tags).toHaveLength(1);
+ expect(tags[0]?.tag).toBe("visible");
+});
diff --git a/src/filesystem.ts b/src/filesystem.ts
index c0021cf..67d6cd3 100644
--- a/src/filesystem.ts
+++ b/src/filesystem.ts
@@ -1,8 +1,11 @@
import { join, resolve, relative, dirname } from 'path';
-import { readdir, stat } from 'node:fs/promises';
+import { readdir, stat, readFile, writeFile, unlink, mkdir, access, rename, copyFile } from 'node:fs/promises';
+import { constants, realpathSync } from 'node:fs';
+import trash from 'trash';
import { FrontmatterHandler } from './frontmatter.js';
import { PathFilter } from './pathfilter.js';
-import type { ParsedNote, DirectoryListing, NoteWriteParams, DeleteNoteParams, DeleteResult, MoveNoteParams, MoveResult, BatchReadParams, BatchReadResult, UpdateFrontmatterParams, NoteInfo, TagManagementParams, TagManagementResult } from './types.js';
+import { generateObsidianUri } from './uri.js';
+import type { ParsedNote, DirectoryListing, NoteWriteParams, DeleteNoteParams, DeleteResult, MoveNoteParams, MoveFileParams, MoveResult, BatchReadParams, BatchReadResult, UpdateFrontmatterParams, NoteInfo, TagManagementParams, TagManagementResult, PatchNoteParams, PatchNoteResult, VaultStats } from './types.js';
export class FileSystemService {
private frontmatterHandler: FrontmatterHandler;
@@ -13,7 +16,13 @@ export class FileSystemService {
pathFilter?: PathFilter,
frontmatterHandler?: FrontmatterHandler
) {
- this.vaultPath = resolve(vaultPath);
+ const resolved = resolve(vaultPath);
+ try {
+ this.vaultPath = realpathSync(resolved);
+ } catch {
+ // Vault path doesn't exist yet or is inaccessible; fall back to lexical resolution
+ this.vaultPath = resolved;
+ }
this.pathFilter = pathFilter || new PathFilter();
this.frontmatterHandler = frontmatterHandler || new FrontmatterHandler();
}
@@ -34,10 +43,46 @@ export class FileSystemService {
const fullPath = resolve(join(this.vaultPath, normalizedPath));
- // Security check: ensure path is within vault
+ // Security check: ensure path is within vault (lexical)
const relativeToVault = relative(this.vaultPath, fullPath);
if (relativeToVault.startsWith('..')) {
- throw new Error(`Path traversal not allowed: ${relativePath}`);
+ throw new Error(`Path traversal not allowed: ${relativePath}. Paths must be within the vault directory.`);
+ }
+
+ // Security check: ensure symlinks don't escape vault boundary
+ try {
+ const realPath = realpathSync(fullPath);
+ const realRelative = relative(this.vaultPath, realPath);
+ if (realRelative.startsWith('..')) {
+ throw new Error(`Symlink target is outside vault: ${relativePath}. Symbolic links must resolve to a path within the vault directory.`);
+ }
+ } catch (err: unknown) {
+ if (err instanceof Error && 'code' in err) {
+ const code = (err as NodeJS.ErrnoException).code;
+ if (code === 'ENOENT') {
+ // File doesn't exist yet (e.g. writing a new note). Verify the parent directory resolves inside vault.
+ try {
+ const parentReal = realpathSync(dirname(fullPath));
+ const parentRelative = relative(this.vaultPath, parentReal);
+ if (parentRelative.startsWith('..')) {
+ throw new Error(`Symlink target is outside vault: ${relativePath}. Symbolic links must resolve to a path within the vault directory.`);
+ }
+ } catch (parentErr: unknown) {
+ // Parent doesn't exist either (will be created by mkdir). Lexical check above is sufficient.
+ if (parentErr instanceof Error && parentErr.message.includes('outside vault')) {
+ throw parentErr;
+ }
+ }
+ } else if (code === 'ELOOP') {
+ throw new Error(`Circular symlink detected: ${relativePath}. The symbolic link chain forms a loop.`);
+ } else if (code === 'EACCES') {
+ throw new Error(`Permission denied resolving symlink: ${relativePath}. Cannot verify the symbolic link target is within the vault.`);
+ } else {
+ throw err;
+ }
+ } else {
+ throw err;
+ }
}
return fullPath;
@@ -47,7 +92,7 @@ export class FileSystemService {
const fullPath = this.resolvePath(path);
if (!this.pathFilter.isAllowed(path)) {
- throw new Error(`Access denied: ${path}`);
+ throw new Error(`Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`);
}
// Check if the path is a directory first
@@ -57,25 +102,18 @@ export class FileSystemService {
}
try {
- const file = Bun.file(fullPath);
- const exists = await file.exists();
-
- if (!exists) {
- throw new Error(`File not found: ${path}`);
- }
-
- const content = await file.text();
+ const content = await readFile(fullPath, 'utf-8');
return this.frontmatterHandler.parse(content);
} catch (error) {
- if (error instanceof Error) {
- if (error.message.includes('File not found')) {
- throw error;
+ if (error instanceof Error && 'code' in error) {
+ if (error.code === 'ENOENT') {
+ throw new Error(`File not found: ${path}. Use list_directory to see available files, or check the path spelling.`);
}
- if (error.message.includes('permission') || error.message.includes('access')) {
- throw new Error(`Permission denied: ${path}`);
+ if (error.code === 'EACCES') {
+ throw new Error(`Permission denied: ${path}. The file exists but cannot be read due to filesystem permissions.`);
}
- if (error.message.includes('Cannot read directory')) {
- throw error;
+ if (error.code === 'EISDIR') {
+ throw new Error(`Cannot read directory as file: ${path}. Use list_directory tool instead.`);
}
}
throw new Error(`Failed to read file: ${path} - ${error instanceof Error ? error.message : 'Unknown error'}`);
@@ -87,7 +125,12 @@ export class FileSystemService {
const fullPath = this.resolvePath(path);
if (!this.pathFilter.isAllowed(path)) {
- throw new Error(`Access denied: ${path}`);
+ throw new Error(`Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`);
+ }
+
+ // Validate content is a defined string to prevent writing literal "undefined"
+ if (content === undefined || content === null) {
+ throw new Error(`Content is required for writing a note: ${path}. The content parameter must be a string.`);
}
// Validate frontmatter if provided
@@ -124,22 +167,29 @@ export class FileSystemService {
? { ...existingNote.frontmatter, ...frontmatter }
: existingNote.frontmatter;
- if (mode === 'append') {
- finalContent = this.frontmatterHandler.stringify(
- mergedFrontmatter,
- existingNote.content + content
+ const mergedContent = mode === 'append'
+ ? existingNote.content + content
+ : content + existingNote.content;
+
+ if (existingNote.matter && existingNote.matter.trim() !== '') {
+ // Preserve raw formatting for unmodified fields by only applying explicit updates
+ finalContent = this.frontmatterHandler.preserveStringify(
+ existingNote.matter,
+ frontmatter || {},
+ mergedContent
);
- } else if (mode === 'prepend') {
+ } else {
finalContent = this.frontmatterHandler.stringify(
mergedFrontmatter,
- content + existingNote.content
+ mergedContent
);
}
}
}
- // Bun.write automatically creates directories if they don't exist
- await Bun.write(fullPath, finalContent!);
+ // Create directories if they don't exist
+ await mkdir(dirname(fullPath), { recursive: true });
+ await writeFile(fullPath, finalContent!, 'utf-8');
} catch (error) {
if (error instanceof Error) {
if (error.message.includes('permission') || error.message.includes('access')) {
@@ -153,8 +203,101 @@ export class FileSystemService {
}
}
+ async patchNote(params: PatchNoteParams): Promise {
+ const { path, oldString, newString, replaceAll = false } = params;
+
+ if (!this.pathFilter.isAllowed(path)) {
+ return {
+ success: false,
+ path,
+ message: `Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`
+ };
+ }
+
+ // Validate that strings are not empty
+ if (!oldString || oldString.trim() === '') {
+ return {
+ success: false,
+ path,
+ message: 'oldString cannot be empty'
+ };
+ }
+
+ if (newString === undefined || newString === null) {
+ return {
+ success: false,
+ path,
+ message: 'newString is required'
+ };
+ }
+
+ // Validate that oldString and newString are different
+ if (oldString === newString) {
+ return {
+ success: false,
+ path,
+ message: 'oldString and newString must be different'
+ };
+ }
+
+ try {
+ // Read the existing note
+ const note = await this.readNote(path);
+
+ // Get the full content with frontmatter
+ const fullContent = note.originalContent;
+
+ // Count occurrences of oldString
+ const occurrences = fullContent.split(oldString).length - 1;
+
+ if (occurrences === 0) {
+ return {
+ success: false,
+ path,
+ message: `String not found in note: "${oldString.substring(0, 50)}${oldString.length > 50 ? '...' : ''}"`,
+ matchCount: 0
+ };
+ }
+
+ // If not replaceAll and multiple occurrences exist, fail
+ if (!replaceAll && occurrences > 1) {
+ return {
+ success: false,
+ path,
+ message: `Found ${occurrences} occurrences of the string. Use replaceAll=true to replace all occurrences, or provide a more specific string to match exactly one occurrence.`,
+ matchCount: occurrences
+ };
+ }
+
+ // Perform the replacement
+ const updatedContent = replaceAll
+ ? fullContent.split(oldString).join(newString)
+ : fullContent.replace(oldString, newString);
+
+ // Write the updated content
+ const fullPath = this.resolvePath(path);
+ await writeFile(fullPath, updatedContent, 'utf-8');
+
+ return {
+ success: true,
+ path,
+ message: `Successfully replaced ${replaceAll ? occurrences : 1} occurrence${occurrences > 1 ? 's' : ''}`,
+ matchCount: occurrences
+ };
+
+ } catch (error) {
+ return {
+ success: false,
+ path,
+ message: `Failed to patch note: ${error instanceof Error ? error.message : 'Unknown error'}`
+ };
+ }
+ }
+
async listDirectory(path: string = ''): Promise {
- const fullPath = this.resolvePath(path);
+ // Normalize path: treat '.' as root directory
+ const normalizedPath = path === '.' ? '' : path;
+ const fullPath = this.resolvePath(normalizedPath);
try {
const entries = await readdir(fullPath, { withFileTypes: true });
@@ -162,18 +305,35 @@ export class FileSystemService {
const directories: string[] = [];
for (const entry of entries) {
- const entryPath = path ? `${path}/${entry.name}` : entry.name;
+ const entryPath = normalizedPath ? `${normalizedPath}/${entry.name}` : entry.name;
- if (!this.pathFilter.isAllowed(entryPath)) {
+ if (!this.pathFilter.isAllowedForListing(entryPath)) {
continue;
}
- if (entry.isDirectory()) {
+ if (entry.isSymbolicLink()) {
+ // Follow symlinks that resolve inside the vault
+ try {
+ const entryFullPath = join(fullPath, entry.name);
+ const realPath = realpathSync(entryFullPath);
+ const realRelative = relative(this.vaultPath, realPath);
+ if (realRelative.startsWith('..')) {
+ continue; // Symlink target outside vault, skip silently
+ }
+ const targetStat = await stat(entryFullPath);
+ if (targetStat.isDirectory()) {
+ directories.push(entry.name);
+ } else if (targetStat.isFile()) {
+ files.push(entry.name);
+ }
+ } catch {
+ continue; // Broken/circular/inaccessible symlink, skip silently
+ }
+ } else if (entry.isDirectory()) {
directories.push(entry.name);
} else if (entry.isFile()) {
files.push(entry.name);
}
- // Skip other types (symlinks, etc.)
}
return {
@@ -183,13 +343,13 @@ export class FileSystemService {
} catch (error) {
if (error instanceof Error) {
if (error.message.includes('not found') || error.message.includes('ENOENT')) {
- throw new Error(`Directory not found: ${path}`);
+ throw new Error(`Directory not found: ${path}. Use list_directory with no path or '/' to see root folders.`);
}
if (error.message.includes('permission') || error.message.includes('access')) {
- throw new Error(`Permission denied: ${path}`);
+ throw new Error(`Permission denied: ${path}. The directory exists but cannot be read due to filesystem permissions.`);
}
if (error.message.includes('not a directory') || error.message.includes('ENOTDIR')) {
- throw new Error(`Not a directory: ${path}`);
+ throw new Error(`Not a directory: ${path}. This path points to a file, not a folder. Use read_note to read files.`);
}
}
throw new Error(`Failed to list directory: ${path} - ${error instanceof Error ? error.message : 'Unknown error'}`);
@@ -204,8 +364,8 @@ export class FileSystemService {
}
try {
- const file = Bun.file(fullPath);
- return await file.exists();
+ await access(fullPath, constants.F_OK);
+ return true;
} catch {
return false;
}
@@ -227,7 +387,7 @@ export class FileSystemService {
}
async deleteNote(params: DeleteNoteParams): Promise {
- const { path, confirmPath } = params;
+ const { path, confirmPath, trashMode = 'none' } = params;
// Confirmation check - paths must match exactly
if (path !== confirmPath) {
@@ -244,7 +404,7 @@ export class FileSystemService {
return {
success: false,
path: path,
- message: `Access denied: ${path}`
+ message: `Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`
};
}
@@ -259,20 +419,46 @@ export class FileSystemService {
};
}
- // Check if file exists
- const file = Bun.file(fullPath);
- const exists = await file.exists();
+ if (trashMode === 'local') {
+ const trashDir = join(this.vaultPath, '.trash');
+ const trashPath = join(trashDir, path);
+
+ // Ensure trash directory exists
+ await mkdir(dirname(trashPath), { recursive: true });
+
+ // Handle collisions by appending a timestamp
+ let finalTrashPath = trashPath;
+ try {
+ await access(finalTrashPath, constants.F_OK);
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+ const ext = path.endsWith('.md') ? '.md' : '';
+ const base = ext ? path.slice(0, -ext.length) : path;
+ const collidedPath = `${base}-${timestamp}${ext}`;
+ finalTrashPath = join(trashDir, collidedPath);
+ } catch {
+ // File does not exist in trash, no collision
+ }
+
+ await rename(fullPath, finalTrashPath);
- if (!exists) {
return {
- success: false,
+ success: true,
+ path: path,
+ message: `Successfully moved note to vault trash: ${path}`
+ };
+ }
+
+ if (trashMode === 'system') {
+ await trash(fullPath);
+ return {
+ success: true,
path: path,
- message: `File not found: ${path}`
+ message: `Successfully moved note to system trash: ${path}`
};
}
- // Perform the deletion using Bun's native API
- await Bun.file(fullPath).delete();
+ // Perform the deletion using Node.js native API
+ await unlink(fullPath);
return {
success: true,
@@ -286,14 +472,14 @@ export class FileSystemService {
return {
success: false,
path: path,
- message: `File not found: ${path}`
+ message: `File not found: ${path}. Use list_directory to see available files.`
};
}
if (error.code === 'EACCES') {
return {
success: false,
path: path,
- message: `Permission denied: ${path}`
+ message: `Permission denied: ${path}. The file exists but cannot be deleted due to filesystem permissions.`
};
}
}
@@ -313,7 +499,7 @@ export class FileSystemService {
success: false,
oldPath,
newPath,
- message: `Access denied: ${oldPath}`
+ message: `Access denied: ${oldPath}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`
};
}
@@ -322,7 +508,7 @@ export class FileSystemService {
success: false,
oldPath,
newPath,
- message: `Access denied: ${newPath}`
+ message: `Access denied: ${newPath}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`
};
}
@@ -330,67 +516,186 @@ export class FileSystemService {
const newFullPath = this.resolvePath(newPath);
try {
- // Check if source file exists
- const sourceFile = Bun.file(oldFullPath);
- const sourceExists = await sourceFile.exists();
+ // Read source content (will throw ENOENT if not found)
+ let content: string;
+ try {
+ content = await readFile(oldFullPath, 'utf-8');
+ } catch (error) {
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
+ return {
+ success: false,
+ oldPath,
+ newPath,
+ message: `Source file not found: ${oldPath}. Use list_directory to see available files.`
+ };
+ }
+ throw error;
+ }
- if (!sourceExists) {
+ // Create directories if needed
+ await mkdir(dirname(newFullPath), { recursive: true });
+
+ // Write to new location, checking for existing file atomically if !overwrite
+ try {
+ if (overwrite) {
+ await writeFile(newFullPath, content, 'utf-8');
+ } else {
+ // wx flag: write exclusive - fails if file exists
+ await writeFile(newFullPath, content, { encoding: 'utf-8', flag: 'wx' });
+ }
+ } catch (error) {
+ if (error instanceof Error && 'code' in error && error.code === 'EEXIST') {
+ return {
+ success: false,
+ oldPath,
+ newPath,
+ message: `Target file already exists: ${newPath}. Use overwrite=true to replace it.`
+ };
+ }
+ throw error;
+ }
+
+ // Delete the source file
+ await unlink(oldFullPath);
+
+ return {
+ success: true,
+ oldPath,
+ newPath,
+ message: `Successfully moved note from ${oldPath} to ${newPath}`
+ };
+
+ } catch (error) {
+ return {
+ success: false,
+ oldPath,
+ newPath,
+ message: `Failed to move note: ${error instanceof Error ? error.message : 'Unknown error'}`
+ };
+ }
+ }
+
+ async moveFile(params: MoveFileParams): Promise {
+ const { oldPath, newPath, confirmOldPath, confirmNewPath, overwrite = false } = params;
+
+ if (oldPath !== confirmOldPath || newPath !== confirmNewPath) {
+ return {
+ success: false,
+ oldPath,
+ newPath,
+ message: "Move cancelled: confirmation paths do not match. For safety, oldPath must equal confirmOldPath and newPath must equal confirmNewPath."
+ };
+ }
+
+ if (!this.pathFilter.isAllowedForListing(oldPath)) {
+ return {
+ success: false,
+ oldPath,
+ newPath,
+ message: `Access denied: ${oldPath}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`
+ };
+ }
+
+ if (!this.pathFilter.isAllowedForListing(newPath)) {
+ return {
+ success: false,
+ oldPath,
+ newPath,
+ message: `Access denied: ${newPath}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`
+ };
+ }
+
+ const oldFullPath = this.resolvePath(oldPath);
+ const newFullPath = this.resolvePath(newPath);
+
+ try {
+ const sourceStat = await stat(oldFullPath);
+ if (sourceStat.isDirectory()) {
return {
success: false,
oldPath,
newPath,
- message: `Source file not found: ${oldPath}`
+ message: `Source path is a directory: ${oldPath}. move_file currently supports files only.`
};
}
-
- // Check if target already exists
- const targetFile = Bun.file(newFullPath);
- const targetExists = await targetFile.exists();
-
- if (targetExists && !overwrite) {
+ } catch (error) {
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
return {
success: false,
oldPath,
newPath,
- message: `Target file already exists: ${newPath}. Use overwrite=true to replace it.`
+ message: `Source file not found: ${oldPath}. Use list_directory to see available files.`
};
}
+ return {
+ success: false,
+ oldPath,
+ newPath,
+ message: `Failed to inspect source file: ${error instanceof Error ? error.message : 'Unknown error'}`
+ };
+ }
- // Read source content
- const content = await sourceFile.text();
-
- // Write to new location (auto-creates directories)
- await Bun.write(newFullPath, content);
+ try {
+ if (!overwrite) {
+ try {
+ await access(newFullPath, constants.F_OK);
+ return {
+ success: false,
+ oldPath,
+ newPath,
+ message: `Target file already exists: ${newPath}. Use overwrite=true to replace it.`
+ };
+ } catch (error) {
+ if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') {
+ throw error;
+ }
+ }
+ }
- // Verify the write was successful
- const newFile = Bun.file(newFullPath);
- const newExists = await newFile.exists();
+ await mkdir(dirname(newFullPath), { recursive: true });
- if (!newExists) {
- return {
- success: false,
- oldPath,
- newPath,
- message: `Failed to create target file: ${newPath}`
- };
+ if (overwrite) {
+ try {
+ const targetStat = await stat(newFullPath);
+ if (targetStat.isDirectory()) {
+ return {
+ success: false,
+ oldPath,
+ newPath,
+ message: `Target path is a directory: ${newPath}. Please provide a file path.`
+ };
+ }
+ await unlink(newFullPath);
+ } catch (error) {
+ if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') {
+ throw error;
+ }
+ }
}
- // Delete the source file
- await sourceFile.delete();
+ try {
+ await rename(oldFullPath, newFullPath);
+ } catch (error) {
+ if (error instanceof Error && 'code' in error && error.code === 'EXDEV') {
+ await copyFile(oldFullPath, newFullPath);
+ await unlink(oldFullPath);
+ } else {
+ throw error;
+ }
+ }
return {
success: true,
oldPath,
newPath,
- message: `Successfully moved note from ${oldPath} to ${newPath}`
+ message: `Successfully moved file from ${oldPath} to ${newPath}`
};
-
} catch (error) {
return {
success: false,
oldPath,
newPath,
- message: `Failed to move note: ${error instanceof Error ? error.message : 'Unknown error'}`
+ message: `Failed to move file: ${error instanceof Error ? error.message : 'Unknown error'}`
};
}
}
@@ -405,11 +710,14 @@ export class FileSystemService {
const results = await Promise.allSettled(
paths.map(async (path) => {
if (!this.pathFilter.isAllowed(path)) {
- throw new Error(`Access denied: ${path}`);
+ throw new Error(`Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`);
}
const note = await this.readNote(path);
- const result: any = { path };
+ const result: any = {
+ path,
+ obsidianUri: generateObsidianUri(this.vaultPath, path)
+ };
if (includeFrontmatter) {
result.frontmatter = note.frontmatter;
@@ -431,7 +739,7 @@ export class FileSystemService {
successful.push(result.value);
} else {
failed.push({
- path: paths[index],
+ path: paths[index] || '',
error: result.reason instanceof Error ? result.reason.message : 'Unknown error'
});
}
@@ -444,7 +752,7 @@ export class FileSystemService {
const { path, frontmatter, merge = true } = params;
if (!this.pathFilter.isAllowed(path)) {
- throw new Error(`Access denied: ${path}`);
+ throw new Error(`Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`);
}
// Read the existing note
@@ -461,41 +769,55 @@ export class FileSystemService {
throw new Error(`Invalid frontmatter: ${validation.errors.join(', ')}`);
}
- // Update the note with new frontmatter, preserving content
- await this.writeNote({
- path,
- content: note.content,
- frontmatter: newFrontmatter
- });
+ const fullPath = this.resolvePath(path);
+
+ if (merge && note.matter && note.matter.trim() !== '') {
+ // Preserve raw formatting for unmodified fields
+ const updatedContent = this.frontmatterHandler.preserveStringify(note.matter, frontmatter, note.content);
+ await writeFile(fullPath, updatedContent, 'utf-8');
+ } else {
+ // Replace frontmatter entirely (or no existing matter to preserve)
+ await this.writeNote({
+ path,
+ content: note.content,
+ frontmatter: newFrontmatter
+ });
+ }
}
async getNotesInfo(paths: string[]): Promise {
const results = await Promise.allSettled(
paths.map(async (path): Promise => {
if (!this.pathFilter.isAllowed(path)) {
- throw new Error(`Access denied: ${path}`);
+ throw new Error(`Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`);
}
const fullPath = this.resolvePath(path);
- const file = Bun.file(fullPath);
- const exists = await file.exists();
- if (!exists) {
- throw new Error(`File not found: ${path}`);
+ let stats;
+ try {
+ stats = await stat(fullPath);
+ } catch (error) {
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
+ throw new Error(`File not found: ${path}`);
+ }
+ throw error;
}
- const size = file.size;
- const lastModified = file.lastModified;
+ const size = stats.size;
+ const lastModified = stats.mtime.getTime();
// Quick check for frontmatter without reading full content
- const firstChunk = await file.slice(0, 100).text();
+ const file = await readFile(fullPath, 'utf-8');
+ const firstChunk = file.slice(0, 100);
const hasFrontmatter = firstChunk.startsWith('---\n');
return {
path,
size,
modified: lastModified,
- hasFrontmatter
+ hasFrontmatter,
+ obsidianUri: generateObsidianUri(this.vaultPath, path)
};
})
);
@@ -515,7 +837,7 @@ export class FileSystemService {
operation,
tags: [],
success: false,
- message: `Access denied: ${path}`
+ message: `Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`
};
}
@@ -558,24 +880,36 @@ export class FileSystemService {
newTags = newTags.filter(tag => !tags.includes(tag));
}
- // Update frontmatter with new tags
- const updatedFrontmatter = {
- ...note.frontmatter,
- tags: newTags.length > 0 ? newTags : undefined
- };
-
- // Remove undefined values
- if (updatedFrontmatter.tags === undefined) {
- delete updatedFrontmatter.tags;
+ // Build tag updates for preserveStringify
+ const tagUpdates: Record = {};
+ if (newTags.length > 0) {
+ tagUpdates.tags = newTags;
+ } else {
+ tagUpdates.tags = undefined;
}
- // Write back the note with updated frontmatter
- await this.writeNote({
- path,
- content: note.content,
- frontmatter: updatedFrontmatter,
- mode: 'overwrite'
- });
+ // Write back the note with updated frontmatter, preserving raw formatting for unmodified fields
+ let updatedContent: string;
+ if (note.matter && note.matter.trim() !== '') {
+ updatedContent = this.frontmatterHandler.preserveStringify(
+ note.matter,
+ tagUpdates,
+ note.content
+ );
+ } else {
+ const updatedFrontmatter = { ...note.frontmatter };
+ if (newTags.length > 0) {
+ updatedFrontmatter.tags = newTags;
+ } else {
+ delete updatedFrontmatter.tags;
+ }
+ updatedContent = this.frontmatterHandler.stringify(
+ updatedFrontmatter,
+ note.content
+ );
+ }
+ const fullPath = this.resolvePath(path);
+ await writeFile(fullPath, updatedContent, 'utf-8');
return {
path,
@@ -599,4 +933,112 @@ export class FileSystemService {
getVaultPath(): string {
return this.vaultPath;
}
-}
\ No newline at end of file
+
+ async getVaultStats(recentCount: number = 5): Promise {
+ let totalNotes = 0;
+ let totalFolders = 0;
+ let totalSize = 0;
+ const recentFiles: Array<{ path: string; modified: number }> = [];
+
+ const scanDirectory = async (dirPath: string, relativePath: string = ''): Promise => {
+ const entries = await readdir(dirPath, { withFileTypes: true });
+
+ for (const entry of entries) {
+ const entryRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
+ const fullEntryPath = join(dirPath, entry.name);
+
+ if (entry.isDirectory()) {
+ if (!this.pathFilter.isAllowedForListing(entryRelativePath)) {
+ continue;
+ }
+ totalFolders++;
+ await scanDirectory(fullEntryPath, entryRelativePath);
+ } else if (entry.isFile()) {
+ if (!this.pathFilter.isAllowed(entryRelativePath)) {
+ continue;
+ }
+
+ totalNotes++;
+ const stats = await stat(fullEntryPath);
+ totalSize += stats.size;
+
+ // Track recent files
+ const fileInfo = { path: entryRelativePath, modified: stats.mtime.getTime() };
+
+ // Insert in sorted order (most recent first)
+ const insertIndex = recentFiles.findIndex(f => f.modified < fileInfo.modified);
+ if (insertIndex === -1) {
+ if (recentFiles.length < recentCount) {
+ recentFiles.push(fileInfo);
+ }
+ } else {
+ recentFiles.splice(insertIndex, 0, fileInfo);
+ if (recentFiles.length > recentCount) {
+ recentFiles.pop();
+ }
+ }
+ }
+ }
+ };
+
+ await scanDirectory(this.vaultPath);
+
+ return {
+ totalNotes,
+ totalFolders,
+ totalSize,
+ recentlyModified: recentFiles
+ };
+ }
+
+ async listAllTags(): Promise> {
+ const tagCounts = new Map();
+
+ const inlineTagRegex = /(?:^|\s)#([a-zA-Z][a-zA-Z0-9_/\-]*)/g;
+
+ const scanDirectory = async (dirPath: string, relativePath: string = ''): Promise => {
+ const entries = await readdir(dirPath, { withFileTypes: true });
+
+ for (const entry of entries) {
+ const entryRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
+ const fullEntryPath = join(dirPath, entry.name);
+
+ if (entry.isDirectory()) {
+ if (!this.pathFilter.isAllowedForListing(entryRelativePath)) continue;
+ await scanDirectory(fullEntryPath, entryRelativePath);
+ } else if (entry.isFile() && this.pathFilter.isAllowed(entryRelativePath)) {
+ try {
+ const content = await readFile(fullEntryPath, 'utf-8');
+ const parsed = this.frontmatterHandler.parse(content);
+
+ // Frontmatter tags
+ const fmTags = parsed.frontmatter?.tags;
+ if (Array.isArray(fmTags)) {
+ for (const tag of fmTags) {
+ if (typeof tag === 'string' && tag.trim()) {
+ const normalized = tag.trim().toLowerCase();
+ tagCounts.set(normalized, (tagCounts.get(normalized) || 0) + 1);
+ }
+ }
+ }
+
+ // Inline #tags from body content
+ let match;
+ while ((match = inlineTagRegex.exec(parsed.content)) !== null) {
+ const normalized = match[1]!.toLowerCase();
+ tagCounts.set(normalized, (tagCounts.get(normalized) || 0) + 1);
+ }
+ } catch {
+ // Skip files that can't be read
+ }
+ }
+ }
+ };
+
+ await scanDirectory(this.vaultPath);
+
+ return Array.from(tagCounts.entries())
+ .map(([tag, count]) => ({ tag, count }))
+ .sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
+ }
+}
diff --git a/src/frontmatter.test.ts b/src/frontmatter.test.ts
new file mode 100644
index 0000000..a4bbbd5
--- /dev/null
+++ b/src/frontmatter.test.ts
@@ -0,0 +1,192 @@
+import { test, expect, describe } from "vitest";
+import { FrontmatterHandler, parseFrontmatter } from "./frontmatter.js";
+
+const handler = new FrontmatterHandler();
+
+test("parse note with frontmatter", () => {
+ const content = `---
+title: Test Note
+tags: [test, example]
+created: 2023-01-01
+---
+
+# Test Note
+
+This is a test note with frontmatter.`;
+
+ const result = handler.parse(content);
+
+ expect(result.frontmatter.title).toBe("Test Note");
+ expect(result.frontmatter.tags).toEqual(["test", "example"]);
+ expect(result.frontmatter.created).toEqual(new Date("2023-01-01"));
+ expect(result.content.trim()).toBe("# Test Note\n\nThis is a test note with frontmatter.");
+});
+
+test("parse note without frontmatter", () => {
+ const content = `# Test Note
+
+This is a test note without frontmatter.`;
+
+ const result = handler.parse(content);
+
+ expect(result.frontmatter).toEqual({});
+ expect(result.content).toBe(content);
+});
+
+test("stringify with frontmatter", () => {
+ const frontmatter = {
+ title: "Test Note",
+ tags: ["test", "example"]
+ };
+ const content = "# Test Note\n\nContent here.";
+
+ const result = handler.stringify(frontmatter, content);
+
+ expect(result).toContain("---");
+ expect(result).toContain("title: Test Note");
+ expect(result).toContain("tags:");
+ expect(result).toContain("# Test Note");
+});
+
+test("stringify without frontmatter", () => {
+ const content = "# Test Note\n\nContent here.";
+
+ const result = handler.stringify({}, content);
+
+ expect(result).toBe(content);
+});
+
+test("validate valid frontmatter", () => {
+ const frontmatter = {
+ title: "Valid Title",
+ tags: ["tag1", "tag2"],
+ date: new Date("2023-01-01"),
+ count: 42,
+ enabled: true
+ };
+
+ const result = handler.validate(frontmatter);
+
+ expect(result.isValid).toBe(true);
+ expect(result.errors).toHaveLength(0);
+});
+
+test("validate invalid frontmatter with function", () => {
+ const frontmatter = {
+ title: "Invalid",
+ badFunction: () => "not allowed"
+ };
+
+ const result = handler.validate(frontmatter);
+
+ expect(result.isValid).toBe(false);
+ expect(result.errors.length).toBeGreaterThan(0);
+ // The specific error message may vary between YAML libraries
+ expect(result.errors[0]).toMatch(/Functions are not allowed|Invalid YAML structure/);
+});
+
+test("update frontmatter in existing content", () => {
+ const content = `---
+title: Old Title
+tags: [old]
+---
+
+# Content
+
+Some content here.`;
+
+ const updates = {
+ title: "New Title",
+ modified: "2023-12-01"
+ };
+
+ const result = handler.updateFrontmatter(content, updates);
+
+ expect(result).toContain("title: New Title");
+ expect(result).toContain("modified: 2023-12-01");
+ expect(result).toContain("tags:");
+ expect(result).toContain("# Content");
+});
+
+test("update frontmatter preserves date format (#77)", () => {
+ const content = `---
+date: 2026-03-16
+---
+# Content`;
+
+ const result = handler.updateFrontmatter(content, { title: "New Title" });
+
+ expect(result).toContain("date: 2026-03-16");
+ expect(result).not.toContain("T00:00:00.000Z");
+});
+
+test("update frontmatter preserves HH:MM time format (#75)", () => {
+ const content = `---
+time_start: 10:00
+time_end: 14:30
+---
+# Content`;
+
+ const result = handler.updateFrontmatter(content, { animal: "dolphin" });
+
+ expect(result).toContain("time_start: 10:00");
+ expect(result).toContain("time_end: 14:30");
+ expect(result).not.toContain("time_start: 600");
+ expect(result).not.toContain("time_end: 870");
+});
+
+test("update frontmatter preserves quote styles (#76)", () => {
+ const content = `---
+categories:
+ - "[[Meetings]]"
+people:
+ - "[[Bob]]"
+---
+# Content`;
+
+ const result = handler.updateFrontmatter(content, { status: "done" });
+
+ expect(result).toContain('"[[Meetings]]"');
+ expect(result).toContain('"[[Bob]]"');
+});
+
+describe("parseFrontmatter", () => {
+ test("returns undefined for null and undefined", () => {
+ expect(parseFrontmatter(null)).toBeUndefined();
+ expect(parseFrontmatter(undefined)).toBeUndefined();
+ });
+
+ test("passes through a plain object", () => {
+ const obj = { tags: ["test"], title: "Hello" };
+ expect(parseFrontmatter(obj)).toBe(obj);
+ });
+
+ test("parses a JSON string into an object", () => {
+ const input = '{"tags": ["test"], "title": "Hello"}';
+ expect(parseFrontmatter(input)).toEqual({ tags: ["test"], title: "Hello" });
+ });
+
+ test("parses an empty JSON object string", () => {
+ expect(parseFrontmatter("{}")).toEqual({});
+ });
+
+ test("throws for a non-JSON string", () => {
+ expect(() => parseFrontmatter("not json")).toThrow("frontmatter must be a JSON object");
+ });
+
+ test("throws for a JSON array string", () => {
+ expect(() => parseFrontmatter('[1, 2, 3]')).toThrow("frontmatter must be a JSON object");
+ });
+
+ test("throws for a JSON primitive string", () => {
+ expect(() => parseFrontmatter('"just a string"')).toThrow("frontmatter must be a JSON object");
+ });
+
+ test("throws for an array value", () => {
+ expect(() => parseFrontmatter([1, 2, 3])).toThrow("frontmatter must be a JSON object");
+ });
+
+ test("throws for a number value", () => {
+ expect(() => parseFrontmatter(42)).toThrow("frontmatter must be a JSON object");
+ });
+});
diff --git a/src/frontmatter.ts b/src/frontmatter.ts
index 34e898c..3a1a4f7 100644
--- a/src/frontmatter.ts
+++ b/src/frontmatter.ts
@@ -1,6 +1,33 @@
import matter from 'gray-matter';
+import { parseDocument } from 'yaml';
import type { ParsedNote, FrontmatterValidationResult } from './types.js';
+/**
+ * Parse a frontmatter value that may be a JSON string (LLM clients sometimes
+ * pass frontmatter as a serialized JSON string instead of an object).
+ * Returns undefined if the value is null/undefined, or throws if invalid.
+ */
+export function parseFrontmatter(value: any): Record | undefined {
+ if (value === undefined || value === null) {
+ return undefined;
+ }
+ if (typeof value === 'object' && !Array.isArray(value)) {
+ return value;
+ }
+ if (typeof value === 'string') {
+ try {
+ const parsed = JSON.parse(value);
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
+ return parsed;
+ }
+ } catch {
+ // not valid JSON
+ }
+ throw new Error('frontmatter must be a JSON object, got a string that is not valid JSON');
+ }
+ throw new Error(`frontmatter must be a JSON object, got ${typeof value}`);
+}
+
export class FrontmatterHandler {
parse(content: string): ParsedNote {
try {
@@ -8,14 +35,16 @@ export class FrontmatterHandler {
return {
frontmatter: parsed.data,
content: parsed.content,
- originalContent: content
+ originalContent: content,
+ matter: parsed.matter
};
} catch (error) {
// If parsing fails, treat as content without frontmatter
return {
frontmatter: {},
content: content,
- originalContent: content
+ originalContent: content,
+ matter: ''
};
}
}
@@ -41,8 +70,8 @@ export class FrontmatterHandler {
};
try {
- // Test if the frontmatter can be serialized to valid YAML using Bun's YAML
- Bun.YAML.stringify(frontmatterData);
+ // Test if the frontmatter can be serialized to valid YAML using gray-matter
+ matter.stringify('', frontmatterData);
} catch (error) {
result.isValid = false;
result.errors.push(`Invalid YAML structure: ${error instanceof Error ? error.message : 'Unknown error'}`);
@@ -105,6 +134,31 @@ export class FrontmatterHandler {
}
}
+ preserveStringify(rawMatter: string, updates: Record, content: string): string {
+ try {
+ if (!rawMatter || rawMatter.trim() === '') {
+ // No existing frontmatter to preserve - fall back to regular stringify
+ if (!updates || Object.keys(updates).length === 0) {
+ return content;
+ }
+ return matter.stringify(content, updates);
+ }
+
+ const doc = parseDocument(rawMatter.trimStart());
+ for (const [key, value] of Object.entries(updates)) {
+ if (value === undefined) {
+ doc.delete(key);
+ } else {
+ doc.set(key, value);
+ }
+ }
+ const yamlContent = doc.toString();
+ return `---\n${yamlContent}---\n${content}`;
+ } catch (error) {
+ throw new Error(`Failed to stringify frontmatter: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+ }
+
extractFrontmatter(content: string): Record {
const parsed = this.parse(content);
return parsed.frontmatter;
@@ -119,6 +173,6 @@ export class FrontmatterHandler {
throw new Error(`Invalid frontmatter: ${validation.errors.join(', ')}`);
}
- return this.stringify(updatedFrontmatter, parsed.content);
+ return this.preserveStringify(parsed.matter || '', updates, parsed.content);
}
}
\ No newline at end of file
diff --git a/src/index.ts b/src/index.ts
new file mode 100644
index 0000000..64a5f66
--- /dev/null
+++ b/src/index.ts
@@ -0,0 +1,7 @@
+export { createServer } from './createServer.js';
+export type { CreateServerOptions } from './createServer.js';
+export { FileSystemService } from './filesystem.js';
+export { FrontmatterHandler, parseFrontmatter } from './frontmatter.js';
+export { PathFilter } from './pathfilter.js';
+export { SearchService } from './search.js';
+export * from './types.js';
diff --git a/src/integration.test.ts b/src/integration.test.ts
new file mode 100644
index 0000000..dd183e1
--- /dev/null
+++ b/src/integration.test.ts
@@ -0,0 +1,195 @@
+import { test, expect, beforeEach, afterEach, describe } from "vitest";
+import { FileSystemService } from "./filesystem.js";
+import { FrontmatterHandler } from "./frontmatter.js";
+import { PathFilter } from "./pathfilter.js";
+import { SearchService } from "./search.js";
+import { writeFile, mkdir, mkdtemp, rm } from "fs/promises";
+import { join } from "path";
+import { tmpdir } from "os";
+
+let testVaultPath: string;
+let pathFilter: PathFilter;
+let frontmatterHandler: FrontmatterHandler;
+let fileSystem: FileSystemService;
+let searchService: SearchService;
+
+beforeEach(async () => {
+ testVaultPath = await mkdtemp(join(tmpdir(), "mcpvault-integration-"));
+
+ // Initialize services (same as server.ts)
+ pathFilter = new PathFilter();
+ frontmatterHandler = new FrontmatterHandler();
+ fileSystem = new FileSystemService(testVaultPath, pathFilter, frontmatterHandler);
+ searchService = new SearchService(testVaultPath, pathFilter);
+});
+
+afterEach(async () => {
+ try {
+ await rm(testVaultPath, { recursive: true });
+ } catch {
+ // Ignore cleanup errors
+ }
+});
+
+// ============================================================================
+// INTEGRATION TESTS - END-TO-END WORKFLOW
+// ============================================================================
+
+describe("Integration: Service Layer Workflows", () => {
+ test("write, read, and delete note workflow", async () => {
+ // 1. Write a note with frontmatter
+ await fileSystem.writeNote({
+ path: "test-note.md",
+ content: "# Test Note\n\nThis is a test.",
+ frontmatter: { tags: ["test"], status: "draft" }
+ });
+
+ // 2. Read the note back
+ const note = await fileSystem.readNote("test-note.md");
+ expect(note.content).toContain("This is a test");
+ expect(note.frontmatter?.tags).toEqual(["test"]);
+ expect(note.frontmatter?.status).toBe("draft");
+
+ // 3. Delete the note
+ const deleteResult = await fileSystem.deleteNote({
+ path: "test-note.md",
+ confirmPath: "test-note.md"
+ });
+ expect(deleteResult.success).toBe(true);
+ });
+
+ test("search notes with special characters in filenames", async () => {
+ // Create notes with special characters in paths
+ const testCases = [
+ { path: "folder (archive)/note [old].md", content: "# Old Note\n\nArchived keyword." },
+ { path: "C++/notes.md", content: "# C++ Notes\n\nProgramming keyword." },
+ { path: "backup.2024/important.md", content: "# Important\n\nBackup keyword." },
+ { path: "price$100.md", content: "# Pricing\n\nCost keyword." }
+ ];
+
+ // Write all test notes
+ for (const { path, content } of testCases) {
+ if (path.includes('/')) {
+ const dirName = path.split('/')[0];
+ if (dirName) {
+ await mkdir(join(testVaultPath, dirName), { recursive: true });
+ }
+ }
+ await writeFile(join(testVaultPath, path), content);
+ }
+
+ // Search for keyword
+ const results = await searchService.search({
+ query: "keyword",
+ limit: 10
+ });
+
+ expect(results.length).toBe(4);
+
+ // Verify paths with special characters are returned correctly
+ const paths = results.map((r: any) => r.p);
+ expect(paths).toContain("folder (archive)/note [old].md");
+ expect(paths).toContain("C++/notes.md");
+ });
+
+ test("write note with regex special chars in content", async () => {
+ const content = `# Price List
+
+Item: Widget ($10.50)
+Regex: [a-z]+ matches lowercase
+Math: 2 + 2 = 4
+Pattern: backup.2024/**/*.md`;
+
+ await fileSystem.writeNote({
+ path: "special-chars.md",
+ content
+ });
+
+ // Read back and verify exact content
+ const note = await fileSystem.readNote("special-chars.md");
+ expect(note.content).toContain("($10.50)");
+ expect(note.content).toContain("[a-z]+");
+ expect(note.content).toContain("2 + 2 = 4");
+ expect(note.content).toContain("backup.2024/**/*.md");
+ });
+
+ test("search matches note filename even without content match", async () => {
+ // Issue #30: notes without a heading that rely on filename for discovery
+ await fileSystem.writeNote({
+ path: "Yard.md",
+ content: "Some info about lawn care and gardening tips."
+ });
+ await fileSystem.writeNote({
+ path: "Kitchen.md",
+ content: "Recipes and kitchen organization."
+ });
+
+ // Search for "yard" — should match Yard.md by filename
+ const results = await searchService.search({
+ query: "yard",
+ searchContent: true,
+ limit: 10
+ });
+
+ expect(results.length).toBeGreaterThanOrEqual(1);
+ expect(results.some(r => r.p === "Yard.md")).toBe(true);
+
+ // Verify filename-only match has reasonable fields
+ const yardResult = results.find(r => r.p === "Yard.md")!;
+ expect(yardResult.t).toBe("Yard");
+ expect(yardResult.mc).toBeGreaterThanOrEqual(1);
+
+ // Search for "kitchen" — should match Kitchen.md by filename
+ const kitchenResults = await searchService.search({
+ query: "kitchen",
+ searchContent: true,
+ limit: 10
+ });
+
+ // Should match both filename AND content (content contains "kitchen")
+ expect(kitchenResults.some(r => r.p === "Kitchen.md")).toBe(true);
+ });
+
+ test("multi-step workflow: search, read multiple, update frontmatter", async () => {
+ // Create several notes
+ for (let i = 1; i <= 3; i++) {
+ await fileSystem.writeNote({
+ path: `note-${i}.md`,
+ content: `# Note ${i}\n\nThis contains searchterm.`,
+ frontmatter: { id: i, processed: false }
+ });
+ }
+
+ // Search for notes
+ const searchResults = await searchService.search({
+ query: "searchterm",
+ limit: 10
+ });
+ expect(searchResults.length).toBe(3);
+
+ // Read multiple notes
+ const paths = searchResults.map(r => r.p);
+ const readResult = await fileSystem.readMultipleNotes({
+ paths,
+ includeContent: true,
+ includeFrontmatter: true
+ });
+ expect(readResult.successful.length).toBe(3);
+
+ // Update frontmatter on all notes
+ for (const path of paths) {
+ await fileSystem.updateFrontmatter({
+ path,
+ frontmatter: { processed: true },
+ merge: true
+ });
+ }
+
+ // Verify updates
+ for (const path of paths) {
+ const note = await fileSystem.readNote(path);
+ expect(note.frontmatter?.processed).toBe(true);
+ }
+ });
+});
+
diff --git a/src/pathfilter.test.ts b/src/pathfilter.test.ts
new file mode 100644
index 0000000..68dbf43
--- /dev/null
+++ b/src/pathfilter.test.ts
@@ -0,0 +1,308 @@
+import { test, expect, describe } from "vitest";
+import { PathFilter } from "./pathfilter.js";
+
+describe("PathFilter", () => {
+ // ============================================================================
+ // BASIC FUNCTIONALITY
+ // ============================================================================
+
+ test("allows markdown files by default", () => {
+ const filter = new PathFilter();
+ expect(filter.isAllowed("notes/test.md")).toBe(true);
+ expect(filter.isAllowed("test.markdown")).toBe(true);
+ expect(filter.isAllowed("folder/subfolder/note.txt")).toBe(true);
+ });
+
+ test("blocks .obsidian directory", () => {
+ const filter = new PathFilter();
+ expect(filter.isAllowed(".obsidian")).toBe(false);
+ expect(filter.isAllowed(".obsidian/app.json")).toBe(false);
+ expect(filter.isAllowed(".obsidian/plugins/plugin/main.js")).toBe(false);
+ });
+
+ test("blocks .git directory", () => {
+ const filter = new PathFilter();
+ expect(filter.isAllowed(".git")).toBe(false);
+ expect(filter.isAllowed(".git/config")).toBe(false);
+ expect(filter.isAllowed(".git/objects/abc123")).toBe(false);
+ });
+
+ test("blocks node_modules", () => {
+ const filter = new PathFilter();
+ expect(filter.isAllowed("node_modules")).toBe(false);
+ expect(filter.isAllowed("node_modules/package/index.js")).toBe(false);
+ });
+
+ test("blocks system files", () => {
+ const filter = new PathFilter();
+ expect(filter.isAllowed(".DS_Store")).toBe(false);
+ expect(filter.isAllowed("Thumbs.db")).toBe(false);
+ });
+
+ test("blocks non-allowed extensions", () => {
+ const filter = new PathFilter();
+ expect(filter.isAllowed("script.js")).toBe(false);
+ expect(filter.isAllowed("data.json")).toBe(false);
+ expect(filter.isAllowed("image.png")).toBe(false);
+ });
+
+ test("allows non-note files for directory listing", () => {
+ const filter = new PathFilter();
+ expect(filter.isAllowedForListing("image.png")).toBe(true);
+ expect(filter.isAllowedForListing("docs/report.pdf")).toBe(true);
+ expect(filter.isAllowedForListing("archive/data.json")).toBe(true);
+ });
+
+ test("blocks restricted paths in directory listing", () => {
+ const filter = new PathFilter();
+ expect(filter.isAllowedForListing(".obsidian/app.json")).toBe(false);
+ expect(filter.isAllowedForListing(".git/config")).toBe(false);
+ expect(filter.isAllowedForListing("node_modules/pkg/index.js")).toBe(false);
+ expect(filter.isAllowedForListing(".DS_Store")).toBe(false);
+ });
+
+ // ============================================================================
+ // REGEX SPECIAL CHARACTERS - SECURITY TESTS
+ // ============================================================================
+
+ describe("regex special characters in paths", () => {
+ test("handles dots in filenames literally", () => {
+ const filter = new PathFilter();
+ // Dots should be literal, not regex wildcards
+ expect(filter.isAllowed("file.name.md")).toBe(true);
+ expect(filter.isAllowed("v1.0.0-notes.md")).toBe(true);
+ });
+
+ test("handles parentheses in paths", () => {
+ const filter = new PathFilter();
+ expect(filter.isAllowed("notes/(archived)/old.md")).toBe(true);
+ expect(filter.isAllowed("project (copy).md")).toBe(true);
+ });
+
+ test("handles square brackets in paths", () => {
+ const filter = new PathFilter();
+ expect(filter.isAllowed("notes/[2024]/january.md")).toBe(true);
+ expect(filter.isAllowed("[inbox]/task.md")).toBe(true);
+ });
+
+ test("handles curly braces in paths", () => {
+ const filter = new PathFilter();
+ expect(filter.isAllowed("templates/{daily}.md")).toBe(true);
+ });
+
+ test("handles plus signs in paths", () => {
+ const filter = new PathFilter();
+ expect(filter.isAllowed("C++/notes.md")).toBe(true);
+ expect(filter.isAllowed("topic+subtopic.md")).toBe(true);
+ });
+
+ test("handles question marks in paths", () => {
+ const filter = new PathFilter();
+ // Question mark is a glob wildcard, but in actual filenames should work
+ expect(filter.isAllowed("FAQ?.md")).toBe(true);
+ });
+
+ test("handles asterisks in filenames", () => {
+ const filter = new PathFilter();
+ // Asterisk in filename (rare but valid on Unix)
+ expect(filter.isAllowed("important*.md")).toBe(true);
+ expect(filter.isAllowed("file*name.md")).toBe(true);
+ expect(filter.isAllowed("notes/todo*.md")).toBe(true);
+ });
+
+ test("asterisk in custom ignored pattern works as glob", () => {
+ const filter = new PathFilter({
+ ignoredPatterns: ["temp*/**"]
+ });
+ // Pattern uses * as wildcard - should match temp, temp1, temporary, etc.
+ expect(filter.isAllowed("temp/file.md")).toBe(false);
+ expect(filter.isAllowed("temp1/file.md")).toBe(false);
+ expect(filter.isAllowed("temporary/file.md")).toBe(false);
+ // Should NOT match "atemp" (pattern starts with temp)
+ expect(filter.isAllowed("atemp/file.md")).toBe(true);
+ });
+
+ test("double asterisk ** matches nested paths", () => {
+ const filter = new PathFilter({
+ ignoredPatterns: ["archive/**"]
+ });
+ expect(filter.isAllowed("archive/old.md")).toBe(false);
+ expect(filter.isAllowed("archive/2024/jan/note.md")).toBe(false);
+ expect(filter.isAllowed("other/archive/note.md")).toBe(true);
+ });
+
+ test("handles pipe character in paths", () => {
+ const filter = new PathFilter();
+ expect(filter.isAllowed("option|choice.md")).toBe(true);
+ });
+
+ test("handles caret in paths", () => {
+ const filter = new PathFilter();
+ expect(filter.isAllowed("version^2.md")).toBe(true);
+ });
+
+ test("handles dollar sign in paths", () => {
+ const filter = new PathFilter();
+ expect(filter.isAllowed("price$100.md")).toBe(true);
+ expect(filter.isAllowed("$HOME/notes.md")).toBe(true);
+ });
+
+ test("handles backslash (Windows paths)", () => {
+ const filter = new PathFilter();
+ // Backslashes should be normalized to forward slashes
+ expect(filter.isAllowed("folder\\subfolder\\note.md")).toBe(true);
+ });
+ });
+
+ // ============================================================================
+ // CUSTOM IGNORED PATTERNS WITH SPECIAL CHARS
+ // ============================================================================
+
+ describe("custom patterns with special characters", () => {
+ test("custom pattern with dots is treated literally", () => {
+ const filter = new PathFilter({
+ ignoredPatterns: ["backup.2024/**"]
+ });
+ expect(filter.isAllowed("backup.2024/notes.md")).toBe(false);
+ // "backup_2024" should NOT match "backup.2024" pattern
+ expect(filter.isAllowed("backup_2024/notes.md")).toBe(true);
+ });
+
+ test("custom pattern with parentheses works", () => {
+ const filter = new PathFilter({
+ ignoredPatterns: ["(archive)/**"]
+ });
+ expect(filter.isAllowed("(archive)/old.md")).toBe(false);
+ expect(filter.isAllowed("archive/old.md")).toBe(true);
+ });
+
+ test("custom pattern with brackets works", () => {
+ const filter = new PathFilter({
+ ignoredPatterns: ["[trash]/**"]
+ });
+ expect(filter.isAllowed("[trash]/deleted.md")).toBe(false);
+ expect(filter.isAllowed("trash/deleted.md")).toBe(true);
+ });
+ });
+
+ // ============================================================================
+ // PATH TRAVERSAL ATTEMPTS
+ // ============================================================================
+
+ describe("path traversal prevention", () => {
+ test("blocks obvious traversal patterns", () => {
+ const filter = new PathFilter({
+ ignoredPatterns: ["../**"]
+ });
+ expect(filter.isAllowed("../secret.md")).toBe(false);
+ expect(filter.isAllowed("../../etc/passwd")).toBe(false);
+ });
+
+ test("handles encoded traversal attempts", () => {
+ const filter = new PathFilter();
+ // These should be allowed by PathFilter (path validation is in FileSystem)
+ // but filter shouldn't crash on unusual characters
+ expect(() => filter.isAllowed("%2e%2e/secret.md")).not.toThrow();
+ expect(() => filter.isAllowed("..%2fnotes.md")).not.toThrow();
+ });
+ });
+
+ test("allows Obsidian first-party file types", () => {
+ const filter = new PathFilter();
+ expect(filter.isAllowed("_Bases/daily-notes.base")).toBe(true);
+ expect(filter.isAllowed("canvas/mindmap.canvas")).toBe(true);
+ });
+
+ // ============================================================================
+ // FILTER PATHS BATCH OPERATION
+ // ============================================================================
+
+ describe("filterPaths", () => {
+ test("filters array of paths correctly", () => {
+ const filter = new PathFilter();
+ const paths = [
+ "notes/valid.md",
+ ".obsidian/config.json",
+ "archive/old.md",
+ ".git/HEAD",
+ "readme.txt"
+ ];
+
+ const allowed = filter.filterPaths(paths);
+ expect(allowed).toEqual([
+ "notes/valid.md",
+ "archive/old.md",
+ "readme.txt"
+ ]);
+ });
+
+ test("handles empty array", () => {
+ const filter = new PathFilter();
+ expect(filter.filterPaths([])).toEqual([]);
+ });
+
+ test("handles array with all blocked paths", () => {
+ const filter = new PathFilter();
+ const paths = [
+ ".obsidian/app.json",
+ ".git/config",
+ "node_modules/pkg/index.js"
+ ];
+ expect(filter.filterPaths(paths)).toEqual([]);
+ });
+ });
+
+ // ============================================================================
+ // EDGE CASES
+ // ============================================================================
+
+ describe("edge cases", () => {
+ test("handles empty path", () => {
+ const filter = new PathFilter();
+ expect(() => filter.isAllowed("")).not.toThrow();
+ });
+
+ test("handles path with only extension", () => {
+ const filter = new PathFilter();
+ expect(filter.isAllowed(".md")).toBe(true);
+ });
+
+ test("handles very long paths", () => {
+ const filter = new PathFilter();
+ const longPath = "a/".repeat(100) + "note.md";
+ expect(() => filter.isAllowed(longPath)).not.toThrow();
+ expect(filter.isAllowed(longPath)).toBe(true);
+ });
+
+ test("handles unicode characters in paths", () => {
+ const filter = new PathFilter();
+ expect(filter.isAllowed("notes/日本語.md")).toBe(true);
+ expect(filter.isAllowed("émojis/🎉.md")).toBe(true);
+ expect(filter.isAllowed("中文/笔记.md")).toBe(true);
+ });
+
+ test("handles spaces in paths", () => {
+ const filter = new PathFilter();
+ expect(filter.isAllowed("my notes/important file.md")).toBe(true);
+ });
+
+ test("handles directories (no extension)", () => {
+ const filter = new PathFilter();
+ // Directories should be allowed (no extension check)
+ expect(filter.isAllowed("folder/subfolder/")).toBe(true);
+ expect(filter.isAllowed("notes")).toBe(true);
+ });
+
+ test("handles directories with dots in their names", () => {
+ const filter = new PathFilter();
+ // Folders with dots should be allowed (common pattern: "1. Project", "2.5 Notes")
+ expect(filter.isAllowed("1. Project")).toBe(true);
+ expect(filter.isAllowed("2. Archive")).toBe(true);
+ expect(filter.isAllowed("3.5 Research")).toBe(true);
+ expect(filter.isAllowed("1. Project/subfolder")).toBe(true);
+ expect(filter.isAllowed("1. Project/note.md")).toBe(true);
+ // But files in those folders should still need proper extensions
+ expect(filter.isAllowed("1. Project/file.js")).toBe(false);
+ });
+ });
+});
diff --git a/src/pathfilter.ts b/src/pathfilter.ts
index 8987a2d..1ddc42c 100644
--- a/src/pathfilter.ts
+++ b/src/pathfilter.ts
@@ -6,8 +6,11 @@ export class PathFilter {
constructor(config?: Partial) {
this.ignoredPatterns = [
+ '.obsidian',
'.obsidian/**',
+ '.git',
'.git/**',
+ 'node_modules',
'node_modules/**',
'.DS_Store',
'Thumbs.db',
@@ -18,18 +21,22 @@ export class PathFilter {
'.md',
'.markdown',
'.txt',
+ '.base', // Obsidian Bases (YAML)
+ '.canvas', // Obsidian Canvas (JSON)
...config?.allowedExtensions || []
];
}
private simpleGlobMatch(pattern: string, path: string): boolean {
- // Convert glob pattern to regex
- // Handle ** (any number of directories)
- let regexPattern = pattern
- .replace(/\*\*/g, '.*') // ** matches any number of directories
- .replace(/\*/g, '[^/]*') // * matches anything except /
- .replace(/\?/g, '[^/]') // ? matches single character except /
- .replace(/\./g, '\\.'); // Escape dots
+ // Normalize pattern path separators (Windows compatibility)
+ const normalizedPattern = pattern.replace(/\\/g, '/');
+
+ // Convert glob pattern to regex, escaping special regex chars first
+ let regexPattern = normalizedPattern
+ .replace(/[\\^$.*+?()[\]{}|]/g, '\\$&') // Escape all regex special chars
+ .replace(/\\\*\\\*/g, '.*') // ** matches any number of directories (unescape)
+ .replace(/\\\*/g, '[^/]*') // * matches anything except / (unescape)
+ .replace(/\\\?/g, '[^/]'); // ? matches single character except / (unescape)
// Ensure we match the full path
regexPattern = '^' + regexPattern + '$';
@@ -42,11 +49,8 @@ export class PathFilter {
// Normalize path separators
const normalizedPath = path.replace(/\\/g, '/');
- // Check if path matches any ignored pattern
- for (const pattern of this.ignoredPatterns) {
- if (this.simpleGlobMatch(pattern, normalizedPath)) {
- return false;
- }
+ if (this.isIgnoredPath(normalizedPath)) {
+ return false;
}
// For files, check extension if allowedExtensions is configured
@@ -62,11 +66,53 @@ export class PathFilter {
return true;
}
+ isAllowedForListing(path: string): boolean {
+ // Normalize path separators
+ const normalizedPath = path.replace(/\\/g, '/');
+
+ // Listing includes non-note files, but still blocks restricted system paths
+ return !this.isIgnoredPath(normalizedPath);
+ }
+
+ private isIgnoredPath(normalizedPath: string): boolean {
+
+ // Check if path matches any ignored pattern
+ for (const pattern of this.ignoredPatterns) {
+ if (this.simpleGlobMatch(pattern, normalizedPath)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
private isFile(path: string): boolean {
- return path.includes('.') && !path.endsWith('/');
+ // A path is a file if it has a file extension at the end
+ // Paths ending with '/' are always directories
+ if (path.endsWith('/')) {
+ return false;
+ }
+
+ // Get the last component of the path
+ const lastSlashIndex = path.lastIndexOf('/');
+ const lastComponent = lastSlashIndex === -1 ? path : path.substring(lastSlashIndex + 1);
+
+ // Check if the last component has a file extension
+ // A file extension is a dot followed by 1-10 alphanumeric characters at the end
+ // This distinguishes "file.md" (file) from "1. Project" (directory with dot in name)
+ const lastDotIndex = lastComponent.lastIndexOf('.');
+ if (lastDotIndex === -1 || lastDotIndex === 0) {
+ // No dot, or dot at the start (like .gitignore) - treat as no extension
+ return false;
+ }
+
+ const extension = lastComponent.substring(lastDotIndex + 1);
+ // Extension should be 1-10 characters and contain only alphanumeric characters
+ // This allows .md, .txt, .markdown but not ". Project" (space after dot)
+ return extension.length >= 1 && extension.length <= 10 && /^[a-zA-Z0-9]+$/.test(extension);
}
filterPaths(paths: string[]): string[] {
return paths.filter(path => this.isAllowed(path));
}
-}
\ No newline at end of file
+}
diff --git a/src/search.test.ts b/src/search.test.ts
new file mode 100644
index 0000000..d25357c
--- /dev/null
+++ b/src/search.test.ts
@@ -0,0 +1,281 @@
+import { describe, test, expect, beforeEach, afterEach } from "vitest";
+import { SearchService } from "./search.js";
+import { PathFilter } from "./pathfilter.js";
+import { writeFile, mkdir, mkdtemp, rm } from "fs/promises";
+import { join } from "path";
+import { tmpdir } from "os";
+
+let testVaultPath: string;
+let searchService: SearchService;
+
+beforeEach(async () => {
+ testVaultPath = await mkdtemp(join(tmpdir(), "mcpvault-search-"));
+ searchService = new SearchService(testVaultPath, new PathFilter());
+});
+
+afterEach(async () => {
+ try {
+ await rm(testVaultPath, { recursive: true });
+ } catch {
+ // Ignore cleanup errors
+ }
+});
+
+// Helper to write a note directly to disk
+async function writeNote(path: string, content: string) {
+ const fullPath = join(testVaultPath, path);
+ const dir = fullPath.substring(0, fullPath.lastIndexOf("/"));
+ if (dir !== testVaultPath) {
+ await mkdir(dir, { recursive: true });
+ }
+ await writeFile(fullPath, content);
+}
+
+describe("SearchService", () => {
+ // ============================================================================
+ // BASIC SEARCH
+ // ============================================================================
+
+ test("finds notes matching a query", async () => {
+ await writeNote("alpha.md", "# Alpha\n\nThis note has bananas.");
+ await writeNote("beta.md", "# Beta\n\nThis note has oranges.");
+
+ const results = await searchService.search({ query: "bananas" });
+
+ expect(results).toHaveLength(1);
+ expect(results[0]!.p).toBe("alpha.md");
+ });
+
+ test("returns empty array when no matches", async () => {
+ await writeNote("note.md", "# Note\n\nNothing relevant here.");
+
+ const results = await searchService.search({ query: "zzzznotfound" });
+
+ expect(results).toHaveLength(0);
+ });
+
+ test("returns empty array for empty vault", async () => {
+ const results = await searchService.search({ query: "anything" });
+
+ expect(results).toHaveLength(0);
+ });
+
+ test("throws on empty query", async () => {
+ await expect(searchService.search({ query: "" }))
+ .rejects.toThrow(/empty/);
+ });
+
+ test("throws on whitespace-only query", async () => {
+ await expect(searchService.search({ query: " " }))
+ .rejects.toThrow(/empty/);
+ });
+
+ // ============================================================================
+ // LIMIT
+ // ============================================================================
+
+ test("respects limit parameter", async () => {
+ for (let i = 0; i < 5; i++) {
+ await writeNote(`note-${i}.md`, `# Note ${i}\n\nkeyword here.`);
+ }
+
+ const results = await searchService.search({ query: "keyword", limit: 2 });
+
+ expect(results).toHaveLength(2);
+ });
+
+ test("caps limit at 20", async () => {
+ for (let i = 0; i < 25; i++) {
+ await writeNote(`note-${i}.md`, `# Note ${i}\n\nkeyword here.`);
+ }
+
+ const results = await searchService.search({ query: "keyword", limit: 100 });
+
+ expect(results.length).toBeLessThanOrEqual(20);
+ });
+
+ test("defaults limit to 5", async () => {
+ for (let i = 0; i < 10; i++) {
+ await writeNote(`note-${i}.md`, `# Note ${i}\n\nkeyword here.`);
+ }
+
+ const results = await searchService.search({ query: "keyword" });
+
+ expect(results).toHaveLength(5);
+ });
+
+ // ============================================================================
+ // CASE SENSITIVITY
+ // ============================================================================
+
+ test("case-insensitive search by default", async () => {
+ await writeNote("upper.md", "# Upper\n\nBANANA is great.");
+ await writeNote("lower.md", "# Lower\n\nbanana is great.");
+ await writeNote("mixed.md", "# Mixed\n\nBanana is great.");
+
+ const results = await searchService.search({ query: "banana", limit: 10 });
+
+ expect(results).toHaveLength(3);
+ });
+
+ test("case-sensitive search when enabled", async () => {
+ await writeNote("upper.md", "# Upper\n\nBANANA is great.");
+ await writeNote("lower.md", "# Lower\n\nbanana is great.");
+
+ const results = await searchService.search({
+ query: "BANANA",
+ caseSensitive: true,
+ limit: 10
+ });
+
+ expect(results).toHaveLength(1);
+ expect(results[0]!.p).toBe("upper.md");
+ });
+
+ // ============================================================================
+ // FRONTMATTER SEARCH
+ // ============================================================================
+
+ test("excludes frontmatter from content-only search", async () => {
+ await writeNote("note.md", "---\ntags: [uniquetag]\n---\n\n# Note\n\nNo tag here.");
+
+ const results = await searchService.search({
+ query: "uniquetag",
+ searchContent: true,
+ searchFrontmatter: false,
+ limit: 10
+ });
+
+ expect(results).toHaveLength(0);
+ });
+
+ test("searches frontmatter when enabled", async () => {
+ await writeNote("note.md", "---\ntags: [uniquetag]\n---\n\n# Note\n\nNo tag here.");
+
+ const results = await searchService.search({
+ query: "uniquetag",
+ searchFrontmatter: true,
+ limit: 10
+ });
+
+ expect(results).toHaveLength(1);
+ expect(results[0]!.p).toBe("note.md");
+ });
+
+ test("searches both content and frontmatter together", async () => {
+ await writeNote("fm-only.md", "---\nstatus: special\n---\n\n# Note\n\nPlain body.");
+ await writeNote("content-only.md", "# Note\n\nThis is special content.");
+
+ const results = await searchService.search({
+ query: "special",
+ searchContent: true,
+ searchFrontmatter: true,
+ limit: 10
+ });
+
+ expect(results).toHaveLength(2);
+ });
+
+ // ============================================================================
+ // FILENAME MATCHING
+ // ============================================================================
+
+ test("matches by filename when content has no match", async () => {
+ await writeNote("Recipes.md", "Some unrelated content about cooking.");
+
+ const results = await searchService.search({ query: "recipes", limit: 10 });
+
+ expect(results).toHaveLength(1);
+ expect(results[0]!.p).toBe("Recipes.md");
+ expect(results[0]!.t).toBe("Recipes");
+ });
+
+ // ============================================================================
+ // MULTI-TERM SEARCH
+ // ============================================================================
+
+ test("multi-term search matches notes with any term", async () => {
+ await writeNote("cats.md", "# Cats\n\nI love cats.");
+ await writeNote("dogs.md", "# Dogs\n\nI love dogs.");
+ await writeNote("fish.md", "# Fish\n\nI love fish.");
+
+ const results = await searchService.search({ query: "cats dogs", limit: 10 });
+
+ const paths = results.map(r => r.p);
+ expect(paths).toContain("cats.md");
+ expect(paths).toContain("dogs.md");
+ expect(paths).not.toContain("fish.md");
+ });
+
+ // ============================================================================
+ // RANKING
+ // ============================================================================
+
+ test("ranks notes with more matches higher", async () => {
+ await writeNote("few.md", "# Few\n\napple once.");
+ await writeNote("many.md", "# Many\n\napple apple apple apple apple.");
+
+ const results = await searchService.search({ query: "apple", limit: 10 });
+
+ expect(results).toHaveLength(2);
+ expect(results[0]!.p).toBe("many.md");
+ });
+
+ // ============================================================================
+ // RESULT SHAPE
+ // ============================================================================
+
+ test("results include expected fields", async () => {
+ await writeNote("folder/note.md", "# My Note\n\nSome content with target word.");
+
+ const results = await searchService.search({ query: "target", limit: 10 });
+
+ expect(results).toHaveLength(1);
+ const r = results[0]!;
+ expect(r.p).toBe("folder/note.md");
+ expect(r.t).toBe("note");
+ expect(r.ex).toBeDefined();
+ expect(r.mc).toBeGreaterThanOrEqual(1);
+ expect(r.ln).toBeGreaterThanOrEqual(1);
+ expect(r.uri).toMatch(/^obsidian:\/\//);
+ });
+
+ test("excerpt contains context around match", async () => {
+ await writeNote("note.md", "# Note\n\nSome words before target some words after.");
+
+ const results = await searchService.search({ query: "target", limit: 10 });
+
+ expect(results[0]!.ex).toContain("target");
+ });
+
+ // ============================================================================
+ // PATH FILTERING
+ // ============================================================================
+
+ test("excludes notes in filtered directories", async () => {
+ await writeNote("visible.md", "# Visible\n\nkeyword here.");
+ await mkdir(join(testVaultPath, ".obsidian"), { recursive: true });
+ await writeFile(join(testVaultPath, ".obsidian/config.md"), "keyword here.");
+
+ const results = await searchService.search({ query: "keyword", limit: 10 });
+
+ expect(results).toHaveLength(1);
+ expect(results[0]!.p).toBe("visible.md");
+ });
+
+ // ============================================================================
+ // TRAILING SLASH IN VAULT PATH
+ // ============================================================================
+
+ test("vault path with trailing slash does not truncate result paths", async () => {
+ const trailingSlashService = new SearchService(testVaultPath + "/", new PathFilter());
+
+ await mkdir(join(testVaultPath, "sessions"), { recursive: true });
+ await writeNote("sessions/foo-bar.md", "# Foo Bar\n\nSome content here.");
+
+ const results = await trailingSlashService.search({ query: "foo", limit: 5 });
+
+ expect(results).toHaveLength(1);
+ expect(results[0]!.p).toBe("sessions/foo-bar.md");
+ });
+});
diff --git a/src/search.ts b/src/search.ts
index 098d17f..c7b43c3 100644
--- a/src/search.ts
+++ b/src/search.ts
@@ -1,12 +1,18 @@
-import { join } from 'path';
+import { join, resolve } from 'path';
+import { readFile, readdir } from 'node:fs/promises';
import type { PathFilter } from './pathfilter.js';
-import type { SearchParams, SearchResult } from './types.js';
+import type { RankCandidate, SearchParams, SearchResult } from './types.js';
+import { generateObsidianUri } from './uri.js';
export class SearchService {
+ private vaultPath: string;
+
constructor(
- private vaultPath: string,
+ vaultPath: string,
private pathFilter: PathFilter
- ) {}
+ ) {
+ this.vaultPath = resolve(vaultPath);
+ }
async search(params: SearchParams): Promise {
const {
@@ -21,19 +27,43 @@ export class SearchService {
throw new Error('Search query cannot be empty');
}
- const results: SearchResult[] = [];
- const glob = new Bun.Glob("**/*.md");
const maxLimit = Math.min(limit, 20);
- for await (const relativePath of glob.scan(this.vaultPath)) {
- if (!this.pathFilter.isAllowed(relativePath)) continue;
- if (results.length >= maxLimit) break;
+ // Corpus stats for reranking
+ let totalDocLength = 0;
+ let docCount = 0;
+ const termDocFreq = new Map();
+ const candidates: RankCandidate[] = [];
+ const searchQuery = caseSensitive ? query : query.toLowerCase();
+ const terms = searchQuery.split(/\s+/).filter(t => t.length > 0);
+ const scoringTerms = terms.length > 1 ? [...terms, searchQuery] : terms;
+
+ // Recursively find all .md files
+ const markdownFiles = await this.findMarkdownFiles(this.vaultPath);
+
+ // Pre-filter by pathFilter before I/O
+ const prefixLen = this.vaultPath.length + 1;
+ const allowedFiles: { fullPath: string; relativePath: string }[] = [];
+ for (const fullPath of markdownFiles) {
+ const relativePath = fullPath.substring(prefixLen).replace(/\\/g, '/');
+ if (this.pathFilter.isAllowed(relativePath)) {
+ allowedFiles.push({ fullPath, relativePath });
+ }
+ }
+
+ // Read files in parallel batches
+ const BATCH_SIZE = 5;
+ for (let start = 0; start < allowedFiles.length; start += BATCH_SIZE) {
+ const batch = allowedFiles.slice(start, start + BATCH_SIZE);
+ const contents = await Promise.all(
+ batch.map(f => readFile(f.fullPath, 'utf-8').catch(() => null))
+ );
- const fullPath = join(this.vaultPath, relativePath);
- const file = Bun.file(fullPath);
+ for (let i = 0; i < batch.length; i++) {
+ const content = contents[i];
+ if (content === null || content === undefined) continue;
- try {
- const content = await file.text();
+ const { relativePath } = batch[i]!;
let searchableText = '';
// Prepare search text based on options
@@ -46,52 +76,150 @@ export class SearchService {
} else if (searchFrontmatter) {
// Search only frontmatter
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n/);
- searchableText = frontmatterMatch ? frontmatterMatch[1] : '';
+ searchableText = frontmatterMatch ? frontmatterMatch[1] || '' : '';
}
const searchIn = caseSensitive ? searchableText : searchableText.toLowerCase();
- const searchQuery = caseSensitive ? query : query.toLowerCase();
- const index = searchIn.indexOf(searchQuery);
- if (index !== -1) {
- // Extract excerpt around first match
- const excerptStart = Math.max(0, index - 50);
- const excerptEnd = Math.min(searchableText.length, index + searchQuery.length + 50);
- let excerpt = searchableText.slice(excerptStart, excerptEnd).trim();
+ // Collect corpus stats for reranking
+ const docLength = searchIn.split(/\s+/).filter(w => w.length > 0).length;
+ totalDocLength += docLength;
+ docCount++;
+ for (const term of scoringTerms) {
+ if (searchIn.includes(term)) {
+ termDocFreq.set(term, (termDocFreq.get(term) || 0) + 1);
+ }
+ }
+
+ // Extract title from filename
+ const title = relativePath.split('/').pop()?.replace(/\.md$/, '') || relativePath;
+
+ // Check filename match (any term)
+ const filenameToSearch = caseSensitive ? title : title.toLowerCase();
+ const filenameMatch = terms.some(term => filenameToSearch.includes(term));
- // Add ellipsis if excerpt is truncated
- if (excerptStart > 0) excerpt = '...' + excerpt;
- if (excerptEnd < searchableText.length) excerpt = excerpt + '...';
+ // Check content match (any term)
+ const termIndices = terms.map(term => searchIn.indexOf(term));
+ const anyTermFound = termIndices.some(idx => idx !== -1);
+ const firstIndex = anyTermFound
+ ? Math.min(...termIndices.filter(idx => idx !== -1))
+ : -1;
- // Count total matches
+ if (firstIndex !== -1 || filenameMatch) {
+ let excerpt: string;
let matchCount = 0;
- let searchIndex = 0;
- while ((searchIndex = searchIn.indexOf(searchQuery, searchIndex)) !== -1) {
- matchCount++;
- searchIndex += searchQuery.length;
+ let lineNumber = 0;
+
+ const termFreqs = new Map();
+
+ if (firstIndex !== -1) {
+ // Find the term that matched first for excerpt
+ const firstTermIdx = termIndices.indexOf(firstIndex);
+ const firstTerm = terms[firstTermIdx]!;
+
+ // Extract excerpt around first content match
+ const excerptStart = Math.max(0, firstIndex - 21);
+ const excerptEnd = Math.min(searchableText.length, firstIndex + firstTerm.length + 21);
+ excerpt = searchableText.slice(excerptStart, excerptEnd).trim();
+
+ // Add ellipsis if excerpt is truncated
+ if (excerptStart > 0) excerpt = '...' + excerpt;
+ if (excerptEnd < searchableText.length) excerpt = excerpt + '...';
+
+ // Count total content matches across all terms
+ for (const term of scoringTerms) {
+ let count = 0;
+ let searchIndex = 0;
+ while ((searchIndex = searchIn.indexOf(term, searchIndex)) !== -1) {
+ count++;
+ searchIndex += term.length;
+ }
+ termFreqs.set(term, count);
+ matchCount += count;
+ }
+
+ // Find line number of first match
+ const lines = searchableText.slice(0, firstIndex).split('\n');
+ lineNumber = lines.length;
+ } else {
+ // Filename-only match: use beginning of content as excerpt
+ excerpt = searchableText.slice(0, 50).trim();
+ if (searchableText.length > 50) excerpt = excerpt + '...';
+ matchCount = 0;
+ lineNumber = 0;
}
- // Find line number of first match
- const lines = searchableText.slice(0, index).split('\n');
- const lineNumber = lines.length;
-
- // Extract title from filename
- const title = relativePath.split('/').pop()?.replace(/\.md$/, '') || relativePath;
-
- results.push({
- path: relativePath,
- title: title,
- excerpt: excerpt,
- matchCount: matchCount,
- lineNumber: lineNumber
+ // Add filename match to count
+ if (filenameMatch) matchCount++;
+
+ candidates.push({
+ result: {
+ p: relativePath,
+ t: title,
+ ex: excerpt,
+ mc: matchCount,
+ ln: lineNumber,
+ uri: generateObsidianUri(this.vaultPath, relativePath)
+ },
+ termFreqs,
+ docLength
});
}
- } catch (error) {
- // Skip files that can't be read
- continue;
}
}
+ const results: SearchResult[] = this.rerank(candidates, scoringTerms, termDocFreq, docCount, totalDocLength, maxLimit);
return results;
}
+
+ private async findMarkdownFiles(dirPath: string): Promise {
+ const markdownFiles: string[] = [];
+
+ try {
+ const entries = await readdir(dirPath, { withFileTypes: true });
+
+ for (const entry of entries) {
+ const fullPath = join(dirPath, entry.name);
+
+ if (entry.isDirectory()) {
+ // Recursively search subdirectories
+ const subFiles = await this.findMarkdownFiles(fullPath);
+ markdownFiles.push(...subFiles);
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
+ markdownFiles.push(fullPath);
+ }
+ }
+ } catch (error) {
+ // Skip directories that can't be read
+ }
+
+ return markdownFiles;
+ }
+
+ private rerank(
+ candidates: RankCandidate[],
+ terms: string[],
+ termDocFreq: Map,
+ docCount: number,
+ totalDocLength: number,
+ maxLimit: number
+ ): SearchResult[] {
+ const avgdl = docCount > 0 ? totalDocLength / docCount : 1;
+ const k1 = 1.2;
+ const b = 0.75;
+
+ const scored = candidates.map(c => {
+ let score = 0;
+ for (const term of terms) {
+ const tf = c.termFreqs.get(term) || 0;
+ const df = termDocFreq.get(term) || 0;
+ const idf = Math.log(1 + (docCount - df + 0.5) / (df + 0.5));
+ score += idf * (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * c.docLength / avgdl));
+ }
+ return { score, result: c.result };
+ });
+
+ scored.sort((a, b) => b.score - a.score);
+ return scored.slice(0, maxLimit).map(s => s.result);
+ }
}
\ No newline at end of file
diff --git a/src/types.ts b/src/types.ts
index de401ab..db668c5 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -2,6 +2,7 @@ export interface ParsedNote {
frontmatter: Record;
content: string;
originalContent: string;
+ matter?: string;
}
export interface NoteWriteParams {
@@ -11,9 +12,24 @@ export interface NoteWriteParams {
mode?: 'overwrite' | 'append' | 'prepend';
}
+export interface PatchNoteParams {
+ path: string;
+ oldString: string;
+ newString: string;
+ replaceAll?: boolean;
+}
+
+export interface PatchNoteResult {
+ success: boolean;
+ path: string;
+ message: string;
+ matchCount?: number;
+}
+
export interface DeleteNoteParams {
path: string;
confirmPath: string;
+ trashMode?: 'none' | 'local' | 'system';
}
export interface DeleteResult {
@@ -48,11 +64,18 @@ export interface SearchParams {
}
export interface SearchResult {
- path: string;
- title: string;
- excerpt: string;
- matchCount: number;
- lineNumber?: number;
+ p: string; // path
+ t: string; // title
+ ex: string; // excerpt
+ mc: number; // matchCount
+ ln?: number; // lineNumber
+ uri?: string; // obsidianUri
+}
+
+export interface RankCandidate {
+ result: SearchResult;
+ termFreqs: Map;
+ docLength: number;
}
// Move types
@@ -62,6 +85,14 @@ export interface MoveNoteParams {
overwrite?: boolean;
}
+export interface MoveFileParams {
+ oldPath: string;
+ newPath: string;
+ confirmOldPath: string;
+ confirmNewPath: string;
+ overwrite?: boolean;
+}
+
export interface MoveResult {
success: boolean;
oldPath: string;
@@ -81,6 +112,7 @@ export interface BatchReadResult {
path: string;
frontmatter?: Record;
content?: string;
+ obsidianUri?: string;
}>;
failed: Array<{
path: string;
@@ -101,6 +133,7 @@ export interface NoteInfo {
size: number;
modified: number; // timestamp
hasFrontmatter: boolean;
+ obsidianUri?: string;
}
// Tag management types
@@ -116,4 +149,15 @@ export interface TagManagementResult {
tags: string[];
success: boolean;
message?: string;
-}
\ No newline at end of file
+}
+
+// Vault statistics types
+export interface VaultStats {
+ totalNotes: number;
+ totalFolders: number;
+ totalSize: number; // bytes
+ recentlyModified: Array<{
+ path: string;
+ modified: number; // timestamp
+ }>;
+}
diff --git a/src/uri.test.ts b/src/uri.test.ts
new file mode 100644
index 0000000..cabae15
--- /dev/null
+++ b/src/uri.test.ts
@@ -0,0 +1,60 @@
+import { describe, it, expect } from 'vitest';
+import { generateObsidianUri } from './uri.js';
+
+describe('generateObsidianUri', () => {
+ it('generates URI with absolute path', () => {
+ const vaultPath = '/Users/test/vault';
+ const notePath = 'folder/note.md';
+ const uri = generateObsidianUri(vaultPath, notePath);
+
+ expect(uri).toBe('obsidian:////Users/test/vault/folder/note');
+ });
+
+ it('handles paths with leading slash', () => {
+ const vaultPath = '/Users/test/vault';
+ const notePath = '/folder/note.md';
+ const uri = generateObsidianUri(vaultPath, notePath);
+
+ expect(uri).toBe('obsidian:////Users/test/vault/folder/note');
+ });
+
+ it('removes .md extension', () => {
+ const vaultPath = '/Users/test/vault';
+ const notePath = 'note.md';
+ const uri = generateObsidianUri(vaultPath, notePath);
+
+ expect(uri).toBe('obsidian:////Users/test/vault/note');
+ });
+
+ it('encodes special characters', () => {
+ const vaultPath = '/Users/test/vault';
+ const notePath = 'folder/my note with spaces.md';
+ const uri = generateObsidianUri(vaultPath, notePath);
+
+ expect(uri).toBe('obsidian:////Users/test/vault/folder/my%20note%20with%20spaces');
+ });
+
+ it('handles notes in root directory', () => {
+ const vaultPath = '/Users/test/vault';
+ const notePath = 'note.md';
+ const uri = generateObsidianUri(vaultPath, notePath);
+
+ expect(uri).toBe('obsidian:////Users/test/vault/note');
+ });
+
+ it('handles nested directories', () => {
+ const vaultPath = '/Users/test/vault';
+ const notePath = 'folder1/folder2/folder3/note.md';
+ const uri = generateObsidianUri(vaultPath, notePath);
+
+ expect(uri).toBe('obsidian:////Users/test/vault/folder1/folder2/folder3/note');
+ });
+
+ it('encodes special characters in directory names', () => {
+ const vaultPath = '/Users/test/vault';
+ const notePath = 'my folder/sub folder/note.md';
+ const uri = generateObsidianUri(vaultPath, notePath);
+
+ expect(uri).toBe('obsidian:////Users/test/vault/my%20folder/sub%20folder/note');
+ });
+});
diff --git a/src/uri.ts b/src/uri.ts
new file mode 100644
index 0000000..22746c3
--- /dev/null
+++ b/src/uri.ts
@@ -0,0 +1,26 @@
+/**
+ * Generates an Obsidian URI for a given note path.
+ * Uses the absolute path format: obsidian:///absolute/path/to/note
+ *
+ * @param vaultPath - The absolute path to the vault root
+ * @param notePath - The relative path to the note within the vault
+ * @returns A properly encoded Obsidian URI
+ */
+export function generateObsidianUri(vaultPath: string, notePath: string): string {
+ // Remove leading slash from notePath if present
+ const cleanPath = notePath.startsWith('/') ? notePath.slice(1) : notePath;
+
+ // Construct absolute path
+ const absolutePath = `${vaultPath}/${cleanPath}`;
+
+ // Remove .md extension if present (Obsidian handles this automatically)
+ const pathWithoutExtension = absolutePath.replace(/\.md$/, '');
+
+ // URI encode the path, but keep slashes as slashes
+ const encodedPath = pathWithoutExtension
+ .split('/')
+ .map(segment => encodeURIComponent(segment))
+ .join('/');
+
+ return `obsidian:///${encodedPath}`;
+}
diff --git a/tests/filesystem.test.ts b/tests/filesystem.test.ts
deleted file mode 100644
index 55dcdba..0000000
--- a/tests/filesystem.test.ts
+++ /dev/null
@@ -1,130 +0,0 @@
-import { test, expect, beforeEach, afterEach } from "bun:test";
-import { FileSystemService } from "../src/filesystem.js";
-import { writeFile, mkdir, rmdir } from "fs/promises";
-import { join } from "path";
-
-const testVaultPath = "/tmp/test-vault-delete";
-let fileSystem: FileSystemService;
-
-beforeEach(async () => {
- // Create test vault directory
- await mkdir(testVaultPath, { recursive: true });
- fileSystem = new FileSystemService(testVaultPath);
-});
-
-afterEach(async () => {
- // Clean up test vault
- try {
- await rmdir(testVaultPath, { recursive: true });
- } catch (error) {
- // Ignore cleanup errors
- }
-});
-
-test("delete note with correct confirmation", async () => {
- const testPath = "test-note.md";
- const content = "# Test Note\n\nThis is a test note to be deleted.";
-
- // Create the test file
- await writeFile(join(testVaultPath, testPath), content);
-
- // Delete with correct confirmation
- const result = await fileSystem.deleteNote({
- path: testPath,
- confirmPath: testPath
- });
-
- expect(result.success).toBe(true);
- expect(result.path).toBe(testPath);
- expect(result.message).toContain("Successfully deleted");
- expect(result.message).toContain("cannot be undone");
-});
-
-test("reject deletion with incorrect confirmation", async () => {
- const testPath = "test-note.md";
- const content = "# Test Note\n\nThis note should not be deleted.";
-
- // Create the test file
- await writeFile(join(testVaultPath, testPath), content);
-
- // Attempt delete with wrong confirmation
- const result = await fileSystem.deleteNote({
- path: testPath,
- confirmPath: "wrong-path.md"
- });
-
- expect(result.success).toBe(false);
- expect(result.path).toBe(testPath);
- expect(result.message).toContain("confirmation path does not match");
-
- // Verify file still exists
- const fileStillExists = await fileSystem.exists(testPath);
- expect(fileStillExists).toBe(true);
-});
-
-test("handle deletion of non-existent file", async () => {
- const testPath = "non-existent.md";
-
- const result = await fileSystem.deleteNote({
- path: testPath,
- confirmPath: testPath
- });
-
- expect(result.success).toBe(false);
- expect(result.path).toBe(testPath);
- expect(result.message).toContain("File not found");
-});
-
-test("reject deletion of filtered paths", async () => {
- const testPath = ".obsidian/app.json";
-
- const result = await fileSystem.deleteNote({
- path: testPath,
- confirmPath: testPath
- });
-
- expect(result.success).toBe(false);
- expect(result.path).toBe(testPath);
- expect(result.message).toContain("Access denied");
-});
-
-test("handle directory deletion attempt", async () => {
- const testPath = "test-directory";
-
- // Create a directory instead of a file
- await mkdir(join(testVaultPath, testPath));
-
- const result = await fileSystem.deleteNote({
- path: testPath,
- confirmPath: testPath
- });
-
- expect(result.success).toBe(false);
- expect(result.path).toBe(testPath);
- expect(result.message).toContain("is not a file");
-});
-
-test("delete note with frontmatter", async () => {
- const testPath = "note-with-frontmatter.md";
- const content = `---
-title: Test Note
-tags: [test, delete]
----
-
-# Test Note
-
-This note has frontmatter and should be deleted successfully.`;
-
- // Create the test file
- await writeFile(join(testVaultPath, testPath), content);
-
- // Delete with correct confirmation
- const result = await fileSystem.deleteNote({
- path: testPath,
- confirmPath: testPath
- });
-
- expect(result.success).toBe(true);
- expect(result.path).toBe(testPath);
- expect(result.message).toContain("Successfully deleted");
-});
\ No newline at end of file
diff --git a/tests/frontmatter.test.ts b/tests/frontmatter.test.ts
deleted file mode 100644
index d03e4e5..0000000
--- a/tests/frontmatter.test.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-import { test, expect } from "bun:test";
-import { FrontmatterHandler } from "../src/frontmatter.js";
-
-const handler = new FrontmatterHandler();
-
-test("parse note with frontmatter", () => {
- const content = `---
-title: Test Note
-tags: [test, example]
-created: 2023-01-01
----
-
-# Test Note
-
-This is a test note with frontmatter.`;
-
- const result = handler.parse(content);
-
- expect(result.frontmatter.title).toBe("Test Note");
- expect(result.frontmatter.tags).toEqual(["test", "example"]);
- expect(result.frontmatter.created).toEqual(new Date("2023-01-01"));
- expect(result.content.trim()).toBe("# Test Note\n\nThis is a test note with frontmatter.");
-});
-
-test("parse note without frontmatter", () => {
- const content = `# Test Note
-
-This is a test note without frontmatter.`;
-
- const result = handler.parse(content);
-
- expect(result.frontmatter).toEqual({});
- expect(result.content).toBe(content);
-});
-
-test("stringify with frontmatter", () => {
- const frontmatter = {
- title: "Test Note",
- tags: ["test", "example"]
- };
- const content = "# Test Note\n\nContent here.";
-
- const result = handler.stringify(frontmatter, content);
-
- expect(result).toContain("---");
- expect(result).toContain("title: Test Note");
- expect(result).toContain("tags:");
- expect(result).toContain("# Test Note");
-});
-
-test("stringify without frontmatter", () => {
- const content = "# Test Note\n\nContent here.";
-
- const result = handler.stringify({}, content);
-
- expect(result).toBe(content);
-});
-
-test("validate valid frontmatter", () => {
- const frontmatter = {
- title: "Valid Title",
- tags: ["tag1", "tag2"],
- date: new Date("2023-01-01"),
- count: 42,
- enabled: true
- };
-
- const result = handler.validate(frontmatter);
-
- expect(result.isValid).toBe(true);
- expect(result.errors).toHaveLength(0);
-});
-
-test("validate invalid frontmatter with function", () => {
- const frontmatter = {
- title: "Invalid",
- badFunction: () => "not allowed"
- };
-
- const result = handler.validate(frontmatter);
-
- expect(result.isValid).toBe(false);
- expect(result.errors.length).toBeGreaterThan(0);
- // The specific error message may vary between YAML libraries
- expect(result.errors[0]).toMatch(/Functions are not allowed|Invalid YAML structure/);
-});
-
-test("update frontmatter in existing content", () => {
- const content = `---
-title: Old Title
-tags: [old]
----
-
-# Content
-
-Some content here.`;
-
- const updates = {
- title: "New Title",
- modified: "2023-12-01"
- };
-
- const result = handler.updateFrontmatter(content, updates);
-
- expect(result).toContain("title: New Title");
- expect(result).toContain("modified: '2023-12-01'");
- expect(result).toContain("tags:");
- expect(result).toContain("# Content");
-});
\ No newline at end of file
diff --git a/tsconfig.build.json b/tsconfig.build.json
new file mode 100644
index 0000000..f08ecbd
--- /dev/null
+++ b/tsconfig.build.json
@@ -0,0 +1,20 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "./dist",
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": false
+ },
+ "include": [
+ "server.ts",
+ "src/**/*"
+ ],
+ "exclude": [
+ "src/**/*.test.ts",
+ "**/*.test.ts",
+ "tests/**/*",
+ "dist",
+ "node_modules"
+ ]
+}
\ No newline at end of file
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..4150f01
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,35 @@
+{
+ "compilerOptions": {
+ "target": "es2022",
+ "module": "es2022",
+ "moduleResolution": "node",
+ "ignoreDeprecations": "6.0",
+ "types": ["node"],
+ "lib": ["es2022"],
+ "allowSyntheticDefaultImports": true,
+ "esModuleInterop": true,
+ "allowJs": true,
+ "declaration": true,
+ "declarationMap": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "exactOptionalPropertyTypes": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedIndexedAccess": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "verbatimModuleSyntax": true
+ },
+ "include": [
+ "src/**/*",
+ "server.ts",
+ "tests/**/*"
+ ],
+ "exclude": [
+ "node_modules",
+ "dist"
+ ]
+}
\ No newline at end of file
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..c8c9d51
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ globals: true,
+ environment: 'node',
+ include: ['src/**/*.test.ts'],
+ },
+});
\ No newline at end of file
diff --git a/website/.gitignore b/website/.gitignore
new file mode 100644
index 0000000..247768e
--- /dev/null
+++ b/website/.gitignore
@@ -0,0 +1,75 @@
+# Dependencies
+node_modules/
+
+# Lock files
+package-lock.json
+bun.lock
+
+# Build outputs
+dist/
+.astro/
+.wrangler/
+
+# Vite cache
+node_modules/.vite/
+node_modules/.vite/deps/
+
+# Environment files
+.env
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+
+# IDE and editor files
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+
+# OS generated files
+.DS_Store
+.DS_Store?
+._*
+.Spotlight-V100
+.Trashes
+ehthumbs.db
+Thumbs.db
+
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Coverage directory used by tools like istanbul
+coverage/
+*.lcov
+
+# nyc test coverage
+.nyc_output
+
+# Temporary folders
+tmp/
+temp/
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# TypeScript cache
+*.tsbuildinfo
+
+# Sync conflicts
+*.sync-conflict-*
\ No newline at end of file
diff --git a/website/AGENTS.md b/website/AGENTS.md
new file mode 100644
index 0000000..ef0258d
--- /dev/null
+++ b/website/AGENTS.md
@@ -0,0 +1,56 @@
+# Website Agent Instructions
+
+## Dual Content Maintenance
+
+This website serves content in two formats. When updating content, **both must be updated together**:
+
+| Format | Location | Audience |
+|--------|----------|----------|
+| HTML pages | `src/components/*.astro`, `src/components/*.tsx` | Browsers (rich, interactive) |
+| Markdown pages | `public/*.md`, `public/llm.txt` | LLMs and AI agents (plain text) |
+
+The markdown files are simplified representations of the same content shown in the HTML pages. They are NOT auto-generated — they are manually maintained static files.
+
+### When adding or changing content:
+1. Update the Astro/React component in `src/components/`
+2. Update the corresponding `.md` file in `public/`
+3. If adding a new page, also update `public/llm.txt` (the index file agents read first)
+
+### File mapping:
+
+| Page | Component(s) | Markdown |
+|------|-------------|----------|
+| Home `/` | `Hero.astro`, `UpdateCallout.astro` | `public/index.md` |
+| Install `/install` | `Terminal.astro` | `public/install.md` |
+| Features `/features` | `FeatureGrid.astro`, `ComparisonTable.astro` | `public/features.md` |
+| Demo `/demo` | `InteractiveDemo.tsx` | `public/demo.md` |
+| How It Works `/how-it-works` | `HowItWorks.astro` | `public/how-it-works.md` |
+
+## Architecture
+
+- **Framework**: Astro 5.x with React islands for interactive components
+- **Styling**: Tailwind CSS with custom dark theme (`tailwind.config.mjs`)
+- **View Transitions**: Astro `ClientRouter` for SPA-like page transitions
+- **Nav**: `Nav.astro` uses `transition:persist` to stay mounted across navigations
+- **Layout**: Single `Layout.astro` wraps all pages with shared head, meta tags, background effects
+
+## Key Technical Details
+
+- The version number is read from the root `../../package.json` (monorepo structure)
+- The nav version badge and Hero version display both pull from `package.json`
+- `InteractiveDemo.tsx` and `ResponseRenderer.tsx` are React client components — use `client:visible` or `client:only="react"` directives
+- Theme color is `#0a0a0a` (near-black) — set on `` element, ` `, ` `, and `::view-transition-group(root)` to prevent white flash during transitions
+
+## Demo Accuracy
+
+The demo examples in `InteractiveDemo.tsx` and `HowItWorks.astro` show actual MCP tool request/response formats. When modifying the MCP server tools (in `server.ts` or `src/`), verify demos still match:
+
+- Tool parameter names and types must match `server.ts` `ListToolsRequestSchema`
+- Response formats must match the `CallToolRequestSchema` handler output
+- The server currently has **15 tools** — this count appears in `FeatureGrid.astro`, `public/features.md`, `public/llm.txt`, and `public/how-it-works.md`
+
+## Commands
+
+- `npm run dev` — Start dev server (http://localhost:4321)
+- `npm run build` — Type-check with `astro check` then build to `dist/`
+- From project root: `npm run website` — Starts the dev server
diff --git a/website/README.md b/website/README.md
new file mode 100644
index 0000000..c47153c
--- /dev/null
+++ b/website/README.md
@@ -0,0 +1,176 @@
+# MCPVault Website
+
+🌐 **Live Site**: [mcpvault.org](https://mcpvault.org)
+
+This is the official landing page and documentation website for [MCPVault](https://github.com/bitbonsai/mcpvault) - a Model Context Protocol (MCP) server that enables AI assistants like Claude to interact securely and intelligently with Obsidian vaults.
+
+## 🎯 Project Objective
+
+This website serves to:
+
+- **Showcase MCPVault**: Demonstrate the capabilities and benefits of connecting AI assistants to Obsidian
+- **Provide Installation Guide**: Clear, step-by-step instructions for setting up MCPVault
+- **Interactive Demo**: Live demonstrations of AI-powered note management
+- **Documentation Hub**: Comprehensive guides, examples, and best practices
+- **Community Resource**: Links to support, contributions, and discussions
+
+## ✨ Features
+
+- **Modern Design**: Beautiful, responsive interface built with Astro and Tailwind CSS
+- **Interactive Demo**: Live terminal simulation showing AI-Obsidian interactions
+- **Code Examples**: Syntax-highlighted configuration examples
+- **Feature Comparison**: Clear comparison tables showing MCPVault advantages
+- **Dark/Light Theme**: Automatic theme switching with system preferences
+- **Performance Optimized**: Static site generation for fast loading
+
+## 🛠️ Tech Stack
+
+- **Framework**: [Astro](https://astro.build/) - Static site generation with component islands
+- **Styling**: [Tailwind CSS](https://tailwindcss.com/) - Utility-first CSS framework
+- **Interactive Components**: [React](https://react.dev/) - For dynamic UI elements
+- **Icons**: [Lucide React](https://lucide.dev/) - Beautiful, customizable icons
+- **Code Highlighting**: [Shiki](https://shiki.style/) - Syntax highlighting
+- **Runtime**: [Bun](https://bun.sh/) - Fast JavaScript runtime and package manager
+
+## 🚀 Getting Started
+
+### Prerequisites
+
+- [Bun](https://bun.sh/) runtime installed
+- Node.js 18+ (for compatibility)
+
+### Installation
+
+1. **Clone the repository**:
+ ```bash
+ git clone https://github.com/bitbonsai/mcpvault.org.git
+ cd mcpvault.org
+ ```
+
+2. **Install dependencies**:
+ ```bash
+ bun install
+ ```
+
+3. **Start development server**:
+ ```bash
+ bun run dev
+ ```
+
+4. **Open your browser**:
+ Navigate to `http://localhost:4321`
+
+### Build Commands
+
+```bash
+# Start development server with hot reload
+bun run dev
+
+# Build for production
+bun run build
+
+# Preview production build
+bun run preview
+
+# Type checking
+bun run astro check
+```
+
+## 📁 Project Structure
+
+```
+src/
+ components/ # Reusable UI components
+ Hero.astro # Main hero section
+ Terminal.astro # Installation terminal
+ FeatureGrid.astro # Features showcase
+ InteractiveDemo.tsx # Live demo component
+ CodeExample.astro # Syntax-highlighted examples
+ ...
+ layouts/
+ Layout.astro # Base layout template
+ pages/
+ index.astro # Homepage
+ about.astro # About page
+ styles/ # Global styles
+```
+
+## 🔧 Development
+
+### Adding New Components
+
+1. Create component in `src/components/`
+2. Use Astro for static content, React for interactivity
+3. Follow existing naming conventions
+4. Import and use in pages or other components
+
+### Styling Guidelines
+
+- Use Tailwind CSS utility classes
+- Follow existing color scheme and spacing
+- Responsive design with mobile-first approach
+- Dark/light theme support using CSS custom properties
+
+### Code Style
+
+- TypeScript for type safety
+- Prettier for formatting
+- Component-scoped styles when needed
+- Semantic HTML structure
+
+## 🌐 Deployment
+
+This site is configured for static deployment and can be hosted on:
+
+- **Vercel** (recommended)
+- **Netlify**
+- **GitHub Pages**
+- **Cloudflare Pages**
+- Any static hosting service
+
+The build output is generated in the `dist/` directory.
+
+## 🤝 Contributing
+
+Contributions are welcome! Please feel free to:
+
+1. **Fork the repository**
+2. **Create a feature branch**: `git checkout -b feature/amazing-feature`
+3. **Make your changes**: Follow the development guidelines
+4. **Commit your changes**: `git commit -m 'Add amazing feature'`
+5. **Push to the branch**: `git push origin feature/amazing-feature`
+6. **Open a Pull Request**
+
+### Contribution Guidelines
+
+- Ensure responsive design across all devices
+- Test your changes thoroughly
+- Follow existing code style and conventions
+- Update documentation if needed
+- Add meaningful commit messages
+
+## 📄 License
+
+This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
+
+## 🔗 Related Projects
+
+- **[MCPVault](https://github.com/bitbonsai/mcpvault)**: The main MCP server implementation
+- **[Model Context Protocol](https://modelcontextprotocol.io)**: Protocol specification and tools
+- **[Obsidian](https://obsidian.md/)**: The knowledge management app
+
+## 💬 Support & Community
+
+- **Issues**: [GitHub Issues](https://github.com/bitbonsai/mcpvault/issues)
+- **Discussions**: [GitHub Discussions](https://github.com/bitbonsai/mcpvault/discussions)
+- **Author**: [@bitbonsai](https://github.com/bitbonsai)
+
+---
+
+
+
+**[Visit Live Site](https://mcpvault.org)** • **[View Source](https://github.com/bitbonsai/mcpvault)**
+
+Made with ❤️ for the Obsidian community
+
+
\ No newline at end of file
diff --git a/website/astro.config.mjs b/website/astro.config.mjs
new file mode 100644
index 0000000..b3b854e
--- /dev/null
+++ b/website/astro.config.mjs
@@ -0,0 +1,33 @@
+import { defineConfig } from 'astro/config';
+import react from '@astrojs/react';
+import tailwind from '@astrojs/tailwind';
+import cloudflare from '@astrojs/cloudflare';
+
+// https://astro.build/config
+export default defineConfig({
+ adapter: cloudflare(),
+ integrations: [react(), tailwind()],
+ build: {
+ inlineStylesheets: 'always' // Inline all CSS to prevent render blocking
+ },
+ vite: {
+ resolve: {
+ alias: {
+ '@components': '/src/components',
+ '@layouts': '/src/layouts'
+ }
+ },
+ build: {
+ rollupOptions: {
+ output: {
+ manualChunks: {
+ // Split syntax highlighter into separate chunk
+ 'syntax-highlighter': ['react-syntax-highlighter']
+ }
+ }
+ },
+ // Increase chunk size warning limit (636 KB unminified, but only 230 KB gzipped)
+ chunkSizeWarningLimit: 700
+ }
+ }
+});
diff --git a/website/package.json b/website/package.json
new file mode 100644
index 0000000..25aed8c
--- /dev/null
+++ b/website/package.json
@@ -0,0 +1,39 @@
+{
+ "name": "mcpvault-site",
+ "type": "module",
+ "version": "0.0.1",
+ "scripts": {
+ "dev": "astro dev",
+ "start": "astro dev",
+ "build": "astro check && astro build",
+ "preview": "astro preview",
+ "newsletter": "tsx src/emails/send-broadcast.ts"
+ },
+ "dependencies": {
+ "@astrojs/react": "^5.0.0",
+ "@astrojs/tailwind": "^6.0.2",
+ "@types/react-syntax-highlighter": "^15.5.13",
+ "aria-query": "^5.3.2",
+ "clsx": "latest",
+ "lucide-react": "latest",
+ "marked": "^17.0.4",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-syntax-highlighter": "^15.6.6",
+ "resend": "^6.9.4",
+ "shiki": "latest",
+ "tailwind-merge": "latest",
+ "tailwindcss": "^3.4.18"
+ },
+ "devDependencies": {
+ "@astrojs/check": "^0.9.7",
+ "@astrojs/cloudflare": "12",
+ "@types/react": "^18.3.27",
+ "@types/react-dom": "^18.3.7",
+ "astro": "^5.18.1",
+ "prettier": "latest",
+ "prettier-plugin-astro": "latest",
+ "tsx": "^4.21.0",
+ "typescript": "^5.9.3"
+ }
+}
diff --git a/website/public/apple-touch-icon.png b/website/public/apple-touch-icon.png
new file mode 100644
index 0000000..d07f67c
Binary files /dev/null and b/website/public/apple-touch-icon.png differ
diff --git a/website/public/demo.md b/website/public/demo.md
new file mode 100644
index 0000000..592ba8d
--- /dev/null
+++ b/website/public/demo.md
@@ -0,0 +1,109 @@
+# MCPVault Interactive Demo
+
+See how AI assistants intelligently interact with your Obsidian vault. These examples show real conversations and outcomes.
+
+## Efficient Editing (patch_note)
+
+**User:** Add the equation for energy-mass equivalence to my physics notes
+
+**AI uses patch_note:**
+```json
+{
+ "path": "Physics/Relativity.md",
+ "oldString": "## Energy and Mass",
+ "newString": "## Energy and Mass\n\nE = mc²"
+}
+```
+
+**Result:** Only the specific section was updated - no full file rewrite needed. 10x faster than rewriting entire file.
+
+## Create Notes (write_note)
+
+**User:** Create a quick note about today's meeting
+
+**AI uses write_note:**
+```json
+{
+ "path": "Meetings/Team Sync.md",
+ "content": "# Team Sync\n\n- Discussed Q1 goals\n- Action items assigned"
+}
+```
+
+**Result:** File created atomically with proper formatting. Ready to open in Obsidian.
+
+## Read Multiple Notes (read_multiple_notes)
+
+**User:** Read all my book club notes and give me a summary
+
+**AI uses read_multiple_notes:**
+```json
+{
+ "paths": [
+ "Reading/The Phoenix Project.md",
+ "Reading/Atomic Habits.md",
+ "Reading/Deep Work.md"
+ ]
+}
+```
+
+**Result:** All 3 notes read in a single request. AI analyzes across multiple documents efficiently.
+
+## Manage Frontmatter (update_frontmatter)
+
+**User:** Update the status and add tags to my project planning note
+
+**AI uses update_frontmatter:**
+```json
+{
+ "path": "Projects/Website Redesign.md",
+ "frontmatter": {
+ "tags": ["project", "web-design", "priority-high"],
+ "status": "in-progress",
+ "created": "2025-01-15",
+ "updated": "2025-01-20"
+ }
+}
+```
+
+**Result:** YAML frontmatter safely updated. Existing fields preserved with original formatting. Note content untouched.
+
+## Search Content (search_notes)
+
+**User:** Search for "React hooks" in my notes
+
+**AI uses search_notes:**
+```json
+{
+ "query": "React hooks",
+ "limit": 10
+}
+```
+
+**Response:**
+```json
+[
+ {
+ "p": "Development/React Best Practices.md",
+ "t": "React Best Practices",
+ "ex": "...State **React hooks** provide...",
+ "mc": 8,
+ "ln": 42
+ },
+ {
+ "p": "Learning/Modern JavaScript.md",
+ "t": "Modern JavaScript",
+ "ex": "...useEffect are common **React hooks**...",
+ "mc": 3,
+ "ln": 156
+ }
+]
+```
+
+**Result:** Found 2 notes with 11 total matches. Token-optimized response with minified field names (p=path, t=title, ex=excerpt, mc=matchCount, ln=lineNumber).
+
+## Technical Notes
+
+- `prettyPrint` defaults to false for minimal token usage
+- All operations are performed atomically
+- Frontmatter is always validated before writing
+- Search returns 21-char context excerpts around matches
diff --git a/website/public/favicon-16x16.png b/website/public/favicon-16x16.png
new file mode 100644
index 0000000..4ac403b
Binary files /dev/null and b/website/public/favicon-16x16.png differ
diff --git a/website/public/favicon-32x32.png b/website/public/favicon-32x32.png
new file mode 100644
index 0000000..38ea049
Binary files /dev/null and b/website/public/favicon-32x32.png differ
diff --git a/website/public/favicon.svg b/website/public/favicon.svg
new file mode 100644
index 0000000..8f840b6
--- /dev/null
+++ b/website/public/favicon.svg
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/website/public/features.md b/website/public/features.md
new file mode 100644
index 0000000..bf01a3b
--- /dev/null
+++ b/website/public/features.md
@@ -0,0 +1,70 @@
+# MCPVault Features
+
+Designed for safety, performance, and developer experience. Every feature gives AI intelligent access without compromising your data.
+
+## Core Features
+
+### Powerful Search
+Fast full-text search across your entire vault with multi-word matching and BM25 relevance ranking. AI can locate notes by name, content, tags, or metadata instantly.
+
+### Safe Frontmatter Handling
+AST-aware YAML updates preserve raw formatting for unmodified fields. Dates, quotes, and time values keep their original form while only changed keys are rewritten.
+
+### File Operations
+Read, write, and manage notes safely. Create, update, and organize your vault with AI assistance.
+
+### Security First
+Path traversal protection and safe file operations. Controlled AI access through MCP protocol.
+
+### Node.js Compatible
+Built with Node.js for broad compatibility and ecosystem support.
+
+### Token Optimized
+Minified JSON field names and compact responses. Less token usage means faster, cheaper API calls.
+
+### TypeScript
+Fully typed for excellent developer experience.
+
+### Open Source
+MIT licensed and community driven.
+
+### Complete Toolkit
+15 MCP tools for vault management: read/write/patch/move files, search content, manage tags, update frontmatter, vault stats, and more. Built for AI assistant integration.
+
+### Multi-Platform
+Works with Claude Desktop, ChatGPT+ Desktop, OpenCode, Gemini CLI, OpenAI Codex, Cursor IDE, Windsurf IDE, IntelliJ IDEA, and other MCP-compatible AI platforms.
+
+## Comparison with Alternatives
+
+| Feature | MCPVault | Other MCPVault (Plugin-based) | Direct File Access |
+|---------|-------------|----------------------------------|-------------------|
+| Setup Complexity | Simple - just point to vault path | Complex - requires Obsidian plugin + API key | Variable |
+| Obsidian Running Required | No | Yes | No |
+| Plugin Dependencies | None | Required (Local REST API plugin) | None |
+| Frontmatter Safety | Protected (AST-aware, preserves unmodified fields) | API-dependent | Can corrupt |
+| Built-in Search | Advanced (full-text + BM25 ranking) | Good (via Obsidian API) | None |
+| Performance | Fast (optimized with batch I/O) | API overhead | Variable |
+| Link Handling | Safe (preserves content/frontmatter on move) | Good | Breaks links |
+| Reliability | High (direct file access) | Plugin-dependent | Variable |
+
+**Summary:** 8/8 features with clear advantage. Zero plugin dependencies. Instant setup time.
+
+## FAQ
+
+### Does my data leave my computer?
+Vault files stay local. MCPVault reads and writes files on your machine. Your AI provider only sees content your client sends.
+
+### Does Obsidian need to be running?
+No. MCPVault works via filesystem access, so Obsidian can be closed.
+
+### Can I use multiple vaults?
+Yes. Configure multiple MCP server entries, one per vault path.
+
+### What file types are supported?
+Read/write tools support `.md`, `.markdown`, `.txt`, `.base`, and `.canvas` files. `list_directory` may show other filenames (like `.png` or `.pdf`), but non-note files are not read as notes.
+
+### Is search semantic?
+No. Search is lexical full-text matching with BM25 ranking, not embedding/vector semantic retrieval.
+
+### What if the AI makes a mistake?
+Use backups or version control. Deletions require explicit path confirmation and all operations stay inside your configured vault.
diff --git a/website/public/how-it-works.md b/website/public/how-it-works.md
new file mode 100644
index 0000000..b352bd2
--- /dev/null
+++ b/website/public/how-it-works.md
@@ -0,0 +1,58 @@
+# How MCPVault Works
+
+Practical prompts you can try with your AI assistant and MCPVault.
+
+## Search & Read Notes
+
+**Prompt:** Find my productivity notes and summarize the key concepts
+
+**What happens:**
+
+1. AI calls `search_notes` with query "productivity", limit 5
+2. Returns BM25-ranked matching notes with paths and match counts
+3. AI calls `read_multiple_notes` with the found paths
+4. AI analyzes the content and provides a summary
+
+**Example response:**
+- Found notes: "Notes/Getting Things Done.md" (5 matches), "Books/Deep Work.md" (4 matches)
+- Key concepts: Time blocking, focused work sessions, eliminating distractions, weekly reviews
+
+## Update Metadata
+
+**Prompt:** Mark all my project notes as completed
+
+**What happens:**
+
+1. AI calls `update_frontmatter` for each project note
+2. Sets status to "completed" and adds completion date
+3. Frontmatter is safely merged with existing fields; unmodified keys keep their raw formatting
+
+**Example request:**
+```json
+{
+ "path": "Projects/Website Redesign.md",
+ "frontmatter": {
+ "status": "completed",
+ "completed": "2025-01-20"
+ }
+}
+```
+
+## Available MCP Tools
+
+| Tool | Description |
+|------|-------------|
+| read_note | Read a single note with frontmatter |
+| write_note | Create or overwrite a note (supports overwrite, append, prepend modes) |
+| patch_note | Efficient partial update via find-and-replace |
+| list_directory | List files and folders in the vault (includes non-note filenames) |
+| delete_note | Delete a note with optional soft-delete: permanent, vault trash, or system trash |
+| search_notes | Search by note name or content across the vault |
+| move_note | Move or rename a note |
+| move_file | Move or rename any file (binary-safe, file-only, requires path confirmation) |
+| read_multiple_notes | Batch read up to 10 notes |
+| update_frontmatter | Safely update YAML frontmatter |
+| get_notes_info | Get metadata without reading content |
+| get_frontmatter | Extract frontmatter only |
+| manage_tags | Add, remove, or list tags |
+| get_vault_stats | Vault statistics: total notes, folders, size, recent files |
diff --git a/website/public/index.md b/website/public/index.md
new file mode 100644
index 0000000..fe182a3
--- /dev/null
+++ b/website/public/index.md
@@ -0,0 +1,45 @@
+# MCPVault - Universal AI Bridge for Obsidian Vaults
+
+**License:** MIT | **Free**
+
+## Your assistant. Your notes. Zero friction.
+
+This MCP server lets Claude, ChatGPT+, and other assistants access your vault. Locally, safe frontmatter, no cloud sync.
+
+- [Get Started](/install) - Install and configure in seconds
+- [View on GitHub](https://github.com/bitbonsai/mcpvault)
+
+## Announcement
+
+- **JUST LAUNCHED - Obsidian Skill:** Smart routing across MCP, Obsidian app context, and Git CLI sync is now live, with preflight checks, targeted setup questions, and safe sync defaults. [See skill flows](/skill.md)
+
+## Recent Updates
+
+- **v0.11.2 (April 2026):** `delete_note` now supports soft-delete with `trashMode`: `none` (permanent), `local` (move to `.trash/` inside vault), or `system` (OS trash). ([#91](https://github.com/bitbonsai/mcpvault/issues/91))
+- **v0.11.1 (April 2026):** Frontmatter updates now use AST-aware YAML preservation. Unmodified fields keep their original formatting: plain dates stay as `YYYY-MM-DD`, quoted strings keep their quotes, and `HH:MM` values are no longer misread as sexagesimal integers. ([#75](https://github.com/bitbonsai/mcpvault/issues/75), [#76](https://github.com/bitbonsai/mcpvault/issues/76), [#77](https://github.com/bitbonsai/mcpvault/issues/77))
+- **v0.11.0 (March 2026):** New `list_all_tags` tool: scan all vault notes for tags with occurrence counts. Obsidian skill now routes to CLI for active file, daily notes, backlinks, and open-in-editor. ([#80](https://github.com/bitbonsai/mcpvault/issues/80))
+- **v0.10.0 (March 2026):** New `createServer()` factory for library consumers. MCPVault can now be imported and connected to any MCP transport. TypeScript declarations and all public types exported. ([#84](https://github.com/bitbonsai/mcpvault/issues/84))
+- **v0.9.1 (March 2026):** Security fix: symlinks inside the vault that point outside the vault boundary are now blocked. ([#78](https://github.com/bitbonsai/mcpvault/issues/78))
+- **v0.9.0 (March 2026):** Package renamed to `@bitbonsai/mcpvault` on npm at Obsidian's request. Update your config: replace `mcpvault` with `@bitbonsai/mcpvault`
+- **v0.8.2 (March 2026):** Trailing-slash vault paths no longer truncate search results ([PR #48](https://github.com/bitbonsai/mcpvault/pull/48)), `get_vault_stats` now handles dotted folder names correctly ([PR #42](https://github.com/bitbonsai/mcpvault/pull/42)), note tools now support `.base` and `.canvas` ([PR #53](https://github.com/bitbonsai/mcpvault/pull/53)), string frontmatter inputs are now handled safely ([PR #47](https://github.com/bitbonsai/mcpvault/pull/47)), vault path is now optional in CLI mode (defaults to current working directory, [#50](https://github.com/bitbonsai/mcpvault/issues/50)), and dependency refreshes for the MCP SDK and Node types are merged ([PR #43](https://github.com/bitbonsai/mcpvault/pull/43), [PR #44](https://github.com/bitbonsai/mcpvault/pull/44))
+- **v0.8.1:** Multi-word BM25 search relevance improvements ([PR #38](https://github.com/bitbonsai/mcpvault/pull/38)), patch_note undefined/null validation hardening ([PR #37](https://github.com/bitbonsai/mcpvault/pull/37)), new `move_file` tool for binary-safe file moves with explicit path confirmation, binary filenames now visible in directory listings ([#21](https://github.com/bitbonsai/mcpvault/issues/21))
+- **v0.7.5:** Search now matches note filenames ([#30](https://github.com/bitbonsai/mcpvault/issues/30)), hidden directories filtered from listings ([#33](https://github.com/bitbonsai/mcpvault/issues/33)), OpenCode install docs ([#35](https://github.com/bitbonsai/mcpvault/issues/35))
+- **v0.7.4:** New get_vault_stats tool + improved error messages with remediation suggestions
+- **v0.7.3:** Bug fix for folder detection with dots in names + dependency updates ([PR #15](https://github.com/bitbonsai/mcpvault/pull/15))
+- **v0.7.2:** Security hardening - TOCTOU fixes, regex injection prevention, comprehensive CI/CD ([PR #12](https://github.com/bitbonsai/mcpvault/pull/12))
+- [See full changelog](https://github.com/bitbonsai/mcpvault/blob/main/CHANGELOG.md)
+
+## Navigation
+
+- [Install](/install.md) - Configuration for all supported platforms
+- [Features](/features.md) - Core features and comparison with alternatives
+- [Demo](/demo.md) - Interactive examples of vault operations
+- [How It Works](/how-it-works.md) - Usage examples with real AI conversations
+- [Skill](/skill.md) - Obsidian skill routing and workflow patterns
+
+## Links
+
+- Repository: https://github.com/bitbonsai/mcpvault
+- npm: https://www.npmjs.com/package/mcpvault
+- Changelog: https://github.com/bitbonsai/mcpvault/blob/main/CHANGELOG.md
+- Website: https://mcpvault.org
diff --git a/website/public/install.md b/website/public/install.md
new file mode 100644
index 0000000..408b972
--- /dev/null
+++ b/website/public/install.md
@@ -0,0 +1,158 @@
+# Install MCPVault
+
+Get MCPVault running in seconds with any MCP-compatible platform.
+
+## Step 1: Configure Your AI Platform
+
+### Claude Desktop / ChatGPT+
+
+Add to your MCP configuration file:
+
+```json
+{
+ "mcpServers": {
+ "obsidian": {
+ "command": "npx",
+ "args": ["@bitbonsai/mcpvault@latest", "/path/to/your/vault"]
+ }
+ }
+}
+```
+
+### Claude Code
+
+```bash
+claude mcp add-json obsidian --scope user '{"type":"stdio","command":"npx","args":["@bitbonsai/mcpvault@latest","/path/to/your/vault"]}'
+```
+
+**Configuration Scopes:**
+- `--scope user` - Available across all your projects (recommended)
+- `--scope project` - Team-shared via .mcp.json file
+- `--scope local` - Current project only (private)
+
+### OpenCode
+
+**Option 1: CLI (interactive)**
+
+```bash
+opencode mcp add
+```
+
+Select **local**, then enter the command: `npx -y @bitbonsai/mcpvault@latest /path/to/your/vault`
+
+**Option 2: Config file**
+
+Add to your `opencode.json` (project root) or `~/.config/opencode/opencode.json` (global):
+
+```json
+{
+ "$schema": "https://opencode.ai/config.json",
+ "mcp": {
+ "obsidian": {
+ "type": "local",
+ "command": ["npx", "-y", "@bitbonsai/mcpvault@latest", "/path/to/your/vault"]
+ }
+ }
+}
+```
+
+### Gemini CLI
+
+**Option 1: CLI**
+
+```bash
+gemini mcp add obsidian -- npx @bitbonsai/mcpvault@latest /path/to/your/vault
+```
+
+**Option 2: Config file**
+
+Add to `~/.gemini/settings.json`:
+
+```json
+{
+ "mcpServers": {
+ "obsidian": {
+ "command": "npx",
+ "args": ["@bitbonsai/mcpvault@latest", "/path/to/your/vault"]
+ }
+ }
+}
+```
+
+### OpenAI Codex (TOML)
+
+```toml
+[mcp_servers.obsidian]
+command = "npx"
+args = ["-y", "@bitbonsai/mcpvault@latest", "/path/to/your/vault"]
+```
+
+### Optional no-path mode (uses current directory)
+
+If your client launches MCPVault from inside your vault folder, you can omit the vault path.
+
+```bash
+npx @bitbonsai/mcpvault@latest
+```
+
+```json
+"args": ["@bitbonsai/mcpvault@latest"]
+```
+
+Supported note file types: `.md`, `.markdown`, `.txt`, `.base`, `.canvas`.
+
+
+Config File Locations (optional)
+
+| Platform | Path |
+|----------|------|
+| Claude Desktop (macOS) | ~/Library/Application Support/Claude/claude_desktop_config.json |
+| Claude Desktop (Windows) | %APPDATA%\Claude\claude_desktop_config.json |
+| Claude Code | ~/.claude.json (user scope) |
+| ChatGPT+ (macOS) | ~/Library/Application Support/ChatGPT/chatgpt_config.json |
+| ChatGPT+ (Windows) | %APPDATA%\ChatGPT\chatgpt_config.json |
+| Gemini CLI | ~/.gemini/settings.json |
+| OpenCode (per project) | opencode.json |
+| OpenCode (global) | ~/.config/opencode/opencode.json |
+| OpenAI Codex (macOS/Linux) | ~/.codex/config.toml |
+| OpenAI Codex (Windows) | %USERPROFILE%\.codex\config.toml |
+
+
+
+
+Need your vault path? (optional)
+
+- macOS: In Finder, right-click your vault folder while holding `Option`, then choose `Copy "..." as Pathname`.
+- Windows: In File Explorer, hold `Shift`, right-click your vault folder, then choose `Copy as path`.
+- Linux: Open a terminal in your vault folder and run `pwd`.
+
+Replace `/path/to/your/vault` with the full absolute path.
+
+
+
+No pre-installation needed! npx automatically downloads and runs the server.
+
+## Step 2: Test with MCP Inspector (Developers)
+
+```bash
+npm install -g @modelcontextprotocol/inspector
+mcp-inspector npx @bitbonsai/mcpvault@latest /path/to/vault
+```
+
+Opens interactive web interface at http://localhost:5173 for testing all MCP methods.
+
+## Platform Compatibility
+
+Works with all MCP-compatible platforms: Claude Desktop, ChatGPT+, Claude Code, OpenCode, Gemini CLI, Cursor IDE, Windsurf, and more.
+
+## Privacy
+
+- Your vault files stay on your computer
+- We never see, store, or transmit your data
+- Only you and your AI assistant can access your notes
+- AI providers (Anthropic, OpenAI) process content you share with them
+- For commercial Claude users: Your data won't be used for AI training
+
+## You're All Set!
+
+Restart your AI platform and you'll see MCPVault connected. Your AI assistant can now safely read, search, and manage your Obsidian vault.
diff --git a/website/public/llm.txt b/website/public/llm.txt
new file mode 100644
index 0000000..5e67d2e
--- /dev/null
+++ b/website/public/llm.txt
@@ -0,0 +1,20 @@
+# MCPVault LLM Map
+
+Serve `.md` versions of every public page for agents. Use the markdown endpoints below for low-token summaries and fall back to HTML if you need richer UI context.
+
+## Primary Routes → Markdown Mirrors
+
+- `/` → `https://mcpvault.org/index.md`
+- `/install` → `https://mcpvault.org/install.md`
+- `/features` → `https://mcpvault.org/features.md`
+- `/demo` → `https://mcpvault.org/demo.md`
+- `/how-it-works` → `https://mcpvault.org/how-it-works.md`
+- `/skill` → `https://mcpvault.org/skill.md`
+
+All markdown files live in `website/public/` inside the repo so they stay in sync with the Astro pages. When citing content, reference the `.md` URL to keep responses reproducible for other agents.
+
+## Additional References
+
+- README (developer docs): `https://github.com/bitbonsai/mcpvault/blob/main/README.md`
+- CHANGELOG (release history): `https://github.com/bitbonsai/mcpvault/blob/main/CHANGELOG.md`
+- npm package: `https://www.npmjs.com/package/@bitbonsai/mcpvault`
diff --git a/website/public/mcp-obsidian-1-min.mp4 b/website/public/mcp-obsidian-1-min.mp4
new file mode 100644
index 0000000..f0731b3
Binary files /dev/null and b/website/public/mcp-obsidian-1-min.mp4 differ
diff --git a/website/public/obsidian-logo.svg b/website/public/obsidian-logo.svg
new file mode 100644
index 0000000..679a19b
--- /dev/null
+++ b/website/public/obsidian-logo.svg
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/website/public/og-image.jpg b/website/public/og-image.jpg
new file mode 100644
index 0000000..a002660
Binary files /dev/null and b/website/public/og-image.jpg differ
diff --git a/website/public/robots.txt b/website/public/robots.txt
new file mode 100644
index 0000000..3b7424f
--- /dev/null
+++ b/website/public/robots.txt
@@ -0,0 +1,37 @@
+User-agent: *
+Allow: /
+
+# Sitemaps
+Sitemap: https://mcpvault.org/sitemap.xml
+
+# AI crawlers
+User-agent: GPTBot
+Allow: /
+
+User-agent: Google-Extended
+Allow: /
+
+User-agent: CCBot
+Allow: /
+
+User-agent: Claude-Web
+Allow: /
+
+User-agent: anthropic-ai
+Allow: /
+
+User-agent: ChatGPT-User
+Allow: /
+
+# LLM-specific crawlers
+User-agent: PerplexityBot
+Allow: /
+
+User-agent: YouBot
+Allow: /
+
+User-agent: FacebookBot
+Allow: /
+
+# Crawl delay for all bots
+Crawl-delay: 1
\ No newline at end of file
diff --git a/website/public/site.webmanifest b/website/public/site.webmanifest
new file mode 100644
index 0000000..2cc839a
--- /dev/null
+++ b/website/public/site.webmanifest
@@ -0,0 +1,16 @@
+{
+ "name": "MCP-Obsidian",
+ "short_name": "MCP-Obsidian",
+ "description": "A lightweight Model Context Protocol (MCP) server for safe Obsidian vault access. This server provides Claude with the ability to read and write notes in an Obsidian vault while preventing YAML frontmatter corruption.",
+ "icons": [
+ {
+ "src": "/favicon.svg",
+ "sizes": "any",
+ "type": "image/svg+xml"
+ }
+ ],
+ "start_url": "/",
+ "display": "standalone",
+ "theme_color": "#8b5cf6",
+ "background_color": "#0d0d0d"
+}
\ No newline at end of file
diff --git a/website/public/skill.md b/website/public/skill.md
new file mode 100644
index 0000000..0c03e89
--- /dev/null
+++ b/website/public/skill.md
@@ -0,0 +1,204 @@
+# Obsidian Skill
+
+Combines MCP server safety with Obsidian CLI context. One skill that routes each operation to the right backend.
+
+## Install
+
+```
+npx skills add bitbonsai/mcpvault
+```
+
+### What can you do with it?
+
+- **Find any note instantly** — Full-text search with relevance ranking across your entire vault.
+- **Organize with smart tags** — Add, remove, and bulk-manage tags and frontmatter across hundreds of notes.
+- **Edit notes safely** — Atomic read/write/patch operations with path sandboxing.
+- **Sync across devices** — Optional git-based sync with no paid subscription required.
+
+## Routing Matrix
+
+Each operation maps to exactly one backend. The skill picks the right one automatically.
+
+| Operation | MCP | Obsidian CLI | Git | Notes |
+|-----------|-----|-------------|-----|-------|
+| Read note | yes | — | — | Safe, sandboxed read via MCP |
+| Write / patch note | yes | — | — | Atomic writes with validation |
+| Search vault | yes | — | — | BM25-ranked full-text search |
+| Manage tags / frontmatter | yes | — | — | Safe YAML merge |
+| List all tags with counts | yes | — | — | Filesystem scan, works headless |
+| Move / rename files | yes | — | — | Path-confirmed moves |
+| Get active file | — | yes | — | Currently focused file in Obsidian |
+| Open note in Obsidian | — | yes | — | Open by path in editor |
+| Daily notes | — | yes | — | Create/read/append with template expansion |
+| Backlinks | — | yes | — | Incoming links to a note |
+| Trigger plugin commands | — | yes | — | Workspace actions, plugin APIs |
+| Sync vault across devices | — | — | yes | Plain git, no Obsidian Sync needed |
+| Automated backup | — | — | yes | Cron / launchd, no UI needed |
+
+## Flow Cheat Sheet
+
+The skill routes by intent:
+
+1. Vault read/write/search/tag/frontmatter requests route to **MCP**.
+2. Open-in-editor or app/plugin-context requests route to **Obsidian CLI/App context**.
+3. Sync/backup/store-with-git requests route to **Git CLI**.
+
+### Git sync flow
+
+1. Preflight: verify git, repo, identity, and remote.
+2. If setup is incomplete, ask one targeted question with a recommended default.
+3. Run safe sync sequence: `git add -A` -> `git commit` (if changes) -> `git pull --rebase` -> `git push`.
+4. Stop on conflicts and provide manual next steps.
+
+## Expanded Flow Playbook
+
+### Routing defaults
+
+- **MCP first** for read/write/search/frontmatter/tags/moves.
+- **Obsidian CLI/App context** for app/editor/plugin-specific behavior.
+- **Git CLI** for sync, backup, and versioning actions.
+
+### Preflight checks before sync
+
+```bash
+git --version
+git rev-parse --is-inside-work-tree
+git config user.name
+git config user.email
+git remote -v
+```
+
+If any check fails, ask one targeted setup question with a recommended default.
+
+### Example conversation
+
+```text
+User: Use git to store my vault and keep it synced.
+Skill: I will run a git preflight first (git, repo, identity, remote), then set up anything missing with one targeted question.
+Skill: Preflight OK. Running sync: git add -A -> git commit (if changes) -> git pull --rebase -> git push.
+Skill: Done. Vault synced to origin/main. No force push used.
+```
+
+## What It Is
+
+**MCP Server** — Handles all file I/O: reading, writing, searching, patching, and organizing notes. Enforces path sandboxing, validates inputs, and performs atomic operations. The safe default for any vault mutation.
+
+**Obsidian CLI** — Bridges the gap for operations that need the running desktop app: opening notes in the editor, triggering plugin commands, exporting to PDF via Obsidian URI schemes.
+
+**Git Sync** — Plain git for vault syncing across devices. No Obsidian Sync subscription required. Works headlessly via cron, launchd, or CI — no app needs to be running.
+
+## Git-Based Vault Sync
+
+An Obsidian vault is just a folder of markdown files. You can `git init` inside it, add a remote, and push/pull like any repo. No proprietary format, no paid service.
+
+### Headless automation
+
+```bash
+# cron job or launchd plist
+cd /path/to/vault
+git add -A
+git commit -m "backup $(date +%Y-%m-%d)"
+git push
+```
+
+No Obsidian CLI required. Works on servers, NAS, or any headless machine.
+
+### Optional: Obsidian Git plugin
+
+The [Obsidian Git](https://github.com/Vinzent03/obsidian-git) community plugin (8k+ stars) adds GUI-driven auto-sync from within the app: auto-commit on interval, pull on startup, push on close, and a source control sidebar.
+
+### Caveats
+
+- **Not real-time** — git syncs on commit intervals, not instantly
+- **Merge conflicts** — editing the same note on two devices before syncing requires manual resolution
+- **Large binaries** — images and PDFs aren't great for git; use `.gitignore` or Git LFS
+- **Workspace files** — add `.obsidian/workspace.json` to `.gitignore`
+
+Recommended .gitignore:
+
+```
+.obsidian/workspace.json
+.obsidian/workspace-mobile.json
+.obsidian/plugins/obsidian-git/data.json
+.trash/
+```
+
+## When To Use
+
+**Trigger phrases:**
+- "search my vault for..." -> MCP
+- "update the frontmatter on..." -> MCP
+- "tag all notes about..." -> MCP
+- "what tags exist in my vault?" -> MCP (list_all_tags)
+- "what file am I looking at?" -> Obsidian CLI
+- "what's the active note?" -> Obsidian CLI
+- "open this note in Obsidian" -> Obsidian CLI
+- "add a task to my daily note" -> Obsidian CLI
+- "what links to this note?" -> Obsidian CLI
+- "sync my vault" -> Git CLI
+- "use git to store my vault" -> Git CLI
+- "move this note to..." -> MCP
+
+**Not a fit for:**
+- General markdown editing (no vault context)
+- Non-Obsidian file management
+- Web-based Obsidian Publish tasks
+
+## Workflow Patterns
+
+### 1. Sequential Orchestration
+
+Chain MCP reads into app actions. Search for a note via MCP, then open it in Obsidian for visual editing.
+
+Steps: search_notes → read_note → open in Obsidian
+
+### 2. Context-Aware Selection
+
+The skill picks the right backend automatically. File operations route through MCP; app-context actions use Obsidian URI schemes.
+
+Steps: Analyze user intent → Route to MCP or App → Execute with safety checks
+
+### 3. Iterative Refinement
+
+Write a draft via MCP, review in Obsidian, then patch corrections back through MCP.
+
+Steps: write_note → review in editor → patch_note
+
+## Safety Defaults
+
+- **Prefer MCP Writes** — All file mutations go through the MCP server, which validates paths, confirms targets, and performs atomic writes.
+- **Confirm Destructive Actions** — Deletes and moves require explicit path confirmation parameters, preventing accidental data loss.
+- **No Shell Interpolation** — Commands use structured arguments, never string-interpolated shell input. No injection vectors.
+- **Sandbox by Default** — MCP tools are scoped to the vault root. Path traversal is blocked at the server level.
+
+## Quick Start
+
+Skill folder structure:
+
+```
+.claude/
+ skills/
+ obsidian/
+ SKILL.md # Gotchas, error recovery, index
+ resources/
+ tool-patterns.md # Per-tool response shapes and recipes
+ obsidian-conventions.md # Vault structure, wikilinks, tags
+ git-sync.md # Git backup/sync workflows
+```
+
+SKILL.md frontmatter:
+
+```yaml
+---
+name: obsidian
+description: >
+ Activate when the user mentions their
+ Obsidian vault, notes, tags, frontmatter,
+ daily notes, backup, or sync. Route
+ operations across MCP, Obsidian CLI/app
+ actions, and git sync with safe defaults.
+metadata:
+ version: "2.0"
+ author: bitbonsai
+---
+```
diff --git a/website/public/video-poster-small.webp b/website/public/video-poster-small.webp
new file mode 100644
index 0000000..8f86249
Binary files /dev/null and b/website/public/video-poster-small.webp differ
diff --git a/website/public/video-poster.jpg b/website/public/video-poster.jpg
new file mode 100644
index 0000000..969bbd2
Binary files /dev/null and b/website/public/video-poster.jpg differ
diff --git a/website/public/video-poster.webp b/website/public/video-poster.webp
new file mode 100644
index 0000000..a3f85a2
Binary files /dev/null and b/website/public/video-poster.webp differ
diff --git a/website/src/components/CodeBlock.tsx b/website/src/components/CodeBlock.tsx
new file mode 100644
index 0000000..2b83644
--- /dev/null
+++ b/website/src/components/CodeBlock.tsx
@@ -0,0 +1,93 @@
+import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
+
+// Catppuccin Mocha theme
+const catppuccinMocha = {
+ 'code[class*="language-"]': {
+ color: '#cdd6f4',
+ background: 'none',
+ fontFamily: 'Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace',
+ fontSize: '1em',
+ textAlign: 'left',
+ whiteSpace: 'pre',
+ wordSpacing: 'normal',
+ wordBreak: 'normal',
+ wordWrap: 'normal',
+ lineHeight: '1.5',
+ tabSize: '4',
+ hyphens: 'none',
+ },
+ 'pre[class*="language-"]': {
+ color: '#cdd6f4',
+ background: '#1e1e2e77',
+ fontFamily: 'Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace',
+ fontSize: '1em',
+ textAlign: 'left',
+ whiteSpace: 'pre',
+ wordSpacing: 'normal',
+ wordBreak: 'normal',
+ wordWrap: 'normal',
+ lineHeight: '1.5',
+ tabSize: '4',
+ hyphens: 'none',
+ padding: '1em',
+ margin: '.5em 0',
+ overflow: 'auto',
+ borderRadius: '0.3em',
+ },
+ 'comment': { color: '#6c7086' },
+ 'prolog': { color: '#6c7086' },
+ 'doctype': { color: '#6c7086' },
+ 'cdata': { color: '#6c7086' },
+ 'punctuation': { color: '#cdd6f4' },
+ 'property': { color: '#89b4fa' },
+ 'tag': { color: '#89b4fa' },
+ 'boolean': { color: '#fab387' },
+ 'number': { color: '#fab387' },
+ 'constant': { color: '#fab387' },
+ 'symbol': { color: '#f5c2e7' },
+ 'deleted': { color: '#f38ba8' },
+ 'selector': { color: '#a6e3a1' },
+ 'attr-name': { color: '#f9e2af' },
+ 'string': { color: '#a6e3a1' },
+ 'char': { color: '#a6e3a1' },
+ 'builtin': { color: '#f5c2e7' },
+ 'inserted': { color: '#a6e3a1' },
+ 'operator': { color: '#94e2d5' },
+ 'entity': { color: '#f9e2af' },
+ 'url': { color: '#89b4fa' },
+ 'variable': { color: '#cdd6f4' },
+ 'atrule': { color: '#f9e2af' },
+ 'attr-value': { color: '#a6e3a1' },
+ 'function': { color: '#89b4fa' },
+ 'class-name': { color: '#f9e2af' },
+ 'keyword': { color: '#cba6f7' },
+ 'regex': { color: '#f5c2e7' },
+ 'important': { color: '#fab387', fontWeight: 'bold' },
+ 'bold': { fontWeight: 'bold' },
+ 'italic': { fontStyle: 'italic' },
+};
+
+interface CodeBlockProps {
+ code: string;
+ language?: string;
+}
+
+export default function CodeBlock({ code, language = 'json' }: CodeBlockProps) {
+ return (
+
+
+ {code}
+
+
+ );
+}
diff --git a/website/src/components/CodeExample.astro b/website/src/components/CodeExample.astro
new file mode 100644
index 0000000..1713a42
--- /dev/null
+++ b/website/src/components/CodeExample.astro
@@ -0,0 +1,367 @@
+---
+import { FolderOpen, Settings, Target, Zap } from 'lucide-react';
+// Code examples for different configuration scenarios
+---
+
+
+
+
+
+
+ Configuration Examples
+
+
+ From basic setup to advanced configurations, here's how to customize MCPVault for your workflow.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/website/src/components/ComparisonTable.astro b/website/src/components/ComparisonTable.astro
new file mode 100644
index 0000000..925c0e9
--- /dev/null
+++ b/website/src/components/ComparisonTable.astro
@@ -0,0 +1,269 @@
+---
+import { AlertTriangle, CheckCircle2, XCircle } from 'lucide-react';
+
+// Comparison data structure
+const features = [
+ {
+ name: 'Setup Complexity',
+ mcpObsidian: { status: 'success', text: 'Simple', description: 'Just point to vault path - works instantly' },
+ otherMcpObsidian: { status: 'error', text: 'Complex', description: 'Requires Obsidian plugin + API key setup' },
+ directAccess: { status: 'warning', text: 'Variable', description: 'Depends on chosen approach' }
+ },
+ {
+ name: 'Obsidian Running Required',
+ mcpObsidian: { status: 'success', text: 'No', description: 'Works with closed Obsidian - direct file access' },
+ otherMcpObsidian: { status: 'error', text: 'Yes', description: 'Obsidian must be running with REST API plugin' },
+ directAccess: { status: 'success', text: 'No', description: 'Direct file manipulation' }
+ },
+ {
+ name: 'Plugin Dependencies',
+ mcpObsidian: { status: 'success', text: 'None', description: 'Zero dependencies - pure file system access' },
+ otherMcpObsidian: { status: 'error', text: 'Required', description: 'Needs Local REST API community plugin' },
+ directAccess: { status: 'success', text: 'None', description: 'No additional software needed' }
+ },
+ {
+ name: 'Frontmatter Safety',
+ mcpObsidian: { status: 'success', text: 'Protected', description: 'AST-aware updates preserve raw formatting for unmodified fields' },
+ otherMcpObsidian: { status: 'warning', text: 'API-dependent', description: 'Safety depends on Obsidian API implementation' },
+ directAccess: { status: 'error', text: 'Can corrupt', description: 'No safety mechanisms' }
+ },
+ {
+ name: 'Built-in Search',
+ mcpObsidian: { status: 'success', text: 'Advanced', description: 'Full-text search with BM25 relevance ranking' },
+ otherMcpObsidian: { status: 'success', text: 'Good', description: 'Uses Obsidian\'s search via API' },
+ directAccess: { status: 'error', text: 'None', description: 'Basic grep at best' }
+ },
+ {
+ name: 'Performance',
+ mcpObsidian: { status: 'success', text: 'Fast', description: 'Optimized for large vaults with batch I/O' },
+ otherMcpObsidian: { status: 'warning', text: 'API overhead', description: 'HTTP API calls add latency' },
+ directAccess: { status: 'warning', text: 'Variable', description: 'Depends on system capabilities' }
+ },
+ {
+ name: 'Link Handling',
+ mcpObsidian: { status: 'success', text: 'Safe', description: 'Preserves note content and frontmatter during moves' },
+ otherMcpObsidian: { status: 'success', text: 'Good', description: 'Leverages Obsidian\'s link management' },
+ directAccess: { status: 'error', text: 'Breaks links', description: 'Can corrupt references' }
+ },
+ {
+ name: 'Reliability',
+ mcpObsidian: { status: 'success', text: 'High', description: 'Direct file access - no intermediary failures' },
+ otherMcpObsidian: { status: 'warning', text: 'Plugin-dependent', description: 'Can fail if Obsidian crashes or plugin issues' },
+ directAccess: { status: 'warning', text: 'Variable', description: 'Depends on implementation quality' }
+ }
+];
+
+const statusIcons: Record = {
+ success: CheckCircle2,
+ warning: AlertTriangle,
+ error: XCircle
+};
+
+const statusColors: Record = {
+ success: 'text-success',
+ warning: 'text-warning',
+ error: 'text-error'
+};
+
+const statusIconColors: Record = {
+ success: 'text-green-500',
+ warning: 'text-orange-400',
+ error: 'text-red-400'
+};
+---
+
+
+
+
+
+
+ Why Choose MCPVault?
+
+
+ See how MCPVault compares to plugin-based alternatives and direct file manipulation.
+ Purpose-built for Obsidian means better safety, performance, and intelligence.
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/website/src/components/FAQ.astro b/website/src/components/FAQ.astro
new file mode 100644
index 0000000..f43b1cf
--- /dev/null
+++ b/website/src/components/FAQ.astro
@@ -0,0 +1,46 @@
+---
+const faqItems = [
+ {
+ q: 'Does my data leave my computer?',
+ a: 'Vault files stay on your machine. MCPVault reads and writes local files directly. Your AI provider only sees content that your client sends to it.'
+ },
+ {
+ q: 'Does Obsidian need to be running?',
+ a: 'No. MCPVault uses filesystem access, so it works even when Obsidian is closed.'
+ },
+ {
+ q: 'Can I use multiple vaults?',
+ a: 'Yes. Configure multiple MCP server entries, each pointing to a different vault path.'
+ },
+ {
+ q: 'What file types are supported?',
+ a: 'Read and write tools support .md, .markdown, .txt, .base, and .canvas files. Directory listing can include other filenames (like images or PDFs), but non-note files are not read as notes.'
+ },
+ {
+ q: 'Is search semantic?',
+ a: 'Search is lexical full-text search with multi-word matching and BM25 relevance ranking. It does not use embeddings or vector indexes.'
+ },
+ {
+ q: 'What if the AI makes a mistake?',
+ a: 'Use version control or vault backups for recovery. Deletions require path confirmation, and write operations are scoped to your selected vault.'
+ }
+];
+---
+
+
+
+
+
FAQ
+
Common setup and safety questions.
+
+
+
+ {faqItems.map((item) => (
+
+ {item.q}
+ {item.a}
+
+ ))}
+
+
+
diff --git a/website/src/components/FeatureCard.astro b/website/src/components/FeatureCard.astro
new file mode 100644
index 0000000..929caa3
--- /dev/null
+++ b/website/src/components/FeatureCard.astro
@@ -0,0 +1,160 @@
+---
+import CodeBlock from './CodeBlock';
+import {
+ BadgeCheck,
+ Coins,
+ FileCode2,
+ FileText,
+ FolderKanban,
+ Globe,
+ Heart,
+ Rocket,
+ Search,
+ Shield,
+ Target,
+ Wrench,
+} from 'lucide-react';
+
+interface Props {
+ title: string;
+ description: string;
+ icon: string;
+ size?: 'small' | 'medium' | 'large';
+ accent?: boolean;
+ codeExample?: string;
+}
+
+const {
+ title,
+ description,
+ icon,
+ size = 'medium',
+ accent = false,
+ codeExample
+} = Astro.props;
+
+const iconMap: Record = {
+ target: Target,
+ search: Search,
+ shield: Shield,
+ file: FileText,
+ rocket: Rocket,
+ node: BadgeCheck,
+ tokens: Coins,
+ typescript: FileCode2,
+ heart: Heart,
+ toolkit: Wrench,
+ platform: Globe,
+ vault: FolderKanban,
+};
+
+const IconComponent = iconMap[icon];
+
+const sizeClasses = {
+ small: 'col-span-1 row-span-1',
+ medium: 'col-span-2 row-span-1 md:col-span-1',
+ large: 'col-span-2 row-span-2'
+};
+
+const cardClasses = `
+ ${sizeClasses[size]}
+ group
+ relative
+ overflow-hidden
+ rounded-2xl
+ border
+ border-border/50
+ bg-card/50
+ backdrop-blur-xl
+ p-6
+ transition-all
+ duration-500
+ hover:border-accent/50
+ hover:shadow-2xl
+ hover:shadow-accent/20
+ hover:scale-[1.02]
+ fade-in-on-scroll
+`;
+---
+
+
+
+
+
+
+ {accent && (
+
+ )}
+
+
+
+
+
+
+ {IconComponent ? (
+
+ ) : (
+ {icon}
+ )}
+
+
+
+ {title}
+
+
+ {description}
+
+
+
+
+
+ {codeExample && size === 'large' && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/website/src/components/FeatureGrid.astro b/website/src/components/FeatureGrid.astro
new file mode 100644
index 0000000..caab198
--- /dev/null
+++ b/website/src/components/FeatureGrid.astro
@@ -0,0 +1,184 @@
+---
+import FeatureCard from "./FeatureCard.astro";
+
+// Features data with code examples
+const searchCodeExample = `[
+ {
+ "p": "Notes/GTD.md",
+ "t": "GTD",
+ "ex": "...getting things done...",
+ "mc": 5,
+ "ln": 12
+ },
+ {
+ "p": "Books/Deep Work.md",
+ "t": "Deep Work",
+ "ex": "...focus and productivity...",
+ "mc": 8,
+ "ln": 45
+ }
+]`;
+
+const frontmatterExample = `{
+ "status": "completed",
+ "tags": ["web", "typescript"],
+ "completed": "2025-01-20"
+}`;
+---
+
+
+
+
+
+
+ Core Features
+
+
+ Designed for safety, performance, and developer experience. Every
+ feature gives AI intelligent access without compromising your data.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/website/src/components/Footer.astro b/website/src/components/Footer.astro
new file mode 100644
index 0000000..959c8f7
--- /dev/null
+++ b/website/src/components/Footer.astro
@@ -0,0 +1,97 @@
+---
+const year = new Date().getFullYear();
+---
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
MCPVault
+
+
+ Give AI safe, intelligent access to your Obsidian vault.
+ Purpose-built for seamless AI-powered note management.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ © {year} bitbonsai. Released under the MIT License.
+
+
+ Made with ❤️ for the Obsidian community
+
+
+
+
diff --git a/website/src/components/Hero.astro b/website/src/components/Hero.astro
new file mode 100644
index 0000000..6155760
--- /dev/null
+++ b/website/src/components/Hero.astro
@@ -0,0 +1,342 @@
+---
+import { Rocket } from 'lucide-react';
+import pkg from '../../../package.json';
+---
+
+
+
+
+
+
+
+
+
+
+
+
+ v{pkg.version}
+
+
+ MIT License
+
+
+ Free
+
+
+
+
+
+ AI + Obsidian =
+
+
+
+
+ Your assistant. Your notes. Zero friction.
+
+
+
+
+ This MCP server lets Claude, ChatGPT+, and other assistants access your vault. Locally, safe frontmatter, no cloud sync.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Your browser does not support the video tag.
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/website/src/components/HowItWorks.astro b/website/src/components/HowItWorks.astro
new file mode 100644
index 0000000..f144da4
--- /dev/null
+++ b/website/src/components/HowItWorks.astro
@@ -0,0 +1,224 @@
+---
+import { Target } from 'lucide-react';
+import ResponseRenderer from './ResponseRenderer';
+
+// Usage examples data with realistic MCP outputs
+const examples = [
+ {
+ title: 'Search & Read Notes',
+ description: 'Find notes and read their content for analysis',
+ tags: ['Search', 'Read', 'Analysis'],
+ prompt: 'Find my productivity notes and summarize the key concepts',
+ response: `MCPVault: Using search_notes...
+
+Request:
+\`\`\`json
+{
+ "query": "productivity",
+ "limit": 5
+}
+\`\`\`
+
+Response:
+\`\`\`json
+[
+ {
+ "p": "Notes/Getting Things Done.md",
+ "t": "Getting Things Done",
+ "ex": "...a system for productivity and...",
+ "mc": 5,
+ "ln": 3
+ },
+ {
+ "p": "Books/Deep Work.md",
+ "t": "Deep Work",
+ "ex": "...focus and productivity in...",
+ "mc": 4,
+ "ln": 12
+ }
+]
+\`\`\`
+
+MCPVault: Using read_multiple_notes...
+
+Request:
+\`\`\`json
+{
+ "paths": [
+ "Notes/Getting Things Done.md",
+ "Books/Deep Work.md"
+ ]
+}
+\`\`\`
+
+Done. I've analyzed the notes. Key concepts include:
+• Time blocking and focused work sessions
+• Eliminating distractions
+• Weekly reviews for planning`
+ },
+ {
+ title: 'Update Metadata',
+ description: 'Batch update frontmatter across multiple notes',
+ tags: ['Frontmatter', 'Batch Updates'],
+ prompt: 'Mark all my project notes as completed',
+ response: `MCPVault: Using update_frontmatter...
+
+Request:
+\`\`\`json
+{
+ "path": "Projects/Website Redesign.md",
+ "frontmatter": {
+ "status": "completed",
+ "completed": "2025-01-20"
+ }
+}
+\`\`\`
+
+Response:
+Successfully updated frontmatter for: Projects/Website Redesign.md
+
+Done. Updated 4 project notes with completion status`
+ }
+];
+---
+
+
+
+
+
+
+ Usage Examples
+
+
+ Practical prompts you can try with your AI assistant and MCPVault.
+
+
+
+
+
+ {examples.map((example, index) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ Example Prompts
+
+
+ Once MCPVault is set up, these prompts demonstrate search, read, and write operations in a single flow.
+
+
+
+
+
+ Get Started Now
+
+
+
+
+
+
+
+
diff --git a/website/src/components/InteractiveDemo.tsx b/website/src/components/InteractiveDemo.tsx
new file mode 100644
index 0000000..fe2df4c
--- /dev/null
+++ b/website/src/components/InteractiveDemo.tsx
@@ -0,0 +1,451 @@
+import { useState } from 'react';
+import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
+import { FilePenLine, LibraryBig, PenSquare, Search, Tags } from 'lucide-react';
+import type { LucideIcon } from 'lucide-react';
+
+// Catppuccin Mocha theme
+const catppuccinMocha = {
+ 'code[class*="language-"]': {
+ color: '#cdd6f4',
+ background: 'none',
+ fontFamily: 'Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace',
+ fontSize: '1em',
+ textAlign: 'left',
+ whiteSpace: 'pre',
+ wordSpacing: 'normal',
+ wordBreak: 'normal',
+ wordWrap: 'normal',
+ lineHeight: '1.5',
+ tabSize: '4',
+ hyphens: 'none',
+ },
+ 'pre[class*="language-"]': {
+ color: '#cdd6f4',
+ background: '#1e1e2e77',
+ fontFamily: 'Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace',
+ fontSize: '1em',
+ textAlign: 'left',
+ whiteSpace: 'pre',
+ wordSpacing: 'normal',
+ wordBreak: 'normal',
+ wordWrap: 'normal',
+ lineHeight: '1.5',
+ tabSize: '4',
+ hyphens: 'none',
+ padding: '1em',
+ margin: '.5em 0',
+ overflow: 'auto',
+ borderRadius: '0.3em',
+ },
+ 'comment': { color: '#6c7086' },
+ 'prolog': { color: '#6c7086' },
+ 'doctype': { color: '#6c7086' },
+ 'cdata': { color: '#6c7086' },
+ 'punctuation': { color: '#cdd6f4' },
+ 'property': { color: '#89b4fa' },
+ 'tag': { color: '#89b4fa' },
+ 'boolean': { color: '#fab387' },
+ 'number': { color: '#fab387' },
+ 'constant': { color: '#fab387' },
+ 'symbol': { color: '#f5c2e7' },
+ 'deleted': { color: '#f38ba8' },
+ 'selector': { color: '#a6e3a1' },
+ 'attr-name': { color: '#f9e2af' },
+ 'string': { color: '#a6e3a1' },
+ 'char': { color: '#a6e3a1' },
+ 'builtin': { color: '#f5c2e7' },
+ 'inserted': { color: '#a6e3a1' },
+ 'operator': { color: '#94e2d5' },
+ 'entity': { color: '#f9e2af' },
+ 'url': { color: '#89b4fa' },
+ 'variable': { color: '#cdd6f4' },
+ 'atrule': { color: '#f9e2af' },
+ 'attr-value': { color: '#a6e3a1' },
+ 'function': { color: '#89b4fa' },
+ 'class-name': { color: '#f9e2af' },
+ 'keyword': { color: '#cba6f7' },
+ 'regex': { color: '#f5c2e7' },
+ 'important': { color: '#fab387', fontWeight: 'bold' },
+ 'bold': { fontWeight: 'bold' },
+ 'italic': { fontStyle: 'italic' },
+};
+
+interface DemoExample {
+ id: string;
+ title: string;
+ icon: LucideIcon;
+ claude: string;
+ response: string;
+ details: string[];
+}
+
+const examples: DemoExample[] = [
+ {
+ id: 'patch',
+ title: 'Efficient Editing',
+ icon: PenSquare,
+ claude: 'Add the equation for energy-mass equivalence to my physics notes',
+ response: `MCP-Obsidian: Using patch_note...
+
+Request:
+\`\`\`json
+{
+ "path": "Physics/Relativity.md",
+ "oldString": "## Energy and Mass",
+ "newString": "## Energy and Mass\\n\\nE = mc²"
+}
+\`\`\`
+
+Response:
+\`\`\`json
+{
+ "success": true,
+ "path": "Physics/Relativity.md",
+ "message": "Successfully replaced 1 occurrence",
+ "matchCount": 1
+}
+\`\`\`
+
+Done! Added Einstein's equation to your notes.
+Only the specific section was updated - no full file rewrite needed.`,
+ details: [
+ 'MCP server used patch_note for efficient partial update',
+ 'Multi-line text replacement performed atomically',
+ 'Frontmatter and formatting preserved perfectly',
+ '10x faster than rewriting entire file',
+ 'replaceAll defaults to false — fails safely if multiple matches found'
+ ]
+ },
+ {
+ id: 'write',
+ title: 'Create Notes',
+ icon: FilePenLine,
+ claude: 'Create a quick note about today\'s meeting',
+ response: `MCP-Obsidian: Using write_note...
+
+Request:
+\`\`\`json
+{
+ "path": "Meetings/Team Sync.md",
+ "content": "# Team Sync\\n\\n- Discussed Q1 goals\\n- Action items assigned"
+}
+\`\`\`
+
+Response:
+Successfully wrote note: Meetings/Team Sync.md (mode: overwrite)
+
+Done! I've created your meeting note.`,
+ details: [
+ 'MCP server wrote the complete note to disk',
+ 'AI generated the structured content',
+ 'File created atomically with proper formatting',
+ 'Ready to open and edit in Obsidian',
+ 'Supports overwrite, append, and prepend modes'
+ ]
+ },
+ {
+ id: 'read_multiple',
+ title: 'Read Multiple Notes',
+ icon: LibraryBig,
+ claude: 'Read all my book club notes and give me a summary',
+ response: `MCP-Obsidian: Using read_multiple_notes...
+
+Request:
+\`\`\`json
+{
+ "paths": [
+ "Reading/The Phoenix Project.md",
+ "Reading/Atomic Habits.md",
+ "Reading/Deep Work.md"
+ ],
+ "prettyPrint": true
+}
+\`\`\`
+
+Response:
+\`\`\`json
+{
+ "ok": [
+ {
+ "path": "Reading/The Phoenix Project.md",
+ "content": "...",
+ "frontmatter": {...}
+ }
+ ],
+ "err": []
+}
+\`\`\`
+
+Done. I've read all 3 notes. Here's a summary:
+Common themes across these books: systems thinking, habit formation, and focus. All emphasize the importance of intentional work practices and continuous improvement.`,
+ details: [
+ 'MCP server read multiple files in one request (max 10)',
+ 'Returns ok (successful) and err (failed) arrays',
+ 'AI analyzes across multiple documents',
+ 'More efficient than reading files one-by-one',
+ 'prettyPrint defaults to false for minimal token usage'
+ ]
+ },
+ {
+ id: 'frontmatter',
+ title: 'Manage Frontmatter',
+ icon: Tags,
+ claude: 'Update the status and add tags to my project planning note',
+ response: `MCP-Obsidian: Using update_frontmatter...
+
+Request:
+\`\`\`json
+{
+ "path": "Projects/Website Redesign.md",
+ "frontmatter": {
+ "tags": [
+ "project",
+ "web-design",
+ "priority-high"
+ ],
+ "status": "in-progress",
+ "created": "2025-01-15",
+ "updated": "2025-01-20"
+ }
+}
+\`\`\`
+
+Response:
+Successfully updated frontmatter for: Projects/Website Redesign.md
+
+Done! I've updated the frontmatter with the new status and tags.`,
+ details: [
+ 'MCP server safely updated YAML frontmatter',
+ 'Merges with existing frontmatter by default (merge: true)',
+ 'Updates are performed atomically',
+ 'Note content remains untouched',
+ 'YAML is validated before writing to prevent corruption'
+ ]
+ },
+ {
+ id: 'search',
+ title: 'Search Content',
+ icon: Search,
+ claude: 'Search for "React hooks" in my notes',
+ response: `MCP-Obsidian: Using search_notes...
+
+Request:
+\`\`\`json
+{
+ "query": "React hooks",
+ "limit": 10,
+ "prettyPrint": true
+}
+\`\`\`
+
+Response:
+\`\`\`json
+[
+ {
+ "p": "Development/React Best Practices.md",
+ "t": "React Best Practices",
+ "ex": "...State **React hooks** provide...",
+ "mc": 8,
+ "ln": 42
+ },
+ {
+ "p": "Learning/Modern JavaScript.md",
+ "t": "Modern JavaScript",
+ "ex": "...useEffect are common **React hooks**...",
+ "mc": 3,
+ "ln": 156
+ }
+]
+\`\`\`
+
+Done. Found 2 notes with 11 total matches across your vault.`,
+ details: [
+ 'MCP server performed full-text search across vault',
+ 'Token-optimized response: p=path, t=title, ex=excerpt, mc=matchCount, ln=lineNumber',
+ 'Returns 21-char context excerpts around matches',
+ 'AI can then read specific files for more details',
+ 'prettyPrint defaults to false for minimal token usage'
+ ]
+ }
+];
+
+export default function InteractiveDemo() {
+ const [activeTab, setActiveTab] = useState(examples[0].id);
+ const [isTyping, setIsTyping] = useState(false);
+
+ const activeExample = examples.find(ex => ex.id === activeTab) || examples[0];
+
+ const handleTabClick = (id: string) => {
+ if (id !== activeTab) {
+ setIsTyping(true);
+ setActiveTab(id);
+ setTimeout(() => setIsTyping(false), 1000);
+ }
+ };
+
+ return (
+
+
+ {/* Section header */}
+
+
+ See It In Action
+
+
+ Watch how AI assistants intelligently interact with your Obsidian vault.
+ These examples show real conversations and outcomes.
+
+
+
+ {/* Tab navigation */}
+
+ {examples.map((example) => (
+ handleTabClick(example.id)}
+ aria-label={`Show ${example.title} demo`}
+ className={`
+ inline-flex items-center gap-2 px-4 py-3 rounded-xl font-medium transition-all duration-300
+ ${activeTab === example.id
+ ? 'bg-accent text-white shadow-lg shadow-accent/25'
+ : 'bg-card/50 text-muted-foreground hover:text-foreground hover:bg-card border border-border/50'
+ }
+ `}
+ >
+
+ {example.title}
+
+ ))}
+
+
+ {/* Demo content */}
+
+
+ {/* Chat header */}
+
+
+
+
+ AI Desktop Tool - MCP-Obsidian Active
+
+
+
+
+
+ {/* Chat content */}
+
+ {/* User message */}
+
+
+ You
+
+
+
+
{activeExample.claude}
+
+
+
+
+ {/* Claude response */}
+
+
+ AI
+
+
+
+ {isTyping ? (
+
+ ) : (
+
+ {activeExample.response.split(/```json\n|```/).map((part, index) => {
+ if (index % 2 === 1) {
+ // This is a JSON block
+ return (
+
+ {part.trim()}
+
+ );
+ }
+ // Regular text
+ return (
+
+ {part}
+
+ );
+ })}
+
+ )}
+
+
+
+
+
+ {/* Technical details */}
+ {!isTyping && (
+
+
+
+
+
+ Technical Details
+
+
+ {activeExample.details.map((detail, index) => (
+
+ ))}
+
+
+ )}
+
+
+
+ {/* Call to action */}
+
+
+
+ );
+}
diff --git a/website/src/components/Nav.astro b/website/src/components/Nav.astro
new file mode 100644
index 0000000..26471f7
--- /dev/null
+++ b/website/src/components/Nav.astro
@@ -0,0 +1,301 @@
+---
+import pkg from '../../../package.json';
+
+const currentPath = Astro.url.pathname;
+
+const navLinks = [
+ { href: '/install', label: 'Install' },
+ { href: '/features', label: 'Features' },
+ { href: '/demo', label: 'Demo' },
+ { href: '/how-it-works', label: 'How It Works' },
+ { href: '/skill', label: 'Skill', isNew: true },
+];
+
+function isActive(href: string) {
+ // Normalize trailing slashes for comparison
+ const normalized = currentPath.replace(/\/$/, '') || '/';
+ return normalized === href;
+}
+---
+
+
+
+
+
+
+
+
+
+
+
diff --git a/website/src/components/NewsletterSignup.astro b/website/src/components/NewsletterSignup.astro
new file mode 100644
index 0000000..7d5fa5c
--- /dev/null
+++ b/website/src/components/NewsletterSignup.astro
@@ -0,0 +1,134 @@
+---
+const highlights = [
+ 'Release announcements before they hit npm',
+ 'Deep dives on new MCP client integrations',
+ 'Security tips for running MCPVault in production',
+];
+---
+
+
+
+
+
+
+
+
+ Stay in the loop
+
+
Ship updates to your inbox
+
+ Get a lightweight email whenever MCPVault ships a new release, adds a client configuration, or shares Obsidian automation recipes.
+
+
+ {highlights.map((item) => (
+
+ ✓
+ {item}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/website/src/components/ResponseRenderer.tsx b/website/src/components/ResponseRenderer.tsx
new file mode 100644
index 0000000..70162d7
--- /dev/null
+++ b/website/src/components/ResponseRenderer.tsx
@@ -0,0 +1,113 @@
+import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
+
+// Catppuccin Mocha theme
+const catppuccinMocha = {
+ 'code[class*="language-"]': {
+ color: '#cdd6f4',
+ background: 'none',
+ fontFamily: 'Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace',
+ fontSize: '1em',
+ textAlign: 'left',
+ whiteSpace: 'pre',
+ wordSpacing: 'normal',
+ wordBreak: 'normal',
+ wordWrap: 'normal',
+ lineHeight: '1.5',
+ tabSize: '4',
+ hyphens: 'none',
+ },
+ 'pre[class*="language-"]': {
+ color: '#cdd6f4',
+ background: '#1e1e2e77',
+ fontFamily: 'Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace',
+ fontSize: '1em',
+ textAlign: 'left',
+ whiteSpace: 'pre',
+ wordSpacing: 'normal',
+ wordBreak: 'normal',
+ wordWrap: 'normal',
+ lineHeight: '1.5',
+ tabSize: '4',
+ hyphens: 'none',
+ padding: '1em',
+ margin: '.5em 0',
+ overflow: 'auto',
+ borderRadius: '0.3em',
+ },
+ 'comment': { color: '#6c7086' },
+ 'prolog': { color: '#6c7086' },
+ 'doctype': { color: '#6c7086' },
+ 'cdata': { color: '#6c7086' },
+ 'punctuation': { color: '#cdd6f4' },
+ 'property': { color: '#89b4fa' },
+ 'tag': { color: '#89b4fa' },
+ 'boolean': { color: '#fab387' },
+ 'number': { color: '#fab387' },
+ 'constant': { color: '#fab387' },
+ 'symbol': { color: '#f5c2e7' },
+ 'deleted': { color: '#f38ba8' },
+ 'selector': { color: '#a6e3a1' },
+ 'attr-name': { color: '#f9e2af' },
+ 'string': { color: '#a6e3a1' },
+ 'char': { color: '#a6e3a1' },
+ 'builtin': { color: '#f5c2e7' },
+ 'inserted': { color: '#a6e3a1' },
+ 'operator': { color: '#94e2d5' },
+ 'entity': { color: '#f9e2af' },
+ 'url': { color: '#89b4fa' },
+ 'variable': { color: '#cdd6f4' },
+ 'atrule': { color: '#f9e2af' },
+ 'attr-value': { color: '#a6e3a1' },
+ 'function': { color: '#89b4fa' },
+ 'class-name': { color: '#f9e2af' },
+ 'keyword': { color: '#cba6f7' },
+ 'regex': { color: '#f5c2e7' },
+ 'important': { color: '#fab387', fontWeight: 'bold' },
+ 'bold': { fontWeight: 'bold' },
+ 'italic': { fontStyle: 'italic' },
+};
+
+interface ResponseRendererProps {
+ response: string;
+}
+
+export default function ResponseRenderer({ response }: ResponseRendererProps) {
+ const parts = response.split(/```json\n|```/);
+
+ return (
+
+ {parts.map((part, index) => {
+ if (index % 2 === 1) {
+ // This is a JSON block
+ return (
+
+ {part.trim()}
+
+ );
+ }
+ // Regular text
+ return (
+
+ {part}
+
+ );
+ })}
+
+ );
+}
diff --git a/website/src/components/SkillsContent.astro b/website/src/components/SkillsContent.astro
new file mode 100644
index 0000000..2057416
--- /dev/null
+++ b/website/src/components/SkillsContent.astro
@@ -0,0 +1,706 @@
+---
+// Routing matrix data
+const routes = [
+ {
+ operation: 'Read note',
+ mcp: true,
+ app: false,
+ git: false,
+ notes: 'Safe, sandboxed read via MCP',
+ },
+ {
+ operation: 'Write / patch note',
+ mcp: true,
+ app: false,
+ git: false,
+ notes: 'Atomic writes with validation',
+ },
+ {
+ operation: 'Search vault',
+ mcp: true,
+ app: false,
+ git: false,
+ notes: 'BM25-ranked full-text search',
+ },
+ {
+ operation: 'Manage tags / frontmatter',
+ mcp: true,
+ app: false,
+ git: false,
+ notes: 'Safe YAML merge',
+ },
+ {
+ operation: 'Move / rename files',
+ mcp: true,
+ app: false,
+ git: false,
+ notes: 'Path-confirmed moves',
+ },
+ {
+ operation: 'Open note in Obsidian',
+ mcp: false,
+ app: true,
+ git: false,
+ notes: 'Requires the desktop app running',
+ },
+ {
+ operation: 'Trigger plugin commands',
+ mcp: false,
+ app: true,
+ git: false,
+ notes: 'Workspace actions, plugin APIs',
+ },
+ {
+ operation: 'Export to PDF',
+ mcp: false,
+ app: true,
+ git: false,
+ notes: 'App-level rendering pipeline',
+ },
+ {
+ operation: 'Sync vault across devices',
+ mcp: false,
+ app: false,
+ git: true,
+ notes: 'Plain git — no Obsidian Sync needed',
+ },
+ {
+ operation: 'Automated backup',
+ mcp: false,
+ app: false,
+ git: true,
+ notes: 'Cron / launchd, no UI needed',
+ },
+];
+
+// Workflow patterns
+const workflows = [
+ {
+ title: 'Sequential Orchestration',
+ description: 'Chain MCP reads into app actions. Search for a note via MCP, then open it in Obsidian for visual editing.',
+ steps: ['MCP: search_notes', 'MCP: read_note', 'App: open in Obsidian'],
+ icon: '1',
+ },
+ {
+ title: 'Context-Aware Selection',
+ description: 'The skill picks the right backend automatically. File operations route through MCP; app-context actions use Obsidian URI schemes.',
+ steps: ['Analyze user intent', 'Route to MCP or App', 'Execute with safety checks'],
+ icon: '2',
+ },
+ {
+ title: 'Iterative Refinement',
+ description: 'Write a draft via MCP, review in Obsidian, then patch corrections back through MCP.',
+ steps: ['MCP: write_note', 'App: review in editor', 'MCP: patch_note'],
+ icon: '3',
+ },
+];
+
+// Safety defaults
+const safetyRules = [
+ {
+ title: 'Prefer MCP Writes',
+ description: 'All file mutations go through the MCP server, which validates paths, confirms targets, and performs atomic writes.',
+ },
+ {
+ title: 'Confirm Destructive Actions',
+ description: 'Deletes and moves require explicit path confirmation parameters, preventing accidental data loss.',
+ },
+ {
+ title: 'No Shell Interpolation',
+ description: 'Commands use structured arguments, never string-interpolated shell input. No injection vectors.',
+ },
+ {
+ title: 'Sandbox by Default',
+ description: 'MCP tools are scoped to the vault root. Path traversal is blocked at the server level.',
+ },
+];
+
+// Trigger phrases
+const triggers = [
+ { phrase: 'search my vault for...', backend: 'MCP' },
+ { phrase: 'update the frontmatter on...', backend: 'MCP' },
+ { phrase: 'tag all notes about...', backend: 'MCP' },
+ { phrase: 'open this note in Obsidian', backend: 'Obsidian CLI' },
+ { phrase: 'sync my vault', backend: 'Git CLI' },
+ { phrase: 'use git to store my vault', backend: 'Git CLI' },
+ { phrase: 'move this note to...', backend: 'MCP' },
+];
+
+const syncFlow = [
+ 'Preflight: verify git, repo, identity, and remote',
+ 'Ask one targeted question if setup is incomplete',
+ 'Run: git add -A -> git commit (if changes) -> git pull --rebase -> git push',
+ 'Stop on conflicts and provide manual next steps',
+];
+
+const exampleConversation = [
+ { role: 'User', text: 'Use git to store my vault and keep it synced.' },
+ { role: 'Skill', text: 'I will run a git preflight first (git, repo, identity, remote), then set up anything missing with one targeted question.' },
+ { role: 'Skill', text: 'Preflight OK. Running sync: git add -A -> git commit (if changes) -> git pull --rebase -> git push.' },
+ { role: 'Skill', text: 'Done. Vault synced to origin/main. No force push used.' },
+];
+
+const negativeTriggers = [
+ 'General markdown editing (no vault context)',
+ 'Non-Obsidian file management',
+ 'Web-based Obsidian Publish tasks',
+];
+
+const installCmd = 'npx skills add bitbonsai/mcpvault';
+---
+
+
+
+
+
+
+
+ Obsidian Skill
+
+
+ Combines MCP server safety with Obsidian CLI context.
+
+ One skill that routes each operation to the right backend.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/website/src/components/Terminal.astro b/website/src/components/Terminal.astro
new file mode 100644
index 0000000..011b1ab
--- /dev/null
+++ b/website/src/components/Terminal.astro
@@ -0,0 +1,1124 @@
+---
+import CodeBlock from "./CodeBlock";
+import {
+ Check,
+ ChevronDown,
+ Compass,
+ Globe,
+ Lightbulb,
+ Lock,
+ Pencil,
+ Search,
+ X,
+ Zap,
+ FolderOpen,
+ Layers,
+} from "lucide-react";
+---
+
+
+
+
+
+
+ Quick Install
+
+
+ Get MCPVault running in seconds with any MCP-compatible platform
+
+
+
+
+
+
+
+
+
+
+
diff --git a/website/src/components/ThemeToggle.tsx b/website/src/components/ThemeToggle.tsx
new file mode 100644
index 0000000..08f24f4
--- /dev/null
+++ b/website/src/components/ThemeToggle.tsx
@@ -0,0 +1,115 @@
+import { useState, useEffect } from 'react';
+
+export default function ThemeToggle() {
+ const [isDark, setIsDark] = useState(true);
+ const [mounted, setMounted] = useState(false);
+
+ // Hydration check
+ useEffect(() => {
+ setMounted(true);
+
+ // Check localStorage first, then system preference
+ let theme = localStorage.getItem('theme');
+
+ if (!theme) {
+ // Check user's system preference
+ theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+ localStorage.setItem('theme', theme);
+ }
+
+ setIsDark(theme === 'dark');
+ }, []);
+
+ const toggleTheme = () => {
+ const newTheme = isDark ? 'light' : 'dark';
+ setIsDark(!isDark);
+ localStorage.setItem('theme', newTheme);
+ document.documentElement.classList.toggle('dark', newTheme === 'dark');
+
+ // Dispatch custom event to notify other parts of the app
+ window.dispatchEvent(new CustomEvent('themeChanged'));
+ };
+
+ // Don't render until mounted to avoid hydration mismatch
+ if (!mounted) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {/* Toggle slider */}
+
+ {/* Icon */}
+
+ {isDark ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+ {/* Background gradient */}
+
+
+ {/* Ambient glow effect */}
+
+
+ {/* Status text */}
+
+ {isDark ? 'Dark mode active' : 'Light mode active'}
+
+
+ );
+}
\ No newline at end of file
diff --git a/website/src/components/UpdateCallout.astro b/website/src/components/UpdateCallout.astro
new file mode 100644
index 0000000..9ebeada
--- /dev/null
+++ b/website/src/components/UpdateCallout.astro
@@ -0,0 +1,303 @@
+---
+import { Rocket } from 'lucide-react';
+---
+
+
+
+
+
+
+
+
+
+ Recent Updates
+
+ v0.11.2
+
+
+
+ Show full history
+
+
+
+
+
+
+
+
+ JUST LAUNCHED
+ Obsidian Skill routing is now live with MCP + Obsidian context + Git CLI sync, including preflight checks and setup guidance.
+ See skill flows
+
+
+
+
+ v0.11.2 (April 2026): delete_note now supports soft-delete with trashMode: none (permanent), local (move to .trash/ inside vault), or system (OS trash).
+ (#91 )
+
+
+
+
+
+
+ v0.11.1 (April 2026): Frontmatter updates now use AST-aware YAML preservation. Unmodified fields keep their original formatting: plain dates stay as YYYY-MM-DD, quoted strings keep their quotes, and HH:MM values are no longer misread as sexagesimal integers.
+ (#77 )
+
+
+ v0.11.0 (March 2026): New list_all_tags tool scans the vault for all frontmatter tags and inline #hashtags with occurrence counts. Obsidian skill now routes to CLI for active file, daily notes, backlinks, and open-in-editor.
+ (#80 )
+
+
+ v0.10.0 (March 2026): New createServer() factory for library consumers. TypeScript declarations exported.
+ (#84 )
+
+
+ v0.9.1 (March 2026): Security fix: symlinks inside the vault that point outside the vault boundary are now blocked.
+ (#78 )
+
+
+ v0.9.0 (March 2026): Package renamed to @bitbonsai/mcpvault on npm at Obsidian's request. Update your config: replace mcpvault with @bitbonsai/mcpvault.
+
+
+ v0.8.2 (March 2026): Trailing-slash vault paths no longer truncate search results
+ (PR #48 ),
+ get_vault_stats now handles dotted folder names correctly
+ (PR #42 ),
+ note tools now support .base and .canvas
+ (PR #53 ),
+ string frontmatter inputs are now handled safely
+ (PR #47 ),
+ vault path is now optional for CLI usage (defaults to current working directory)
+ (#50 ),
+ and dependency refreshes for the MCP SDK and Node types are merged
+ (PR #43 ,
+ PR #44 )
+
+
+ v0.8.1: Multi-word BM25 search relevance improvements
+ (PR #38 ),
+ patch_note undefined/null validation hardening
+ (PR #37 ),
+ new move_file tool for binary-safe file moves with explicit path confirmation,
+ binary filenames now visible in directory listings
+ (#21 )
+
+
+ v0.7.5: Search now matches note filenames,
+ hidden directories filtered from listings, OpenCode install docs
+
+
+ v0.7.4: New get_vault_stats tool + improved error messages with remediation suggestions
+
+
+ v0.7.3: Bug fix for folder detection with dots in names + dependency updates
+ (PR #15)
+
+
+ v0.7.2: Security hardening - TOCTOU fixes, regex injection prevention, comprehensive CI/CD
+ (PR #12)
+
+
+
+
+
+
+
+ See full changelog
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/website/src/content.config.ts b/website/src/content.config.ts
new file mode 100644
index 0000000..d6a74bd
--- /dev/null
+++ b/website/src/content.config.ts
@@ -0,0 +1,2 @@
+// Export collections
+export const collections = {};
\ No newline at end of file
diff --git a/website/src/emails/issues/2026-03-20-v0.10.0.md b/website/src/emails/issues/2026-03-20-v0.10.0.md
new file mode 100644
index 0000000..76c80ee
--- /dev/null
+++ b/website/src/emails/issues/2026-03-20-v0.10.0.md
@@ -0,0 +1,19 @@
+# MCPVault 0.9.1 + 0.10.0
+
+Hey, this is the first email from MCPVault. Keeping it short.
+
+## 0.9.1 — Symlink security fix
+
+Fixes an issue where symlinks inside the vault could resolve to files outside the vault boundary. Normal in-vault symlinks still work. If you use symlinks, update.
+
+## 0.10.0 — Library mode
+
+MCPVault can now be imported as a library. The new `createServer()` function lets you connect any MCP transport, not just stdio. This is the groundwork for Streamable HTTP and OAuth, both coming soon. Full TypeScript types are now exported too.
+
+```
+import { createServer } from '@bitbonsai/mcpvault';
+const server = createServer('/path/to/vault');
+await server.connect(yourTransport);
+```
+
+If you're using `@bitbonsai/mcpvault@latest` in your config (the default), you'll pick these up on next restart. If you pinned a version, update to `@0.10.0`.
diff --git a/website/src/emails/issues/2026-03-24-v0.11.0.md b/website/src/emails/issues/2026-03-24-v0.11.0.md
new file mode 100644
index 0000000..d83c3fa
--- /dev/null
+++ b/website/src/emails/issues/2026-03-24-v0.11.0.md
@@ -0,0 +1,32 @@
+# MCPVault 0.11.0
+
+Two things this week.
+
+## New tool: `list_all_tags`
+
+Scans every note in your vault for frontmatter tags and inline `#hashtags`, returns a deduplicated list sorted by how often each tag appears. Agents can now check what tags exist before creating or organizing notes, so they stop inventing new ones.
+
+```json
+[
+ { "tag": "project", "count": 42 },
+ { "tag": "status/active", "count": 18 },
+ { "tag": "idea", "count": 7 }
+]
+```
+
+Works headless, no Obsidian needed. 15 tools total now.
+
+## Obsidian CLI integration in the skill
+
+The Obsidian skill now routes to the CLI when Obsidian is running. This covers things the MCP server can't do on its own: getting the active file, opening notes in the editor, daily notes with template expansion, backlinks, and unresolved links.
+
+The MCP server stays filesystem-only. The skill handles the routing. If Obsidian isn't running, everything falls back to MCP tools.
+
+Update your skill with `npx skills add bitbonsai/mcpvault` to get the new routing.
+
+## Also shipped since last email
+
+- **v0.10.0**: MCPVault can now be imported as a library. New `createServer()` function for plugging in any MCP transport. TypeScript declarations included.
+- **v0.9.1**: Security fix for symlinks escaping the vault boundary.
+
+If you're using `@bitbonsai/mcpvault@latest`, you already have everything.
diff --git a/website/src/emails/newsletter.html b/website/src/emails/newsletter.html
new file mode 100644
index 0000000..9d39b0b
--- /dev/null
+++ b/website/src/emails/newsletter.html
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{content}}
+
+
+
+
+ MCPVault · Universal AI Bridge for Obsidian
+
+ GitHub
+ ·
+ npm
+
+
+
+
+
+
+
diff --git a/website/src/emails/send-broadcast.ts b/website/src/emails/send-broadcast.ts
new file mode 100644
index 0000000..0ba114c
--- /dev/null
+++ b/website/src/emails/send-broadcast.ts
@@ -0,0 +1,117 @@
+/**
+ * Newsletter broadcast sender.
+ *
+ * Usage:
+ * npx tsx website/src/emails/send-broadcast.ts [--send]
+ *
+ * Examples:
+ * # Preview (dry run, writes HTML to /tmp):
+ * npx tsx website/src/emails/send-broadcast.ts issues/2026-03-20-v0.10.0.md "MCPVault 0.9.1 + 0.10.0"
+ *
+ * # Send for real:
+ * npx tsx website/src/emails/send-broadcast.ts issues/2026-03-20-v0.10.0.md "MCPVault 0.9.1 + 0.10.0" --send
+ *
+ * Env vars (from website/.env):
+ * RESEND_API_KEY
+ * RESEND_AUDIENCE_ID (used as segment_id for broadcasts)
+ */
+
+import { readFileSync, writeFileSync } from 'fs';
+import { resolve, dirname, join, extname } from 'path';
+import { fileURLToPath } from 'url';
+import { Resend } from 'resend';
+import { marked } from 'marked';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+// Load .env from website/
+const envPath = resolve(__dirname, '../../.env');
+try {
+ const envContent = readFileSync(envPath, 'utf-8');
+ for (const line of envContent.split('\n')) {
+ const match = line.match(/^(\w+)=(.+)$/);
+ if (match) process.env[match[1]] = match[2].trim();
+ }
+} catch {
+ // .env not found, rely on existing env vars
+}
+
+const args = process.argv.slice(2);
+const issueFile = args[0];
+const subject = args[1];
+const shouldSend = args.includes('--send');
+
+if (!issueFile || !subject) {
+ console.error('Usage: npx tsx send-broadcast.ts [--send]');
+ process.exit(1);
+}
+
+const apiKey = process.env.RESEND_API_KEY;
+const segmentId = process.env.RESEND_AUDIENCE_ID;
+
+if (!apiKey || !segmentId) {
+ console.error('Missing RESEND_API_KEY or RESEND_AUDIENCE_ID in env.');
+ process.exit(1);
+}
+
+// Read and convert content
+const raw = readFileSync(join(__dirname, issueFile), 'utf-8');
+const isMarkdown = ['.md', '.markdown'].includes(extname(issueFile));
+const contentHtml = isMarkdown ? applyEmailStyles(await marked.parse(raw)) : raw;
+
+// Assemble email
+const template = readFileSync(join(__dirname, 'newsletter.html'), 'utf-8');
+const html = template.replace('{{content}}', contentHtml);
+
+if (!shouldSend) {
+ console.log('--- DRY RUN (pass --send to send for real) ---');
+ console.log(`Subject: ${subject}`);
+ console.log(`Segment: ${segmentId}`);
+ console.log(`From: MCPVault `);
+ console.log('---');
+
+ const previewPath = '/tmp/newsletter-preview.html';
+ writeFileSync(previewPath, html);
+ console.log(`Preview saved to ${previewPath}`);
+ console.log('Open it in a browser to check styling.');
+ process.exit(0);
+}
+
+// Send broadcast
+const resend = new Resend(apiKey);
+
+const { data, error } = await resend.broadcasts.create({
+ segmentId,
+ from: 'MCPVault ',
+ subject,
+ html,
+ name: subject,
+ send: true,
+});
+
+if (error) {
+ console.error('Broadcast failed:', error.message);
+ process.exit(1);
+}
+
+console.log('Broadcast sent:', data?.id);
+
+/**
+ * Apply inline styles to markdown-generated HTML so it looks right in email clients.
+ * Email clients strip
+
+
+
+
+
+
+
+
+ Skip to content
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+