Compare commits
27 Commits
alpha-v202
...
site
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c118472674 | ||
|
|
ced89a42bd | ||
|
|
20a960551f | ||
|
|
2e331315e8 | ||
|
|
d2c15b8469 | ||
|
|
2fe6c5b580 | ||
|
|
2a224f78f8 | ||
|
|
b85d54bb5d | ||
|
|
9c05e17d37 | ||
|
|
cf6ad71fff | ||
|
|
db82aee172 | ||
|
|
c72d833624 | ||
|
|
a3ca78fced | ||
|
|
e912ab9a9f | ||
|
|
49717dc671 | ||
|
|
d9a0a04ddb | ||
|
|
0561fe68b1 | ||
|
|
2f078fab11 | ||
|
|
98fde6571a | ||
|
|
a5c4b8fa20 | ||
|
|
1301653b81 | ||
|
|
ee90b05159 | ||
|
|
b6c11b1468 | ||
|
|
2fbdafa6f2 | ||
|
|
39f44b86aa | ||
|
|
cb3274cdbc | ||
|
|
76d8bba332 |
21
.claude/commands/start.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# /start
|
||||
|
||||
Start Tolaria in Tauri dev mode.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Change to the Tolaria workspace:
|
||||
|
||||
```bash
|
||||
cd /Users/luca/Workspace/tolaria
|
||||
```
|
||||
|
||||
2. Start the native development app:
|
||||
|
||||
```bash
|
||||
pnpm tauri dev
|
||||
```
|
||||
|
||||
3. Keep the command running. Report the Vite local URL once it appears and leave the dev process alive for the user.
|
||||
|
||||
This is a local utility command, not a Laputa task. Do not run `/laputa-next-task`, CodeScene gates, Todoist updates, commits, or pushes for this command unless the user asks separately.
|
||||
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=10.0
|
||||
AVERAGE_THRESHOLD=9.88
|
||||
AVERAGE_THRESHOLD=9.89
|
||||
|
||||
17
.github/workflows/ci.yml
vendored
@@ -65,22 +65,21 @@ jobs:
|
||||
- name: Vite build check
|
||||
run: pnpm build
|
||||
|
||||
# ── 1. Tests ──────────────────────────────────────────────────────────
|
||||
- name: Run frontend tests
|
||||
run: pnpm test
|
||||
- name: Docs build check
|
||||
run: pnpm docs:build
|
||||
|
||||
# ── 1. Coverage-backed tests ──────────────────────────────────────────
|
||||
# The coverage commands run the same frontend and Rust test suites, so keep
|
||||
# them as the canonical test lane instead of running every suite twice.
|
||||
- name: Bundle MCP server resources (required by Tauri build)
|
||||
run: node scripts/bundle-mcp-server.mjs
|
||||
|
||||
- name: Run Rust tests
|
||||
run: cargo test --manifest-path=src-tauri/Cargo.toml
|
||||
|
||||
# ── 2. Coverage (enforced — fails build if thresholds not met) ────────
|
||||
- name: Frontend coverage (≥70% lines/functions/branches/statements)
|
||||
# ── 2. Tests + coverage (enforced — fails build if thresholds not met) ─
|
||||
- name: Frontend tests + coverage (≥70% lines/functions/branches/statements)
|
||||
run: pnpm test:coverage
|
||||
# Thresholds configured in vite.config.ts — exits non-zero if coverage drops
|
||||
|
||||
- name: Rust coverage (≥85% lines)
|
||||
- name: Rust tests + coverage (≥85% lines)
|
||||
run: |
|
||||
cargo llvm-cov \
|
||||
--manifest-path src-tauri/Cargo.toml \
|
||||
|
||||
24
.github/workflows/release-stable.yml
vendored
@@ -689,10 +689,10 @@ jobs:
|
||||
stable-latest.json
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Phase 4: Update GitHub Pages
|
||||
# Phase 4: Update GitHub Pages with docs, release history, and download assets
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
pages:
|
||||
name: Update release history page
|
||||
name: Update docs and release pages
|
||||
needs: [version, release]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -703,23 +703,39 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Build release history page
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build docs and release pages
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
VITEPRESS_BASE="/${GITHUB_REPOSITORY#*/}/" pnpm docs:build
|
||||
mkdir -p _site/alpha _site/stable
|
||||
cp -R site/.vitepress/dist/. _site/
|
||||
gh api -H "Accept: application/vnd.github.html+json" repos/${{ github.repository }}/releases --paginate > _site/releases.json
|
||||
PAGES_URL="https://refactoringhq.github.io/${GITHUB_REPOSITORY#*/}"
|
||||
|
||||
curl -fsSL "${PAGES_URL}/alpha/latest.json" -o _site/alpha/latest.json || echo '{}' > _site/alpha/latest.json
|
||||
gh release download --repo ${{ github.repository }} "${{ needs.version.outputs.tag }}" --pattern "stable-latest.json" --output _site/stable/latest.json || echo '{}' > _site/stable/latest.json
|
||||
bun scripts/build-release-download-page.ts --latest-json _site/stable/latest.json --releases-json _site/releases.json --output-file _site/stable/download/index.html
|
||||
bun scripts/build-release-history-page.ts --releases-json _site/releases.json --output-file _site/index.html
|
||||
bun scripts/build-release-history-page.ts --releases-json _site/releases.json --output-file _site/releases/index.html
|
||||
mkdir -p _site/download
|
||||
cp _site/stable/download/index.html _site/download/index.html
|
||||
|
||||
|
||||
24
.github/workflows/release.yml
vendored
@@ -740,10 +740,10 @@ jobs:
|
||||
alpha-latest.json
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Phase 4: Update GitHub Pages with release history
|
||||
# Phase 4: Update GitHub Pages with docs, release history, and download assets
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
pages:
|
||||
name: Update release history page
|
||||
name: Update docs and release pages
|
||||
needs: [version, release]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -754,23 +754,39 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Build release history page
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build docs and release pages
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
VITEPRESS_BASE="/${GITHUB_REPOSITORY#*/}/" pnpm docs:build
|
||||
mkdir -p _site/alpha _site/stable
|
||||
cp -R site/.vitepress/dist/. _site/
|
||||
gh api -H "Accept: application/vnd.github.html+json" repos/${{ github.repository }}/releases --paginate > _site/releases.json
|
||||
PAGES_URL="https://refactoringhq.github.io/${GITHUB_REPOSITORY#*/}"
|
||||
|
||||
gh release download --repo ${{ github.repository }} "${{ needs.version.outputs.tag }}" --pattern "alpha-latest.json" --output _site/alpha/latest.json || echo '{}' > _site/alpha/latest.json
|
||||
curl -fsSL "${PAGES_URL}/stable/latest.json" -o _site/stable/latest.json || echo '{}' > _site/stable/latest.json
|
||||
bun scripts/build-release-download-page.ts --latest-json _site/stable/latest.json --releases-json _site/releases.json --output-file _site/stable/download/index.html
|
||||
bun scripts/build-release-history-page.ts --releases-json _site/releases.json --output-file _site/index.html
|
||||
bun scripts/build-release-history-page.ts --releases-json _site/releases.json --output-file _site/releases/index.html
|
||||
mkdir -p _site/download
|
||||
cp _site/stable/download/index.html _site/download/index.html
|
||||
|
||||
|
||||
3
.gitignore
vendored
@@ -10,6 +10,9 @@ lerna-debug.log*
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
site/.vitepress/cache/
|
||||
site/.vitepress/dist/
|
||||
_site/
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
|
||||
@@ -33,10 +33,12 @@ You can find some Loom walkthroughs below — they are short and to the point:
|
||||
|
||||
## Getting started
|
||||
|
||||
Download the [latest release here](https://github.com/refactoringhq/tolaria/releases/latest/download/Tolaria.app.tar.gz).
|
||||
Download the [latest release here](https://refactoringhq.github.io/tolaria/download/).
|
||||
|
||||
When you open Tolaria for the first time you get the chance of cloning the [getting started vault](https://github.com/refactoringhq/tolaria-getting-started) — which gives you a walkthrough of the whole app.
|
||||
|
||||
The public user docs live in [`site/`](site/) and are intended for GitHub Pages. Start with [Install Tolaria](site/start/install.md), then [First Launch](site/start/first-launch.md).
|
||||
|
||||
## Open source and local setup
|
||||
|
||||
Tolaria is open source and built with Tauri, React, and TypeScript. If you want to run or contribute to the app locally, here is [how to get started](https://github.com/refactoringhq/tolaria/blob/main/docs/GETTING-STARTED.md). You can also find the gist below 👇
|
||||
|
||||
@@ -68,6 +68,8 @@ Git is a per-vault capability, not a prerequisite for the document model. A vaul
|
||||
|
||||
Plain folders become Git-backed only when the user explicitly runs Git initialization from the setup dialog, status bar, or command palette. Features that depend on Git must check this capability instead of assuming every vault has `.git`.
|
||||
|
||||
Git initialization is intentionally scoped to dedicated vault folders. When the current non-git folder looks like a broad personal root such as Documents, Desktop, or Downloads and does not already carry Tolaria-managed vault markers, `init_git_repo` refuses to run Git and asks the user to select or create a dedicated subfolder instead.
|
||||
|
||||
### VaultEntry
|
||||
|
||||
The core data type representing a single note, defined in Rust (`src-tauri/src/vault/mod.rs`) and TypeScript (`src/types.ts`).
|
||||
@@ -321,20 +323,21 @@ type SidebarSelection =
|
||||
`vault::scan_vault(path)` in `src-tauri/src/vault/mod.rs`:
|
||||
|
||||
1. Validates the path exists and is a directory
|
||||
2. Scans root-level `.md` files (non-recursive)
|
||||
3. Recursively scans protected folders: `type/`, legacy `config/`, `attachments/`
|
||||
4. Files in non-protected subfolders are **not indexed** (flat vault enforcement)
|
||||
5. For each `.md` file, calls `parse_md_file()`:
|
||||
2. Recursively scans non-hidden files while skipping hidden directories such as `.git/`
|
||||
3. For each `.md` file, calls `parse_md_file()`:
|
||||
- Reads content with `fs::read_to_string()`
|
||||
- Parses frontmatter with `gray_matter::Matter::<YAML>`
|
||||
- Extracts title from first `#` heading
|
||||
- Reads entity type from `type:` frontmatter field (`Is A:` accepted as legacy alias); type is never inferred from folder
|
||||
- Parses dates as ISO 8601 to Unix timestamps
|
||||
- Extracts relationships, outgoing links, custom properties, word count, snippet
|
||||
4. For recognized non-markdown text and binary files, emits a minimal `VaultEntry` with `fileKind`
|
||||
5. Sorts by `modified_at` descending
|
||||
6. Skips unparseable files with a warning log
|
||||
|
||||
The folder tree hides only the dedicated `type/` directory, since note types already have their own sidebar section. Default vault folders such as `attachments/` and `views/` remain visible alongside user-created folders.
|
||||
6. Sorts by `modified_at` descending
|
||||
7. Skips unparseable files with a warning log
|
||||
|
||||
Command-facing vault content is filtered through `vault::filter_gitignored_entries`, `vault::filter_gitignored_folders`, and `vault::filter_gitignored_paths` when the app setting `hide_gitignored_files` is enabled. The cache still stores the complete scan; `list_vault`, `reload_vault`, `list_vault_folders`, and search apply the visibility filter at the boundary before React consumes entries. The filter batches paths through `git check-ignore --no-index --stdin`, so negated and specific `.gitignore` patterns follow Git semantics as closely as the app can reasonably support.
|
||||
|
||||
A `vault_health_check` command detects stray files in non-protected subfolders and filename-title mismatches. On vault load, a migration banner offers to flatten stray files to the root via `flatten_vault`.
|
||||
|
||||
@@ -342,6 +345,8 @@ Command-layer path access is fenced to the active vault before file operations r
|
||||
|
||||
UI-only file actions operate on paths that are already selected or indexed in React state. Reveal-in-Finder and external-open calls route through the Tauri opener plugin, while copy-path uses the browser clipboard API; none of those actions mutate vault contents or bypass the backend write boundary.
|
||||
|
||||
The local MCP WebSocket bridge follows the same active-vault boundary. `useVaultSwitcher` calls `sync_mcp_bridge_vault` after the persisted selection loads and after each vault switch; the desktop command starts/restarts the bridge with that vault's canonical path, or stops it when there is no selected vault. MCP Node entrypoints require `VAULT_PATH` and fail clearly instead of falling back to `~/Laputa`.
|
||||
|
||||
### Vault Caching
|
||||
|
||||
`vault::scan_vault_cached(path)` wraps scanning with git-based caching:
|
||||
@@ -444,6 +449,10 @@ interface PulseCommit {
|
||||
- `pullAndPush()`: pulls then auto-pushes for divergence recovery
|
||||
- `ConflictNoteBanner`: inline banner in editor for conflicted notes (Keep mine / Keep theirs)
|
||||
|
||||
### External Vault Refresh
|
||||
|
||||
External vault mutations are any disk writes Tolaria did not just perform through its own save path: Git pulls, AI-agent writes, filesystem watcher events, and edits from another app. These changes must route through `refreshPulledVaultState()` rather than calling `reloadVault()` in isolation. The shared refresh abstraction reloads entries, folders, and saved views together, preserves unsaved active-editor content, reopens a clean active note from disk, and closes the active tab if the file disappeared. `useVaultWatcher` supplies changed filesystem paths to this abstraction after debouncing and after filtering recent app-owned saves.
|
||||
|
||||
`useGitRemoteStatus` is the commit-time companion to `useAutoSync`:
|
||||
- Re-checks `git_remote_status` when the Commit dialog opens and right before submit
|
||||
- Converts `hasRemote: false` into a local-only commit path
|
||||
@@ -509,6 +518,15 @@ Defined in `src/utils/mathMarkdown.ts`, `src/components/editorSchema.tsx`, and s
|
||||
- `serializeMathAwareBlocks()` converts math nodes back to Markdown delimiters before save, raw-mode entry, and editor-position snapshots.
|
||||
- Raw CodeMirror mode always shows the plain Markdown source, so imported technical notes stay editable outside Tolaria.
|
||||
|
||||
### Mermaid Diagrams
|
||||
|
||||
Defined in `src/utils/mermaidMarkdown.ts`, `src/components/MermaidDiagram.tsx`, `src/components/editorSchema.tsx`, and styled in `src/components/EditorTheme.css`:
|
||||
|
||||
- Fenced `mermaid` blocks become `mermaidBlock` schema nodes before BlockNote sees the Markdown body.
|
||||
- Each `mermaidBlock` stores the original fenced Markdown plus the diagram body, so raw-mode entry and saves can restore the canonical source instead of serializing generated SVG.
|
||||
- The rich editor renders diagrams with the `mermaid` package and uses the original source as an inline fallback when rendering fails.
|
||||
- `serializeMermaidAwareBlocks()` wraps the math-aware serializer so math, wikilinks, and diagrams share the same Markdown-first save path.
|
||||
|
||||
### Formatting Surface Policy
|
||||
|
||||
Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tolariaEditorFormattingConfig.ts`:
|
||||
@@ -527,24 +545,25 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["📄 Raw markdown\n(from disk)"] --> B["splitFrontmatter()\n→ yaml + body"]
|
||||
B --> C["preProcessWikilinks(body)\n[[target]] → ‹token›"]
|
||||
C --> D["preProcessMathMarkdown(body)\n$...$ / $$...$$ → tokens"]
|
||||
D --> E["tryParseMarkdownToBlocks()\n→ BlockNote block tree"]
|
||||
E --> F["injectWikilinks + injectMathInBlocks\n tokens → schema nodes"]
|
||||
F --> G["editor.replaceBlocks()\n→ rendered editor"]
|
||||
B --> C["preProcessMermaidMarkdown(body)\nmermaid fence → token"]
|
||||
C --> D["preProcessWikilinks(body)\n[[target]] → ‹token›"]
|
||||
D --> E["preProcessMathMarkdown(body)\n$...$ / $$...$$ → tokens"]
|
||||
E --> F["tryParseMarkdownToBlocks()\n→ BlockNote block tree"]
|
||||
F --> G["injectWikilinks + injectMathInBlocks + injectMermaidInBlocks\n tokens → schema nodes"]
|
||||
G --> H["editor.replaceBlocks()\n→ rendered editor"]
|
||||
|
||||
style A fill:#f8f9fa,stroke:#6c757d,color:#000
|
||||
style G fill:#d4edda,stroke:#28a745,color:#000
|
||||
style H fill:#d4edda,stroke:#28a745,color:#000
|
||||
```
|
||||
|
||||
> Wikilink placeholder tokens use `\u2039` and `\u203A`; math placeholder tokens use ASCII sentinels with URI-encoded LaTeX payloads.
|
||||
> Wikilink placeholder tokens use `\u2039` and `\u203A`; math and Mermaid placeholder tokens use ASCII sentinels with URI-encoded payloads.
|
||||
|
||||
### BlockNote-to-Markdown Pipeline (Save)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["✏️ BlockNote blocks\n(editor state)"] --> B["blocksToMarkdownLossy()"]
|
||||
B --> C["restoreWikilinks + serializeMathAwareBlocks()\nschema nodes → Markdown source"]
|
||||
B --> C["restoreWikilinks + serializeMermaidAwareBlocks()\nschema nodes → Markdown source"]
|
||||
C --> D["prepend frontmatter yaml"]
|
||||
D --> E["invoke('save_note_content')\n→ disk write"]
|
||||
|
||||
@@ -616,7 +635,7 @@ The Inspector panel (`src/components/Inspector.tsx`) is composed of sub-panels:
|
||||
|
||||
### Search
|
||||
|
||||
Keyword-based search scans all vault `.md` files using `walkdir`:
|
||||
Keyword-based search scans all vault `.md` files using `walkdir` and applies the same Gitignored-content visibility filter as vault loading:
|
||||
|
||||
```typescript
|
||||
interface SearchResult {
|
||||
@@ -707,11 +726,12 @@ interface Settings {
|
||||
release_channel: string | null // null = stable default, "alpha" = every-push prerelease feed
|
||||
theme_mode: 'light' | 'dark' | null
|
||||
ui_language: AppLocale | null
|
||||
default_ai_agent: 'claude_code' | 'codex' | null
|
||||
default_ai_agent: 'claude_code' | 'codex' | 'opencode' | 'pi' | null
|
||||
hide_gitignored_files: boolean | null // null = default true
|
||||
}
|
||||
```
|
||||
|
||||
Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. Stored legacy aliases such as `zh-Hans` are normalized to canonical locale codes before the setting reaches React state. `default_ai_agent` is an installation-local preference that selects which supported CLI agent the AI panel, command palette AI mode, and status bar should target by default. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
|
||||
Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. Stored legacy aliases such as `zh-Hans` are normalized to canonical locale codes before the setting reaches React state. `default_ai_agent` is an installation-local preference that selects which supported CLI agent the AI panel, command palette AI mode, and status bar should target by default. `hide_gitignored_files` is also installation-local and defaults to `true`; changing it reloads entries, search, saved views, and folders without restarting. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
|
||||
|
||||
## Telemetry
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ When deciding where to persist a piece of data, ask: **"Would the user want this
|
||||
| Pinned properties per type | API keys (OpenAI, Google) |
|
||||
| Sidebar label overrides | Auto-sync interval |
|
||||
| Property display order | Window size / position |
|
||||
| Vault-authored `.gitignore` patterns | Whether this installation hides Gitignored files |
|
||||
| Any user-visible customization of how content is organized or displayed | Any machine-specific or credential-type setting |
|
||||
|
||||
**Rule:** If the information is about *how the content is structured or presented* and the user would expect it to be consistent wherever they open their vault, store it in the vault (frontmatter of the relevant note, using the `_field` underscore convention for system properties). If it's about *this specific installation of the app*, store it in `~/.config/com.tolaria.app/settings.json` or localStorage.
|
||||
@@ -82,6 +83,11 @@ flowchart LR
|
||||
3. **No orphan state updates**: Never call `updateEntry()` before the corresponding `handleUpdateFrontmatter()` or `handleDeleteProperty()` has resolved. The three functions in `useEntryActions` (`handleCustomizeType`, `handleRenameSection`, `handleToggleTypeVisibility`) follow this rule — disk write first, then state update.
|
||||
4. **Recovery via reload**: If state ever diverges from disk (crash, external edit, race condition), `Reload Vault` (Cmd+K → "Reload Vault") invalidates the cache and does a full filesystem rescan via the `reload_vault` Tauri command, replacing all React state. The `reload_vault_entry` command can re-read a single file.
|
||||
5. **Cache is disposable**: The `reload_vault` command deletes the cache file before rescanning, guaranteeing fresh data. The cache never contains data that doesn't exist on the filesystem.
|
||||
6. **Visibility filters are command-boundary concerns**: Gitignored-content visibility is applied after scanning/caching, before entries, folders, or search results reach React. The cache remains complete so toggling the setting can show ignored content again without rebuilding a different cache shape.
|
||||
|
||||
#### External Change Detection
|
||||
|
||||
The main window starts a native watcher for the active vault through `start_vault_watcher` / `stop_vault_watcher` (`src-tauri/src/vault_watcher.rs`, backed by Rust `notify`). The watcher emits `vault-changed` events for content paths and ignores churn from `.git/`, `node_modules/`, temp files, and `.tolaria-rename-txn`. `useVaultWatcher` batches those events, suppresses recent app-owned saves, and sends the remaining external paths through `refreshPulledVaultState()` so folders, saved views, note-list state, and the clean active editor all refresh under the ADR-0071 unsaved-edit rules. `useVaultLoader.isReloading` drives the status-bar reload spinner for both manual and watcher-triggered reloads.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
@@ -91,6 +97,7 @@ flowchart LR
|
||||
| Frontend | React + TypeScript | React 19, TS 5.9 |
|
||||
| Editor | BlockNote | 0.46.2 |
|
||||
| Code block highlighting | @blocknote/code-block | 0.46.2 |
|
||||
| Diagram rendering | Mermaid | 11.14.0 |
|
||||
| Raw editor | CodeMirror 6 | - |
|
||||
| Styling | Tailwind CSS v4 + CSS variables | 4.1.18 |
|
||||
| UI primitives | Radix UI + shadcn/ui | - |
|
||||
@@ -98,7 +105,8 @@ flowchart LR
|
||||
| Build | Vite | 7.3.1 |
|
||||
| Backend language | Rust (edition 2021) | 1.77.2 |
|
||||
| Frontmatter parsing | gray_matter | 0.2 |
|
||||
| AI (agent panel) | CLI agent adapters (Claude Code + Codex) | - |
|
||||
| Filesystem watcher | notify | 6.1 |
|
||||
| AI (agent panel) | CLI agent adapters (Claude Code + Codex + OpenCode + Pi) | - |
|
||||
| Search | Keyword (walkdir-based file scan) | - |
|
||||
| Localization | App-owned runtime + JSON catalogs (`src/lib/i18n.ts`, `src/lib/locales/*.json`, `lara.yaml`) | English fallback + Lara CLI sync |
|
||||
| MCP | @modelcontextprotocol/sdk | 1.0 |
|
||||
@@ -137,7 +145,7 @@ flowchart TD
|
||||
end
|
||||
|
||||
subgraph EXT["External Services"]
|
||||
CCLI["Claude CLI / Codex CLI\n(agent subprocesses)"]
|
||||
CCLI["Claude / Codex / OpenCode / Pi CLI\n(agent subprocesses)"]
|
||||
MCP["MCP Server\n(ws://9710, 9711)"]
|
||||
GCLI["git CLI\n(system executable)"]
|
||||
REMOTE["Git remotes\n(GitHub/GitLab/Gitea/etc.)"]
|
||||
@@ -180,7 +188,7 @@ flowchart TD
|
||||
|
||||
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), collapsible type-based section groups, and a dedicated folder tree. The folder tree shows user-created folders plus default vault folders such as `attachments/` and `views/`; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`.
|
||||
- **Note List / Pulse View** (200-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Folder-backed lists also show non-Markdown files: previewable image binaries get an image indicator and open in the editor pane, while unsupported binaries remain muted instead of auto-launching an external app. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
|
||||
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count and note-layout toggle, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide-screen left-aligned note column while preserving the same readable max width. Binary image files render through `FilePreview` as ordinary vault files using Tauri asset URLs, with explicit unsupported/broken fallback states and keyboard focus returning to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs.
|
||||
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count and note-layout toggle, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide-screen left-aligned note column while preserving the same readable max width. Binary image files render through `FilePreview` as ordinary vault files using Tauri asset URLs, with explicit unsupported/broken fallback states and keyboard focus returning to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs.
|
||||
- **Inspector / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, instances, backlinks, git history) and AI Agent panel (the selected CLI agent with tool execution). The Sparkle icon in the breadcrumb bar toggles between them. Per-note `icon` is a suggested Inspector property and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).
|
||||
|
||||
Panels are separated by `ResizeHandle` components that support drag-to-resize.
|
||||
@@ -216,10 +224,10 @@ Notes can be opened in separate Tauri windows for focused editing. Secondary win
|
||||
|
||||
Full agent mode — spawns the selected local CLI agent as a subprocess with tool access and MCP vault integration.
|
||||
|
||||
1. **Frontend** (`AiPanel` + `useCliAiAgent` + `aiAgents.ts`) — streaming UI with reasoning blocks, tool action cards, response display, onboarding, and default-agent selection
|
||||
1. **Frontend** (`AiPanel` + `useCliAiAgent` + `aiAgentSession.ts` + `aiAgents.ts`) — one normalized session lifecycle for message state, reasoning blocks, tool action cards, response display, onboarding, and default-agent selection
|
||||
2. **Backend** (`ai_agents.rs`) — normalizes agent availability and streaming, dispatching to per-agent adapters
|
||||
3. **Agent adapters** — Claude Code still uses `claude_cli.rs`; Codex runs through `codex --sandbox workspace-write --ask-for-approval never exec --json` so app-launched sessions can edit the active vault while the dangerous bypass mode remains disabled
|
||||
4. **MCP Integration** — Claude receives the generated MCP config file path, while Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides
|
||||
3. **Agent adapters** — Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, a file/search-only built-in tool list, hidden Windows subprocess launches, and closed stdin for print-mode subprocesses so Windows launches receive EOF; Codex runs through `codex --sandbox workspace-write --ask-for-approval never exec --json`; OpenCode runs through `opencode run --format json`; Pi runs through `pi --mode json --no-session` with `npm:pi-mcp-adapter`. OpenCode and Pi both launch from the active vault cwd with closed stdin and transient MCP config. All app-launched paths use hidden Windows launches and avoid dangerous permission-bypass flags.
|
||||
4. **MCP Integration** — Claude receives the generated MCP config file path, Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides, OpenCode receives it through `OPENCODE_CONFIG_CONTENT`, and Pi receives it through a temporary `PI_CODING_AGENT_DIR/mcp.json` consumed by `pi-mcp-adapter`
|
||||
|
||||
CLI-agent availability intentionally does not depend only on the desktop app's inherited `PATH`. The detectors check the current process path, the user's login shell, and supported local/toolchain install locations such as native `~/.local/bin`, local `~/.claude/local`, Mise/asdf shims, npm-global, Homebrew, Windows `%APPDATA%\npm`/pnpm/Scoop shims, Windows `.exe` launchers, and the macOS Codex app resource path so first-run onboarding works on fresh macOS and Windows installs.
|
||||
|
||||
@@ -236,11 +244,11 @@ sequenceDiagram
|
||||
U->>FE: sendMessage(text, references)
|
||||
FE->>FE: buildContextSnapshot(activeNote, linkedNotes, openTabs)
|
||||
FE->>R: invoke('stream_ai_agent', {agent, message, systemPrompt, vaultPath})
|
||||
R->>R: pick adapter for claude_code or codex
|
||||
R->>R: pick adapter for claude_code, codex, opencode, or pi
|
||||
R->>C: spawn agent with MCP-enabled config
|
||||
|
||||
loop Normalized stream
|
||||
C-->>R: Claude NDJSON or Codex JSONL events
|
||||
C-->>R: Claude NDJSON, Codex JSONL, OpenCode JSON, or Pi JSON events
|
||||
R-->>FE: emit("ai-agent-stream", event)
|
||||
alt TextDelta
|
||||
FE->>FE: accumulate response (revealed on Done)
|
||||
@@ -262,7 +270,7 @@ sequenceDiagram
|
||||
|
||||
#### File Operation Detection
|
||||
|
||||
When the agent writes or edits vault files, `useCliAiAgent` detects this from normalized tool inputs and calls `onFileCreated` or `onFileModified` callbacks to trigger vault reload.
|
||||
When the agent writes or edits vault files, `aiAgentFileOperations.ts` detects this from normalized tool inputs and calls `onFileCreated` or `onFileModified` callbacks to trigger vault reload. Unrecognized write-like operations fall back to a full vault refresh.
|
||||
|
||||
### Context Building
|
||||
|
||||
@@ -282,7 +290,7 @@ Token budget: 60% of 180k context limit (~108k tokens max). Active note gets pri
|
||||
|
||||
### Authentication
|
||||
|
||||
Each CLI agent authenticates itself outside Tolaria. Claude Code uses its existing CLI login; Codex surfaces a friendly prompt to run `codex login` when needed. Tolaria does not store model-provider API keys in app settings.
|
||||
Each CLI agent authenticates itself outside Tolaria. Claude Code uses its existing CLI login; Codex surfaces a friendly prompt to run `codex login` when needed; OpenCode surfaces a friendly prompt to run `opencode auth login` or configure a provider when needed; Pi surfaces a friendly prompt to run `pi /login` or configure a provider API key when needed. Tolaria does not store model-provider API keys in app settings.
|
||||
|
||||
## MCP Server
|
||||
|
||||
@@ -321,13 +329,13 @@ Tolaria can register itself as an MCP server in:
|
||||
- `~/.cursor/mcp.json` (Cursor)
|
||||
- `~/.config/mcp/mcp.json` (generic MCP-compatible clients)
|
||||
|
||||
That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`).
|
||||
That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. Tolaria verifies Node.js is available before writing config, writes an explicit `type: "stdio"` entry, pins `VAULT_PATH` to the active vault, and sets `WS_UI_PORT=9711` so UI actions route back to the desktop app. Packaged builds resolve `mcp-server/` from the installed resource directory next to the executable before falling back to macOS `Resources`, Linux bundle paths, and AppImage paths. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`). The desktop WebSocket bridge is started only when a persisted active vault exists and is resynced from React state on vault changes; no selected vault stops the bridge instead of falling back to `~/Laputa`.
|
||||
|
||||
### Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph MCP["MCP Server (Node.js) — spawned by Tauri on startup"]
|
||||
subgraph MCP["MCP Server (Node.js) — selected-vault scoped"]
|
||||
IDX["index.js"]
|
||||
VAULT["vault.js\n(findMarkdownFiles, readNote, createNote,\nsearchNotes, appendToNote, editNoteFrontmatter,\ndeleteNote, linkNotes, listNotes, vaultContext)"]
|
||||
WSB["ws-bridge.js"]
|
||||
@@ -339,7 +347,7 @@ flowchart TD
|
||||
WSB -->|"port 9711 — UI bridge"| FE["Frontend\n(useAiActivity)"]
|
||||
end
|
||||
|
||||
TAURI["Tauri (mcp.rs)"] -->|"spawn on startup"| MCP
|
||||
TAURI["Tauri bridge lifecycle"] -->|"start/stop/restart with active VAULT_PATH"| MCP
|
||||
UI["Status bar / Command Palette"] -->|"explicit setup or disconnect"| CFG["~/.claude.json\n~/.claude/mcp.json\n~/.cursor/mcp.json\n~/.config/mcp/mcp.json"]
|
||||
```
|
||||
|
||||
@@ -367,11 +375,12 @@ flowchart LR
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `spawn_ws_bridge(vault_path)` | Spawns `ws-bridge.js` as child process with VAULT_PATH env |
|
||||
| `register_mcp(vault_path)` | Writes Tolaria entry to Claude Code, Cursor, and generic MCP configs on explicit user request |
|
||||
| `sync_mcp_bridge_vault(vault_path?)` | Starts, restarts, or stops the desktop WebSocket bridge as the selected vault changes |
|
||||
| `register_mcp(vault_path)` | Verifies Node.js, resolves the packaged `mcp-server/`, and writes Tolaria's explicit stdio entry to Claude Code, Cursor, and generic MCP configs on user request |
|
||||
| `remove_mcp()` | Removes Tolaria's MCP entry from Claude Code, Cursor, and generic MCP configs |
|
||||
| `upsert_mcp_config(path, entry)` | Atomic config file update (create/merge, preserves others) |
|
||||
|
||||
The `WsBridgeChild` state wrapper in `lib.rs` ensures the bridge process is killed on app exit via `RunEvent::Exit` handler. The same desktop layer now keeps the Tauri asset protocol scoped to the active vault instead of every filesystem path.
|
||||
The `WsBridgeChild` state wrapper in `lib.rs` ensures the bridge process is replaced on vault switches, stopped when no active vault is selected, and killed on app exit via the `RunEvent::Exit` handler. The same desktop layer now keeps the Tauri asset protocol scoped to the active vault instead of every filesystem path.
|
||||
|
||||
## Search
|
||||
|
||||
@@ -461,9 +470,9 @@ On first launch, `useOnboarding` checks if the default vault exists. If not, it
|
||||
|
||||
When an opened folder is not yet a git repo, Tolaria shows a dismissible Git setup dialog and a persistent `Git disabled` status-bar warning. Markdown scanning, note browsing, note editing, and search continue normally. Git-dependent surfaces (history, changes, commit, sync, conflict resolution, remotes, AutoGit, and auto-sync) stay unavailable until the user explicitly initializes Git from the dialog, the status-bar warning, or the `Initialize Git for Current Vault` command-palette action.
|
||||
|
||||
When the user enables Git later, `init_git_repo` runs `git init`, ensures Tolaria's default `.gitignore`, stages the vault, and writes the initial `Initial vault setup` commit. That app-managed setup commit explicitly disables commit signing for the single command so inherited global or local `commit.gpgsign` preferences cannot strand onboarding when GPG is missing or misconfigured. Later `git_commit` calls honor the user's signing configuration first, then retry the same app-managed commit once with `commit.gpgsign=false` only when Git reports a signing-helper failure, so working GPG/SSH signing setups continue to sign while broken GPG setups do not create repeated opaque commit failures.
|
||||
When the user enables Git later, `init_git_repo` runs `git init`, ensures Tolaria's default `.gitignore`, stages the vault, and writes the initial `Initial vault setup` commit. Before app-managed setup and remote-connection commits, Tolaria ensures the vault has local `user.name` / `user.email` values, falling back to `Tolaria <vault@tolaria.md>` when the vault has no local Git identity yet. That app-managed setup commit explicitly disables commit signing for the single command so inherited global or local `commit.gpgsign` preferences cannot strand onboarding when GPG is missing or misconfigured. Later `git_commit` calls honor the user's signing configuration first, then retry the same app-managed commit once with `commit.gpgsign=false` only when Git reports a signing-helper failure, so working GPG/SSH signing setups continue to sign while broken GPG setups do not create repeated opaque commit failures.
|
||||
|
||||
Once a vault is ready, `useAiAgentsOnboarding` can show a one-time `AiAgentsOnboardingPrompt`. That prompt reads `useAiAgentsStatus` so first launch surfaces whether Claude Code and Codex are installed, offers per-agent install links when they are missing, and stores local dismissal so the prompt does not repeat on every launch.
|
||||
Once a vault is ready, `useAiAgentsOnboarding` can show a one-time `AiAgentsOnboardingPrompt`. That prompt reads `useAiAgentsStatus` so first launch surfaces whether Claude Code, Codex, OpenCode, and Pi are installed, offers per-agent install links when they are missing, and stores local dismissal so the prompt does not repeat on every launch.
|
||||
|
||||
`useGettingStartedClone` reuses the same parent-folder semantics for the status-bar / command-palette clone action, and `Toast` is rendered through the AI-agents onboarding gate so the resolved destination path stays visible right after a successful clone.
|
||||
|
||||
@@ -587,7 +596,7 @@ flowchart TD
|
||||
|
||||
If the current vault is not a Git repository, Tolaria treats Git as disabled instead of degraded. The status bar replaces changes, commit, sync, remote, conflict, and history controls with a `Git disabled` warning that reopens Git setup. Command registration follows the same state: only `Initialize Git for Current Vault` is available in the Git group, while pull, commit, changes, conflict, and remote commands are hidden. `useAutoSync` is disabled for non-git vaults so the app does not run background Git commands against plain folders.
|
||||
|
||||
The same local-only state enables the explicit Add Remote flow. `AddRemoteModal` is reachable from the `No remote` chip and the command palette. The backend `git_add_remote` command adds `origin`, fetches it, refuses incompatible histories, and only enables tracking after a safe push or fast-forward-compatible check succeeds.
|
||||
The same local-only state enables the explicit Add Remote flow. `AddRemoteModal` is reachable from the `No remote` chip and the command palette. The backend `git_add_remote` command ensures the local author identity, adds `origin`, fetches it, refuses incompatible histories, and only enables tracking after a safe push or fast-forward-compatible check succeeds.
|
||||
|
||||
`useCommitFlow` also exposes `runAutomaticCheckpoint()`, a dialog-free commit path shared by AutoGit and the bottom-bar Commit button. `useAutoGit` watches the last editor activity plus app focus/visibility state, and when the vault is git-backed, all saves are flushed, and no unsaved edits remain, it triggers the same deterministic `Updated N note(s)` / `Updated N file(s)` commit message path after the configured idle or inactive thresholds. The bottom-bar quick action reuses that checkpoint flow after forcing a save first, so manual quick commits and scheduled AutoGit commits stay aligned on message generation and push behavior.
|
||||
|
||||
@@ -611,6 +620,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `parsing.rs` | Text processing: snippet extraction, markdown stripping, ISO date parsing, `extract_title` (H1 → legacy frontmatter → filename), `slug_to_title` |
|
||||
| `title_sync.rs` | Legacy filename → `title` frontmatter sync helper; no longer used by the normal note-open flow |
|
||||
| `cache.rs` | Git-based incremental vault caching (`scan_vault_cached`), git helpers |
|
||||
| `ignored.rs` | Gitignored-content visibility filtering via batched `git check-ignore` |
|
||||
| `filename_rules.rs` | Cross-platform validation for note filenames, folder names, and custom view filenames |
|
||||
| `rename.rs` | `rename_note` / `rename_note_filename` / `move_note_to_folder` — stage crash-safe file moves, update `title` frontmatter when needed, recover unfinished rename transactions, and report backlink rewrite failures |
|
||||
| `image.rs` | `save_image` / `copy_image_to_vault` — save editor image attachments with sanitized filenames |
|
||||
@@ -625,9 +635,10 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `vault/` | Vault scanning, caching, parsing, rename, image, migration |
|
||||
| `frontmatter/` | YAML frontmatter read/write (`mod.rs`, `yaml.rs`, `ops.rs`) |
|
||||
| `git/` | Git operations (`commit.rs`, `status.rs`, `history.rs`, `conflict.rs`, `remote.rs`, `pulse.rs`, `clone.rs`, `connect.rs`) |
|
||||
| `search.rs` | Keyword search — walkdir-based vault file scan |
|
||||
| `search.rs` | Keyword search — walkdir-based vault file scan with Gitignored-content visibility filtering |
|
||||
| `ai_agents.rs` | Shared CLI-agent detection, stream normalization, and adapter dispatch |
|
||||
| `claude_cli.rs` | Claude Code subprocess spawning + NDJSON stream parsing |
|
||||
| `pi_cli.rs`, `pi_config.rs`, `pi_discovery.rs`, `pi_events.rs` | Pi subprocess launch, transient MCP adapter config, discovery, and JSON stream parsing |
|
||||
| `mcp.rs` | MCP server spawning + explicit config registration/removal |
|
||||
| `commands/` | Tauri command handlers (split into submodules) |
|
||||
| `settings.rs` | App settings persistence |
|
||||
@@ -641,7 +652,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `list_vault` | Scan vault (cached) → `Vec<VaultEntry>` |
|
||||
| `list_vault` | Scan vault (cached), then apply Gitignored-content visibility → `Vec<VaultEntry>` |
|
||||
| `get_note_content` | Read note file content |
|
||||
| `save_note_content` | Write note content to disk |
|
||||
| `delete_note` | Permanently delete note from disk (with confirm dialog) |
|
||||
@@ -653,8 +664,9 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `sync_note_title` | Legacy helper: rewrite `title` frontmatter from filename → `bool` (modified); not used by the normal note-open flow |
|
||||
| `batch_archive_notes` | Archive multiple notes |
|
||||
| `batch_delete_notes` | Permanently delete notes from disk |
|
||||
| `reload_vault` | Sync the active vault asset scope, invalidate cache, and full rescan from filesystem → `Vec<VaultEntry>` |
|
||||
| `reload_vault` | Sync the active vault asset scope, invalidate cache, full rescan from filesystem, then apply Gitignored-content visibility → `Vec<VaultEntry>` |
|
||||
| `reload_vault_entry` | Re-read a single file from disk → `VaultEntry` |
|
||||
| `start_vault_watcher` / `stop_vault_watcher` | Start or stop native active-vault filesystem change events |
|
||||
| `check_vault_exists` | Check if vault path exists |
|
||||
| `create_empty_vault` | Create a git-backed vault, then seed root `AGENTS.md`, `CLAUDE.md`, `type.md`, and `note.md` defaults |
|
||||
| `create_getting_started_vault` | Clone the public Getting Started vault, refresh Tolaria-managed guidance/config defaults, and keep the cloned repo clean |
|
||||
@@ -709,13 +721,13 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `stream_claude_chat` | Claude CLI chat mode (streaming) |
|
||||
| `stream_claude_agent` | Claude CLI agent mode (streaming + tools) |
|
||||
| `check_claude_cli` | Check if Claude CLI is available |
|
||||
| `get_ai_agents_status` | Check Claude Code + Codex availability |
|
||||
| `stream_ai_agent` | Stream the selected CLI agent through the normalized event layer |
|
||||
| `get_ai_agents_status` | Check Claude Code + Codex + OpenCode + Pi availability |
|
||||
| `stream_ai_agent` | Stream Claude Code, Codex, OpenCode, or Pi through the normalized agent event layer |
|
||||
| `register_mcp_tools` | Register MCP in Claude/Cursor/generic config for the active vault |
|
||||
| `remove_mcp_tools` | Remove Tolaria's MCP entry from Claude/Cursor/generic config |
|
||||
| `check_mcp_status` | Check whether the active vault is explicitly registered in Claude/Cursor/generic config |
|
||||
| `sync_mcp_bridge_vault` | Sync the desktop WebSocket bridge process to the selected vault, or stop it when no vault is selected |
|
||||
|
||||
The desktop MCP WebSocket bridge is intentionally local-only. `mcp-server/ws-bridge.js` binds both bridge ports to loopback, rejects non-loopback clients, accepts browser/Tauri origins only on the UI bridge, and rejects browser-origin requests on the tool bridge so remote pages cannot drive vault tools directly.
|
||||
|
||||
@@ -773,13 +785,13 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
|
||||
| `useTabManagement` | Navigation history, note switching | Note navigation lifecycle |
|
||||
| `useVaultSwitcher` | `vaultPath`, `extraVaults` | Vault switching |
|
||||
| `useTheme` | Editor theme CSS vars and theme-mode bridge | Editor typography and app theme runtime |
|
||||
| `useCliAiAgent` | `messages`, `status`, tool actions | Selected AI agent conversation |
|
||||
| `useCliAiAgent` | `messages`, `status`, tool actions | Selected AI agent conversation backed by the shared session pipeline |
|
||||
| `useAutoSync` | Sync interval, pull/push state | Git auto-sync |
|
||||
| `useAutoGit` | Last activity timestamp, idle/inactive checkpoint triggers | Automatic commit/push checkpoints |
|
||||
| `useCommitFlow` | Commit dialog state, shared manual/automatic checkpoint runner | Git commit/push orchestration |
|
||||
| `useGitRemoteStatus` | `remoteStatus`, `refreshRemoteStatus()` | On-demand remote detection for commit UI |
|
||||
| `useUnifiedSearch` | Query, results, loading state | Keyword search |
|
||||
| `useSettings` | App settings (telemetry, release channel, theme mode, UI language, auto-sync interval, AutoGit thresholds, default AI agent) | Persistent settings |
|
||||
| `useSettings` | App settings (telemetry, release channel, theme mode, UI language, auto-sync interval, AutoGit thresholds, default AI agent, Gitignored-content visibility) | Persistent settings |
|
||||
| `useVaultConfig` | Per-vault UI preferences | Vault-specific config |
|
||||
| `appCommandDispatcher` | Canonical shortcut/menu command IDs | Shared execution path for renderer and native menu commands |
|
||||
|
||||
@@ -985,7 +997,7 @@ Desktop-only modules gated at the crate level:
|
||||
Desktop-only features gated at the function level in `commands/`:
|
||||
- Git operations (commit, pull, push, status, history, diff, conflicts)
|
||||
- Clone-by-URL via system git (`clone_repo`)
|
||||
- CLI AI agent streaming (Claude, Codex)
|
||||
- CLI AI agent streaming (Claude, Codex, OpenCode, Pi)
|
||||
- MCP registration and status
|
||||
- Menu state updates
|
||||
|
||||
|
||||
@@ -106,16 +106,15 @@ tolaria/
|
||||
│ │ └── ui/ # shadcn/ui primitives
|
||||
│ │ ├── button.tsx, dialog.tsx, input.tsx, ...
|
||||
│ │
|
||||
│ ├── hooks/ # Custom React hooks (~87 files)
|
||||
│ ├── hooks/ # Custom React hooks (~86 files)
|
||||
│ │ ├── useVaultLoader.ts # Loads vault entries + content
|
||||
│ │ ├── useVaultSwitcher.ts # Multi-vault management
|
||||
│ │ ├── useVaultConfig.ts # Per-vault UI settings
|
||||
│ │ ├── useNoteActions.ts # Composes creation + rename + frontmatter
|
||||
│ │ ├── useNoteCreation.ts # Note/type creation
|
||||
│ │ ├── useNoteRename.ts # Note renaming + wikilink updates
|
||||
│ │ ├── useAiAgent.ts # Legacy Claude-specific stream helpers reused by the shared agent hook
|
||||
│ │ ├── useCliAiAgent.ts # Selected AI agent state + normalized tool tracking
|
||||
│ │ ├── useAiAgentsStatus.ts # Claude/Codex availability polling
|
||||
│ │ ├── useCliAiAgent.ts # Selected AI agent state + normalized session pipeline
|
||||
│ │ ├── useAiAgentsStatus.ts # Claude/Codex/OpenCode/Pi availability polling
|
||||
│ │ ├── useAiAgentPreferences.ts # Default-agent persistence + cycling
|
||||
│ │ ├── useAiActivity.ts # MCP UI bridge listener
|
||||
│ │ ├── useAutoSync.ts # Auto git pull/push
|
||||
@@ -191,6 +190,7 @@ tolaria/
|
||||
│ │ ├── search.rs # Keyword search (walkdir-based)
|
||||
│ │ ├── ai_agents.rs # Shared CLI-agent detection + stream adapters
|
||||
│ │ ├── claude_cli.rs # Claude CLI subprocess management
|
||||
│ │ ├── pi_cli.rs # Pi CLI subprocess management
|
||||
│ │ ├── mcp.rs # MCP server lifecycle + explicit config registration/removal
|
||||
│ │ ├── app_updater.rs # Alpha/stable updater endpoint selection
|
||||
│ │ ├── settings.rs # App settings persistence
|
||||
@@ -261,8 +261,9 @@ tolaria/
|
||||
| `src-tauri/src/frontmatter/ops.rs` | YAML manipulation — how properties are updated/deleted in files. |
|
||||
| `src-tauri/src/git/` | All git operations (clone, commit, pull, push, conflicts, pulse, add-remote). |
|
||||
| `src-tauri/src/search.rs` | Keyword search — scans vault files with walkdir. |
|
||||
| `src-tauri/src/ai_agents.rs` | Shared CLI-agent availability checks, safe-default Codex adapter, and stream normalization. |
|
||||
| `src-tauri/src/ai_agents.rs` | Shared CLI-agent availability checks, adapter dispatch, and stream normalization. |
|
||||
| `src-tauri/src/claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing. |
|
||||
| `src-tauri/src/pi_cli.rs` | Pi subprocess spawning through JSON mode and transient MCP adapter config. |
|
||||
| `src-tauri/src/app_updater.rs` | Desktop updater bridge — selects alpha/stable manifests and streams install progress. |
|
||||
|
||||
### Editor
|
||||
@@ -282,7 +283,9 @@ tolaria/
|
||||
| File | Why it matters |
|
||||
|------|---------------|
|
||||
| `src/components/AiPanel.tsx` | AI agent panel — selected CLI agent with tool execution, reasoning, and actions. |
|
||||
| `src/hooks/useCliAiAgent.ts` | Agent state: messages, streaming, tool tracking, file detection. |
|
||||
| `src/hooks/useCliAiAgent.ts` | Thin React owner for the selected CLI agent session state. |
|
||||
| `src/lib/aiAgentSession.ts` | Single message/session lifecycle for prompt normalization, history, streaming, and reset behavior. |
|
||||
| `src/lib/aiAgentFileOperations.ts` | Detects agent-created or modified vault files from normalized tool inputs. |
|
||||
| `src/lib/aiAgents.ts` | Supported agent definitions, status normalization, and default-agent helpers. |
|
||||
| `src/utils/ai-context.ts` | Context snapshot builder for AI conversations. |
|
||||
|
||||
@@ -417,5 +420,11 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
|
||||
1. **Agent system prompt**: Edit `src/utils/ai-agent.ts` (inline system prompt string)
|
||||
2. **Context building**: Edit `src/utils/ai-context.ts` for what data is sent to the agent
|
||||
3. **Tool action display**: Edit `src/components/AiActionCard.tsx`
|
||||
4. **Claude CLI arguments**: Edit `src-tauri/src/claude_cli.rs` (`run_agent_stream()`)
|
||||
5. **Shared agent adapters / Codex args**: Edit `src-tauri/src/ai_agents.rs` (keep Codex sandboxed with active-vault `workspace-write`; do not use the dangerous bypass unless an ADR explicitly designs a new mode)
|
||||
4. **Claude CLI arguments**: Edit `src-tauri/src/claude_cli.rs` (`run_agent_stream()`; keep app-managed launches on strict Tolaria MCP config, `acceptEdits`, and the scoped file/search tool list)
|
||||
5. **Shared agent adapters / Codex/Pi args**: Edit `src-tauri/src/ai_agents.rs` plus the per-agent adapter modules (keep Codex sandboxed with active-vault `workspace-write`, keep Pi on transient MCP config, and do not use dangerous permission bypasses unless an ADR explicitly designs a new mode)
|
||||
|
||||
### Work with external MCP setup
|
||||
|
||||
1. **Backend registration/status**: Edit `src-tauri/src/mcp.rs`; registration must verify Node.js first, resolve the packaged `mcp-server/` for macOS, Windows, Linux, and AppImage installs, and write an explicit stdio entry with `VAULT_PATH` plus `WS_UI_PORT=9711`
|
||||
2. **Setup dialog copy/actions**: Edit `src/components/McpSetupDialog.tsx`; users should see the Node.js prerequisite and the manual config shape before Tolaria writes third-party config files
|
||||
3. **Status hook/toasts**: Edit `src/hooks/useMcpStatus.ts` when setup, reconnect, disconnect, or failure messaging changes
|
||||
|
||||
34
docs/PUBLIC-DOCS-PLAN.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Public Docs Plan
|
||||
|
||||
This document records the phase 1 information architecture for public Tolaria documentation. The public docs source lives in `site/`; the existing `docs/` directory remains contributor, architecture, and agent context.
|
||||
|
||||
## Audiences
|
||||
|
||||
| Audience | Needs | Primary location |
|
||||
|---|---|---|
|
||||
| New users | Install, first launch, understand the app layout, clone the starter vault | `site/start/` |
|
||||
| Active users | Learn concrete workflows such as organizing, Git sync, custom views, and AI | `site/guides/` |
|
||||
| Power users | Understand file layout, frontmatter, filters, shortcuts, and platform support | `site/reference/` |
|
||||
| Contributors and agents | Architecture, abstractions, ADRs, development workflow | `docs/`, `AGENTS.md` |
|
||||
|
||||
## Hosting Shape
|
||||
|
||||
The GitHub Pages output should reserve the root for public docs and mount release assets underneath it:
|
||||
|
||||
```text
|
||||
/ public docs home
|
||||
/releases/ release history
|
||||
/download/ latest stable download redirect
|
||||
/stable/latest.json
|
||||
/alpha/latest.json
|
||||
/latest.json compatibility alias for alpha latest
|
||||
/latest-canary.json compatibility alias for alpha latest
|
||||
```
|
||||
|
||||
Every user-visible app change should answer:
|
||||
|
||||
```text
|
||||
Public docs impact:
|
||||
- updated: <pages>
|
||||
- not needed because: <reason>
|
||||
```
|
||||
40
docs/adr/0088-markdown-durable-mermaid-diagrams.md
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0088"
|
||||
title: "Markdown-durable Mermaid diagrams in notes"
|
||||
status: active
|
||||
date: 2026-04-27
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria notes are plain Markdown files, while the rich editor uses BlockNote and raw mode uses CodeMirror. Users need fenced `mermaid` blocks to render as diagrams in the note surface without changing the canonical file format or hiding the source from raw editing.
|
||||
|
||||
BlockNote can parse fenced code blocks, but a generic highlighted code block does not provide diagram rendering. Rendering Mermaid directly from the Markdown fence also has to preserve the original fence source when notes are saved, copied through raw mode, closed, and reopened.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria will support Mermaid diagrams through a Markdown placeholder round-trip owned by the editor pipeline and rendered with the `mermaid` package.**
|
||||
|
||||
The implementation:
|
||||
|
||||
- Converts fenced `mermaid` blocks to temporary placeholders before BlockNote parses Markdown.
|
||||
- Replaces placeholders with a `mermaidBlock` schema block that stores both the original fenced source and the diagram body.
|
||||
- Renders the block through Mermaid in the rich editor.
|
||||
- Serializes `mermaidBlock` nodes back to their stored fenced Markdown before save, raw-mode entry, and editor-position snapshots.
|
||||
- Shows the original source as an inline fallback when Mermaid cannot render a diagram.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Tolaria-owned placeholder round-trip with Mermaid rendering** (chosen): matches the existing wikilink and math architecture, keeps Markdown as the source of truth, and gives Tolaria explicit control over serialization.
|
||||
- **Render all `mermaid` code blocks by overriding the generic code-block renderer**: smaller surface, but it couples diagram behavior to the code-highlighting package and makes exact source preservation harder.
|
||||
- **Raw-mode-only Mermaid support**: preserves source but fails the enhanced note reading experience users expect.
|
||||
- **Store parsed diagram metadata outside the Markdown body**: enables richer future editing, but violates the files-first model.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `src/utils/mermaidMarkdown.ts` is the canonical parser/serializer bridge for note diagrams.
|
||||
- Rich mode renders diagrams as schema-backed blocks; raw mode remains the direct source editor.
|
||||
- Invalid Mermaid source remains visible instead of breaking the editor surface.
|
||||
- `mermaid` is now a runtime dependency and should be upgraded deliberately with rendering regression coverage.
|
||||
- Future diagram controls, such as copy source or expand, can attach to the same `mermaidBlock` without changing storage.
|
||||
36
docs/adr/0089-active-vault-filesystem-watcher.md
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0089"
|
||||
title: "Active vault filesystem watcher"
|
||||
status: active
|
||||
date: 2026-04-27
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria treats the filesystem as the source of truth, but before this decision the running app only noticed external file changes after a manual Reload Vault, a Git pull, or an AI-agent-specific refresh callback. Edits from another editor, terminal commands, another Tolaria window, or a non-pull Git operation could leave React state and the editor surface stale.
|
||||
|
||||
ADR-0071 already defines the safe reconciliation policy for external vault mutations: reload vault-derived state, protect unsaved local edits, and reopen the clean active note from disk when needed. Filesystem watching needed to reuse that policy instead of adding another ad hoc reload path.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria watches the active desktop vault with a native filesystem watcher and routes external change batches through the shared external-refresh reconciler.**
|
||||
|
||||
The desktop backend exposes `start_vault_watcher` and `stop_vault_watcher` commands backed by Rust `notify`. It watches the active vault recursively, ignores known non-content churn such as `.git/`, `node_modules/`, temp files, and `.tolaria-rename-txn`, then emits `vault-changed` events with the active vault path and changed paths.
|
||||
|
||||
The renderer owns batching and reconciliation. `useVaultWatcher` starts the backend watcher for the active main-window vault, debounces native events into one refresh, filters out recent app-owned saves, and calls `refreshPulledVaultState()`. Manual Reload Vault still uses `reload_vault` directly, but now exposes visible reload feedback.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Native active-vault watcher plus shared reconciliation** (chosen): keeps external changes visible without polling and preserves ADR-0071 behavior for clean and unsaved tabs. Cons: adds one native dependency and a long-lived watcher state.
|
||||
- **Frontend-only polling**: simpler backend surface, but wastes work on idle vaults and still needs careful active-note reconciliation.
|
||||
- **Direct `reloadVault()` on every native event**: easy to implement, but bypasses clean-tab reopen handling and can clobber the user experience around unsaved edits.
|
||||
- **Watch every configured vault**: could pre-warm state, but burns resources for inactive vaults and complicates event ownership across windows.
|
||||
|
||||
## Consequences
|
||||
|
||||
- External writes converge automatically into the visible vault state after a short debounce.
|
||||
- Active clean notes are refreshed through the same path as pull and AI-agent updates; unsaved local edits remain protected.
|
||||
- Tolaria app-owned saves are suppressed briefly so autosave does not trigger a full external refresh loop.
|
||||
- The status bar can show reload progress for manual and automatic refreshes.
|
||||
- The watcher is a desktop-only integration; mobile builds keep no-op command stubs until a mobile-specific filesystem strategy exists.
|
||||
35
docs/adr/0090-pi-cli-agent-adapter.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0090"
|
||||
title: "Pi CLI agent adapter"
|
||||
status: active
|
||||
date: 2026-04-28
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria already supports Claude Code, Codex, and OpenCode as local CLI agents in the AI panel. The next provider request is Pi Coding Agent support with the same first-class availability, settings, status, streaming, and MCP vault access.
|
||||
|
||||
Pi exposes non-interactive JSON events through `pi --mode json`, but Pi core intentionally does not include built-in MCP. Its supported MCP path is the `pi-mcp-adapter` extension, which reads MCP server definitions from Pi-compatible config files.
|
||||
|
||||
## Decision
|
||||
|
||||
Tolaria adds Pi as a supported CLI agent id (`pi`) and launches app-managed Pi sessions through a dedicated adapter module.
|
||||
|
||||
The adapter runs `pi --mode json --no-session` from the active vault cwd, closes stdin, and points `PI_CODING_AGENT_DIR` at a temporary directory containing Tolaria's `mcp.json`. That config loads the Tolaria MCP server through `pi-mcp-adapter`, pins `VAULT_PATH` to the selected vault, sets `WS_UI_PORT=9711`, uses lazy server lifecycle, and exposes the small Tolaria tool set directly.
|
||||
|
||||
Pi availability follows the existing desktop pattern: check the inherited `PATH`, the user's login shell, and common local/toolchain install locations. Pi authentication remains owned by the Pi CLI; Tolaria only surfaces setup errors and does not store provider API keys.
|
||||
|
||||
## Options Considered
|
||||
|
||||
- **Transient Pi adapter config** (chosen): gives app-launched Pi sessions Tolaria MCP access without mutating user or vault config files. Cons: requires the Pi MCP adapter extension path to be available to Pi.
|
||||
- **Write `.mcp.json` into the active vault**: simple for Pi discovery, but creates project files as a side effect of a chat session and can dirty user vaults.
|
||||
- **Rely on global `~/.pi/agent/mcp.json`**: matches Pi documentation, but silently retargets or depends on user-global state and conflicts with Tolaria's explicit integration boundary.
|
||||
- **Skip MCP for Pi**: easier to implement, but creates a weaker agent than the existing providers and violates the requirement for first-class MCP support.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The AI panel, settings, command palette, status bar, onboarding, and stream normalization now treat Pi as a first-class supported agent.
|
||||
- App-launched Pi sessions can use Tolaria vault MCP tools while remaining scoped to the selected vault.
|
||||
- Pi support stays isolated in Pi-specific modules instead of expanding the shared `ai_agents.rs` hotspot.
|
||||
- Users still need Pi itself, a configured Pi model/provider, and the Pi MCP adapter extension path available to the Pi CLI.
|
||||
@@ -140,3 +140,5 @@ proposed → active → superseded
|
||||
| [0082](0082-markdown-durable-math-notes.md) | Markdown-durable math in notes | active |
|
||||
| [0083](0083-dual-architecture-macos-release-artifacts.md) | Dual-architecture macOS release artifacts | active |
|
||||
| [0085](0085-non-git-vault-support.md) | Non-git vaults open with explicit later Git initialization | active |
|
||||
| [0088](0088-markdown-durable-mermaid-diagrams.md) | Markdown-durable Mermaid diagrams in notes | active |
|
||||
| [0089](0089-active-vault-filesystem-watcher.md) | Active vault filesystem watcher | active |
|
||||
|
||||
@@ -21,8 +21,9 @@ import {
|
||||
} from '@modelcontextprotocol/sdk/types.js'
|
||||
import WebSocket from 'ws'
|
||||
import { searchNotes, getNote, vaultContext } from './vault.js'
|
||||
import { requireVaultPath } from './vault-path.js'
|
||||
|
||||
const VAULT_PATH = process.env.VAULT_PATH || process.env.HOME + '/Laputa'
|
||||
const VAULT_PATH = requireVaultPath()
|
||||
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
|
||||
const WS_UI_URL = `ws://localhost:${WS_UI_PORT}`
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import os from 'node:os'
|
||||
import {
|
||||
findMarkdownFiles, getNote, searchNotes, vaultContext,
|
||||
} from './vault.js'
|
||||
import { requireVaultPath } from './vault-path.js'
|
||||
import { evaluateBridgeRequest } from './ws-bridge.js'
|
||||
|
||||
let tmpDir
|
||||
@@ -191,6 +192,22 @@ describe('evaluateBridgeRequest', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('requireVaultPath', () => {
|
||||
it('returns the explicit configured vault path', () => {
|
||||
assert.equal(
|
||||
requireVaultPath({ VAULT_PATH: '/tmp/Selected Vault' }),
|
||||
'/tmp/Selected Vault',
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects missing vault paths instead of falling back to ~/Laputa', () => {
|
||||
assert.throws(
|
||||
() => requireVaultPath({}),
|
||||
/VAULT_PATH is required/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
async function assertRejectsOutsideVault(prefix, resolveNotePath) {
|
||||
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), prefix))
|
||||
const outsideNote = path.join(outsideDir, 'outside.md')
|
||||
|
||||
7
mcp-server/vault-path.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export function requireVaultPath(env = process.env) {
|
||||
const vaultPath = env.VAULT_PATH?.trim()
|
||||
if (!vaultPath) {
|
||||
throw new Error('VAULT_PATH is required. Open a vault in Tolaria before starting MCP tools.')
|
||||
}
|
||||
return vaultPath
|
||||
}
|
||||
@@ -24,8 +24,8 @@ import { WebSocketServer } from 'ws'
|
||||
import {
|
||||
getNote, searchNotes, vaultContext,
|
||||
} from './vault.js'
|
||||
import { requireVaultPath } from './vault-path.js'
|
||||
|
||||
const VAULT_PATH = process.env.VAULT_PATH || process.env.HOME + '/Laputa'
|
||||
const WS_PORT = parseInt(process.env.WS_PORT || '9710', 10)
|
||||
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
|
||||
const LOOPBACK_HOST = 'localhost'
|
||||
@@ -37,6 +37,12 @@ const TRUSTED_UI_ORIGINS = new Set([
|
||||
|
||||
/** @type {WebSocketServer | null} */
|
||||
let uiBridge = null
|
||||
let vaultPath = null
|
||||
|
||||
function activeVaultPath() {
|
||||
vaultPath ??= requireVaultPath()
|
||||
return vaultPath
|
||||
}
|
||||
|
||||
function broadcastUiAction(action, payload) {
|
||||
if (!uiBridge) return
|
||||
@@ -48,10 +54,10 @@ function broadcastUiAction(action, payload) {
|
||||
|
||||
|
||||
const TOOL_HANDLERS = {
|
||||
open_note: (args) => getNote(VAULT_PATH, args.path).then(note => ({ content: note.content, frontmatter: note.frontmatter })),
|
||||
read_note: (args) => getNote(VAULT_PATH, args.path).then(note => ({ content: note.content, frontmatter: note.frontmatter })),
|
||||
search_notes: (args) => searchNotes(VAULT_PATH, args.query, args.limit),
|
||||
vault_context: () => vaultContext(VAULT_PATH),
|
||||
open_note: (args) => getNote(activeVaultPath(), args.path).then(note => ({ content: note.content, frontmatter: note.frontmatter })),
|
||||
read_note: (args) => getNote(activeVaultPath(), args.path).then(note => ({ content: note.content, frontmatter: note.frontmatter })),
|
||||
search_notes: (args) => searchNotes(activeVaultPath(), args.query, args.limit),
|
||||
vault_context: () => vaultContext(activeVaultPath()),
|
||||
ui_open_note: (args) => { broadcastUiAction('vault_changed', { path: args.path }); broadcastUiAction('open_note', { path: args.path }); return { ok: true } },
|
||||
ui_open_tab: (args) => { broadcastUiAction('vault_changed', { path: args.path }); broadcastUiAction('open_tab', { path: args.path }); return { ok: true } },
|
||||
ui_highlight: (args) => { broadcastUiAction('highlight', { element: args.element, path: args.path }); return { ok: true } },
|
||||
@@ -164,6 +170,7 @@ export function startUiBridge(port = WS_UI_PORT) {
|
||||
}
|
||||
|
||||
export function startBridge(port = WS_PORT) {
|
||||
const currentVaultPath = activeVaultPath()
|
||||
const wss = new WebSocketServer({
|
||||
port,
|
||||
host: LOOPBACK_HOST,
|
||||
@@ -171,7 +178,7 @@ export function startBridge(port = WS_PORT) {
|
||||
})
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
console.error(`[ws-bridge] Client connected (vault: ${VAULT_PATH})`)
|
||||
console.error(`[ws-bridge] Client connected (vault: ${currentVaultPath})`)
|
||||
|
||||
ws.on('message', async (raw) => {
|
||||
try {
|
||||
@@ -192,5 +199,11 @@ export function startBridge(port = WS_PORT) {
|
||||
// Run directly if invoked as main module
|
||||
const isMain = process.argv[1]?.endsWith('ws-bridge.js')
|
||||
if (isMain) {
|
||||
startUiBridge().then(() => startBridge())
|
||||
try {
|
||||
activeVaultPath()
|
||||
startUiBridge().then(() => startBridge())
|
||||
} catch (err) {
|
||||
console.error(`[ws-bridge] ${err.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"bundle-mcp": "node scripts/bundle-mcp-server.mjs",
|
||||
"docs:dev": "vitepress dev site --host 127.0.0.1",
|
||||
"docs:build": "vitepress build site",
|
||||
"docs:preview": "vitepress preview site --host 127.0.0.1",
|
||||
"lint": "eslint .",
|
||||
"l10n:translate": "lara-cli translate",
|
||||
"l10n:translate:force": "lara-cli translate --force",
|
||||
@@ -60,6 +63,7 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"katex": "^0.16.28",
|
||||
"lucide-react": "^0.564.0",
|
||||
"mermaid": "^11.14.0",
|
||||
"posthog-js": "^1.363.5",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.0",
|
||||
@@ -75,12 +79,12 @@
|
||||
"unicode-emoji-json": "^0.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@translated/lara-cli": "^1.3.2",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@tauri-apps/cli": "^2.10.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@translated/lara-cli": "^1.3.2",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@@ -98,6 +102,7 @@
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1",
|
||||
"vitepress": "^1.6.4",
|
||||
"vitest": "^4.0.18",
|
||||
"ws": "^8.19.0"
|
||||
}
|
||||
|
||||
1940
pnpm-lock.yaml
generated
101
site/.vitepress/config.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { defineConfig } from "vitepress";
|
||||
|
||||
const base = process.env.VITEPRESS_BASE ?? "/";
|
||||
|
||||
export default defineConfig({
|
||||
title: "Tolaria",
|
||||
description:
|
||||
"Tolaria is a local-first Markdown knowledge base with native relationships, Git history, and AI workflows.",
|
||||
base,
|
||||
cleanUrls: true,
|
||||
head: [
|
||||
["link", { rel: "icon", type: "image/png", href: `${base}landing/favicon.png` }],
|
||||
["meta", { property: "og:title", content: "Tolaria" }],
|
||||
[
|
||||
"meta",
|
||||
{
|
||||
property: "og:description",
|
||||
content:
|
||||
"A second brain for the AI era. Free forever, local-first, Markdown-based, Git-ready, and AI-friendly.",
|
||||
},
|
||||
],
|
||||
],
|
||||
themeConfig: {
|
||||
logo: { src: "/landing/tolaria-icon.png", alt: "Tolaria" },
|
||||
nav: [
|
||||
{ text: "Features", link: "/#features" },
|
||||
{ text: "Start", link: "/start/install" },
|
||||
{ text: "Concepts", link: "/concepts/vaults" },
|
||||
{ text: "Guides", link: "/guides/capture-a-note" },
|
||||
{ text: "Reference", link: "/reference/supported-platforms" },
|
||||
{ text: "Releases", link: "/releases/" },
|
||||
],
|
||||
search: {
|
||||
provider: "local",
|
||||
},
|
||||
socialLinks: [{ icon: "github", link: "https://github.com/refactoringhq/tolaria" }],
|
||||
sidebar: [
|
||||
{
|
||||
text: "Start Here",
|
||||
items: [
|
||||
{ text: "Install Tolaria", link: "/start/install" },
|
||||
{ text: "First Launch", link: "/start/first-launch" },
|
||||
{ text: "Getting Started Vault", link: "/start/getting-started-vault" },
|
||||
{ text: "Open Or Create A Vault", link: "/start/open-or-create-vault" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Concepts",
|
||||
items: [
|
||||
{ text: "Vaults", link: "/concepts/vaults" },
|
||||
{ text: "Notes", link: "/concepts/notes" },
|
||||
{ text: "Properties", link: "/concepts/properties" },
|
||||
{ text: "Types", link: "/concepts/types" },
|
||||
{ text: "Relationships", link: "/concepts/relationships" },
|
||||
{ text: "Inbox", link: "/concepts/inbox" },
|
||||
{ text: "Git", link: "/concepts/git" },
|
||||
{ text: "AI", link: "/concepts/ai" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Guides",
|
||||
items: [
|
||||
{ text: "Capture A Note", link: "/guides/capture-a-note" },
|
||||
{ text: "Organize The Inbox", link: "/guides/organize-inbox" },
|
||||
{ text: "Use Wikilinks", link: "/guides/use-wikilinks" },
|
||||
{ text: "Create Types", link: "/guides/create-types" },
|
||||
{ text: "Build Custom Views", link: "/guides/build-custom-views" },
|
||||
{ text: "Connect A Git Remote", link: "/guides/connect-a-git-remote" },
|
||||
{ text: "Commit And Push", link: "/guides/commit-and-push" },
|
||||
{ text: "Use The AI Panel", link: "/guides/use-ai-panel" },
|
||||
{ text: "Use The Command Palette", link: "/guides/use-command-palette" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Reference",
|
||||
items: [
|
||||
{ text: "Supported Platforms", link: "/reference/supported-platforms" },
|
||||
{ text: "File Layout", link: "/reference/file-layout" },
|
||||
{ text: "Frontmatter Fields", link: "/reference/frontmatter-fields" },
|
||||
{ text: "View Filters", link: "/reference/view-filters" },
|
||||
{ text: "Keyboard Shortcuts", link: "/reference/keyboard-shortcuts" },
|
||||
{ text: "Docs Maintenance", link: "/reference/docs-maintenance" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Troubleshooting",
|
||||
items: [
|
||||
{ text: "Vault Not Loading", link: "/troubleshooting/vault-not-loading" },
|
||||
{ text: "Git Authentication", link: "/troubleshooting/git-auth" },
|
||||
{ text: "AI Agent Not Found", link: "/troubleshooting/ai-agent-not-found" },
|
||||
{ text: "Sync Conflicts", link: "/troubleshooting/sync-conflicts" },
|
||||
],
|
||||
},
|
||||
],
|
||||
footer: {
|
||||
message: "Free and open source. Local-first, Git-first, and Markdown-based.",
|
||||
copyright:
|
||||
"Tolaria is AGPL-3.0-or-later. The Tolaria name and logo remain covered by the project trademark policy.",
|
||||
},
|
||||
},
|
||||
});
|
||||
1061
site/.vitepress/theme/LandingHome.vue
Normal file
28
site/.vitepress/theme/Layout.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import DefaultTheme from "vitepress/theme";
|
||||
import { onBeforeUnmount, onMounted } from "vue";
|
||||
import { useData } from "vitepress";
|
||||
|
||||
const { frontmatter } = useData();
|
||||
|
||||
const scrollClass = "tolaria-scrolled";
|
||||
const updateScrollClass = () => {
|
||||
document.documentElement.classList.toggle(scrollClass, window.scrollY > 8);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
updateScrollClass();
|
||||
window.addEventListener("scroll", updateScrollClass, { passive: true });
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("scroll", updateScrollClass);
|
||||
document.documentElement.classList.remove(scrollClass);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="{ 'tolaria-landing-shell': frontmatter.landing }">
|
||||
<DefaultTheme.Layout />
|
||||
</div>
|
||||
</template>
|
||||
BIN
site/.vitepress/theme/assets/RefactoringSans.otf
Normal file
12
site/.vitepress/theme/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import DefaultTheme from "vitepress/theme";
|
||||
import LandingHome from "./LandingHome.vue";
|
||||
import Layout from "./Layout.vue";
|
||||
import "./styles.css";
|
||||
|
||||
export default {
|
||||
extends: DefaultTheme,
|
||||
Layout,
|
||||
enhanceApp({ app }) {
|
||||
app.component("LandingHome", LandingHome);
|
||||
},
|
||||
};
|
||||
171
site/.vitepress/theme/styles.css
Normal file
@@ -0,0 +1,171 @@
|
||||
@font-face {
|
||||
font-family: "RefactoringSans";
|
||||
src: url("./assets/RefactoringSans.otf") format("opentype");
|
||||
font-display: swap;
|
||||
font-style: normal;
|
||||
font-weight: 400 900;
|
||||
}
|
||||
|
||||
:root {
|
||||
--vp-font-family-base:
|
||||
"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--vp-font-family-mono: "SF Mono", "Fira Code", ui-monospace, monospace;
|
||||
--tolaria-font-brand:
|
||||
"RefactoringSans", "Inter", -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--tolaria-bg: #faf9f5;
|
||||
--tolaria-surface: #ffffff;
|
||||
--tolaria-surface-muted: #f7f6f3;
|
||||
--tolaria-text: #1a1a18;
|
||||
--tolaria-text-secondary: #6b6b60;
|
||||
--tolaria-text-muted: #9b9b90;
|
||||
--tolaria-border: #e5e5e0;
|
||||
--tolaria-blue: #155dff;
|
||||
--tolaria-blue-hover: #4a5ad6;
|
||||
--tolaria-blue-soft: #e8eeff;
|
||||
--vp-c-bg: var(--tolaria-surface);
|
||||
--vp-c-bg-alt: var(--tolaria-surface-muted);
|
||||
--vp-c-bg-elv: var(--tolaria-surface);
|
||||
--vp-c-bg-soft: var(--tolaria-surface-muted);
|
||||
--vp-c-text-1: var(--tolaria-text);
|
||||
--vp-c-text-2: var(--tolaria-text-secondary);
|
||||
--vp-c-text-3: var(--tolaria-text-muted);
|
||||
--vp-c-border: var(--tolaria-border);
|
||||
--vp-c-divider: var(--tolaria-border);
|
||||
--vp-c-brand-1: var(--tolaria-blue);
|
||||
--vp-c-brand-2: var(--tolaria-blue-hover);
|
||||
--vp-c-brand-3: var(--tolaria-blue);
|
||||
--vp-c-brand-soft: var(--tolaria-blue-soft);
|
||||
--vp-button-brand-bg: var(--tolaria-blue);
|
||||
--vp-button-brand-hover-bg: var(--tolaria-blue-hover);
|
||||
--vp-button-brand-border: var(--tolaria-blue);
|
||||
--vp-code-bg: #eeeeea;
|
||||
--vp-code-color: var(--tolaria-text-secondary);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--vp-c-bg: #1f1e1b;
|
||||
--vp-c-bg-alt: #191814;
|
||||
--vp-c-bg-elv: #23221f;
|
||||
--vp-c-bg-soft: #23221f;
|
||||
--vp-c-text-1: #e6e1d8;
|
||||
--vp-c-text-2: #b8b1a6;
|
||||
--vp-c-text-3: #7f776d;
|
||||
--vp-c-border: #34322d;
|
||||
--vp-c-divider: #34322d;
|
||||
--vp-c-brand-1: #78a4ff;
|
||||
--vp-c-brand-2: #9bbeff;
|
||||
--vp-c-brand-3: #78a4ff;
|
||||
--vp-c-brand-soft: rgba(120, 164, 255, 0.16);
|
||||
--vp-code-bg: #2d2b27;
|
||||
--vp-code-color: #d8d1c6;
|
||||
}
|
||||
|
||||
.VPNavBarTitle .logo {
|
||||
width: auto;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.VPNavBarTitle .title {
|
||||
color: var(--vp-c-text-1);
|
||||
font-family: var(--tolaria-font-brand);
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.tolaria-landing-shell {
|
||||
--vp-c-bg: var(--tolaria-bg);
|
||||
}
|
||||
|
||||
.tolaria-landing-shell .VPNav,
|
||||
.tolaria-landing-shell .VPNavBar,
|
||||
.tolaria-landing-shell .VPNavBar .content-body {
|
||||
background: color-mix(in srgb, var(--tolaria-bg) 94%, transparent);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.tolaria-landing-shell .VPNavBar .divider,
|
||||
.tolaria-landing-shell .VPNavBar .divider-line {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.tolaria-landing-shell .VPNavBar .divider-line {
|
||||
opacity: 0;
|
||||
transition: opacity 160ms ease;
|
||||
}
|
||||
|
||||
.tolaria-landing-shell .VPLocalNav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tolaria-scrolled .tolaria-landing-shell .VPNav {
|
||||
box-shadow: 0 10px 24px rgba(26, 26, 24, 0.06);
|
||||
}
|
||||
|
||||
.tolaria-scrolled .tolaria-landing-shell .VPNavBar .divider-line {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.DocSearch-Button {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.vp-doc h1,
|
||||
.vp-doc h2,
|
||||
.vp-doc h3 {
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.vp-doc a,
|
||||
.VPNavBarMenuLink.active,
|
||||
.VPLink.active {
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
.vp-doc table {
|
||||
display: table;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vp-doc th {
|
||||
color: var(--vp-c-text-1);
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.vp-doc td,
|
||||
.vp-doc th {
|
||||
border-color: var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.tolaria-landing-shell .VPContent,
|
||||
.tolaria-landing-shell .VPPage,
|
||||
.tolaria-landing-shell .VPDoc,
|
||||
.tolaria-landing-shell .VPDoc .container,
|
||||
.tolaria-landing-shell .VPDoc .content,
|
||||
.tolaria-landing-shell .VPDoc .content-container,
|
||||
.tolaria-landing-shell .VPDoc .main,
|
||||
.tolaria-landing-shell .vp-doc {
|
||||
max-width: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tolaria-landing-shell .VPPage {
|
||||
padding-top: var(--vp-nav-height);
|
||||
}
|
||||
|
||||
.tolaria-landing-shell .VPDoc {
|
||||
padding-top: var(--vp-nav-height);
|
||||
}
|
||||
|
||||
.tolaria-landing-shell .vp-doc > div {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tolaria-landing-shell .vp-doc a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tolaria-landing-shell .VPFooter {
|
||||
display: none;
|
||||
}
|
||||
20
site/concepts/ai.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# AI
|
||||
|
||||
Tolaria is designed for local AI agents that can work with files and tools rather than a hidden cloud-only notes API.
|
||||
|
||||
## Agent Panel
|
||||
|
||||
The AI panel streams messages from supported local CLI agents. It shows reasoning, tool activity, and file changes in the app.
|
||||
|
||||
## MCP Server
|
||||
|
||||
Tolaria exposes an MCP server so tools such as Claude Code and Codex can search, read, and edit vault notes through explicit tools.
|
||||
|
||||
## Why Git Matters For AI
|
||||
|
||||
AI-generated changes should be inspectable. Git gives you diffs, history, rollback, and a clear boundary between suggestions and committed work.
|
||||
|
||||
## Current Direction
|
||||
|
||||
Claude Code is the primary integration. Codex and other CLI agents are supported through the shared agent architecture as the app evolves.
|
||||
|
||||
21
site/concepts/git.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Git
|
||||
|
||||
Git is Tolaria's history and sync layer. It keeps the model local-first while still supporting remote backup and multi-device workflows.
|
||||
|
||||
## What Tolaria Uses Git For
|
||||
|
||||
- Local commit history.
|
||||
- Diff views.
|
||||
- Per-note history.
|
||||
- Pull and push.
|
||||
- Conflict detection and resolution.
|
||||
- Remote connection for local-only vaults.
|
||||
|
||||
## Local Commits
|
||||
|
||||
You can commit changes inside Tolaria without leaving the app. This gives you useful restore points even before a remote is configured.
|
||||
|
||||
## Remotes
|
||||
|
||||
Connect a compatible Git remote when you want sync or backup. Tolaria relies on your system Git authentication, so GitHub CLI, SSH keys, credential helpers, and existing Git configuration can continue to work.
|
||||
|
||||
22
site/concepts/inbox.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Inbox
|
||||
|
||||
The Inbox is for notes that have been captured but not yet organized.
|
||||
|
||||
## Why It Exists
|
||||
|
||||
Fast capture should not require perfect structure. The Inbox gives you a place to put incomplete notes, then process them later.
|
||||
|
||||
## Organizing Inbox Notes
|
||||
|
||||
When reviewing the Inbox:
|
||||
|
||||
1. Give the note a clear H1.
|
||||
2. Set its `type`.
|
||||
3. Add status, dates, or URL if useful.
|
||||
4. Add relationships with wikilinks or frontmatter fields.
|
||||
5. Move it into a folder only if the folder adds value.
|
||||
|
||||
## Healthy Inbox Habit
|
||||
|
||||
Keep the Inbox small enough that it can be reviewed in one focused pass. Tolaria works best when capture is fast and organization is deliberate.
|
||||
|
||||
31
site/concepts/notes.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Notes
|
||||
|
||||
A note is a Markdown file with optional YAML frontmatter. Tolaria reads the first H1 as the primary title and keeps the file on disk as the durable representation.
|
||||
|
||||
## Anatomy
|
||||
|
||||
```md
|
||||
---
|
||||
type: Project
|
||||
status: Active
|
||||
belongs_to:
|
||||
- "[[workspace]]"
|
||||
---
|
||||
|
||||
# Launch Documentation
|
||||
|
||||
Draft the public Tolaria docs and keep them close to code changes.
|
||||
```
|
||||
|
||||
## Titles
|
||||
|
||||
The first H1 is the main title. Older notes can still use a `title:` frontmatter fallback, but new notes should rely on the H1.
|
||||
|
||||
## Body Links
|
||||
|
||||
Use `[[wikilinks]]` to connect notes from the body. Tolaria can resolve links by filename, title, and aliases.
|
||||
|
||||
## Frontmatter
|
||||
|
||||
Use frontmatter for structured fields such as type, status, date, URL, and relationships. Keep free-form thinking in the body.
|
||||
|
||||
25
site/concepts/properties.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Properties
|
||||
|
||||
Properties are frontmatter fields that Tolaria can display, filter, and edit.
|
||||
|
||||
## Common Properties
|
||||
|
||||
| Field | Purpose |
|
||||
| --- | --- |
|
||||
| `type` | Groups the note into a type such as Project, Person, or Topic. |
|
||||
| `status` | Tracks lifecycle state such as Active, Done, or Blocked. |
|
||||
| `url` | Stores a canonical external link. |
|
||||
| `date` | Represents a single date. |
|
||||
| `start_date`, `end_date` | Represents a date range. |
|
||||
| `aliases` | Gives a note alternative names for wikilink resolution. |
|
||||
|
||||
## System Properties
|
||||
|
||||
Fields that start with `_` are system properties. They remain in plain text but are hidden from normal property editing.
|
||||
|
||||
Examples include `_icon`, `_color`, `_order`, and `_pinned_properties` on type documents.
|
||||
|
||||
## Property Editing
|
||||
|
||||
The Inspector is the safest place to edit structured properties. Use raw Markdown mode when you need direct control over YAML.
|
||||
|
||||
27
site/concepts/relationships.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Relationships
|
||||
|
||||
Relationships make a vault feel like a graph instead of a pile of documents.
|
||||
|
||||
## Relationship Fields
|
||||
|
||||
Any frontmatter field containing wikilinks can become a relationship.
|
||||
|
||||
```yaml
|
||||
belongs_to:
|
||||
- "[[product-work]]"
|
||||
related_to:
|
||||
- "[[documentation]]"
|
||||
blocked_by:
|
||||
- "[[release-process]]"
|
||||
```
|
||||
|
||||
Tolaria does not need a hardcoded list of relationship names. It detects relationship fields dynamically.
|
||||
|
||||
## Body Links Versus Relationship Fields
|
||||
|
||||
Use body links when the relationship appears naturally in writing. Use frontmatter relationships when the connection is important enough to show in navigation, filters, or the Inspector.
|
||||
|
||||
## Backlinks
|
||||
|
||||
Tolaria can show incoming links and inverse relationships, making it easier to navigate from a note to the rest of its context.
|
||||
|
||||
38
site/concepts/types.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Types
|
||||
|
||||
Types describe what kind of thing a note represents: Project, Person, Topic, Procedure, Event, or any category you create.
|
||||
|
||||
## Type Field
|
||||
|
||||
The `type:` field assigns a note to a type.
|
||||
|
||||
```yaml
|
||||
type: Project
|
||||
```
|
||||
|
||||
Tolaria does not infer type from folder location. Moving a file into another folder does not change its type.
|
||||
|
||||
## Type Documents
|
||||
|
||||
Type documents live in the `type/` folder and describe how a type should appear.
|
||||
|
||||
```yaml
|
||||
---
|
||||
type: Type
|
||||
icon: folder
|
||||
color: blue
|
||||
sidebar_label: Projects
|
||||
sort: modified:desc
|
||||
---
|
||||
|
||||
# Project
|
||||
```
|
||||
|
||||
## What Types Control
|
||||
|
||||
- Sidebar grouping.
|
||||
- Type icon and color.
|
||||
- Default sort.
|
||||
- Pinned properties.
|
||||
- New-note templates.
|
||||
|
||||
29
site/concepts/vaults.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Vaults
|
||||
|
||||
A vault is the folder Tolaria reads and writes. The filesystem is the source of truth; the app state and cache are derived from files.
|
||||
|
||||
## Core Rules
|
||||
|
||||
- Notes are Markdown files.
|
||||
- YAML frontmatter provides structure.
|
||||
- Attachments are normal files inside the vault.
|
||||
- Type definitions and saved views are also files.
|
||||
- Git tracks history and supports remote sync.
|
||||
|
||||
## Why Local Files Matter
|
||||
|
||||
Local files keep your notes inspectable. You can open them in another editor, search with command-line tools, back them up with your own system, and version them with Git.
|
||||
|
||||
Tolaria should never become the only way to read your data.
|
||||
|
||||
## App State Versus Vault State
|
||||
|
||||
Vault-level information should travel with the vault. Machine-specific preferences stay with the app installation.
|
||||
|
||||
| Vault state | App state |
|
||||
| --- | --- |
|
||||
| Type icons and colors | Editor zoom |
|
||||
| Saved views | Window size |
|
||||
| Pinned properties | Recent vault list |
|
||||
| Relationship conventions | Local cache |
|
||||
|
||||
17
site/download/index.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Download
|
||||
|
||||
Download Tolaria from the latest stable release.
|
||||
|
||||
## macOS
|
||||
|
||||
[Download the latest stable build](https://refactoringhq.github.io/tolaria/download/)
|
||||
|
||||
macOS is the primary supported platform.
|
||||
|
||||
## Linux And Windows
|
||||
|
||||
Linux and Windows builds are best effort for now. Check the [latest GitHub release](https://github.com/refactoringhq/tolaria/releases/latest) for available artifacts.
|
||||
|
||||
## Verify The Version
|
||||
|
||||
After launching the app, check the version shown in Tolaria's status bar or release surface.
|
||||
20
site/guides/build-custom-views.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Build Custom Views
|
||||
|
||||
Custom views are saved filters for recurring questions.
|
||||
|
||||
## Good View Candidates
|
||||
|
||||
- Active projects.
|
||||
- People without a recent follow-up.
|
||||
- Drafts ready for review.
|
||||
- Notes changed this week.
|
||||
- Events in a date range.
|
||||
|
||||
## View Definition
|
||||
|
||||
Saved views live as files in the vault. They describe filters, sorting, and visible columns using structured data.
|
||||
|
||||
## Design The Question First
|
||||
|
||||
Before creating a view, write the question it answers. A good view is not "all fields with all filters"; it is a focused lens.
|
||||
|
||||
20
site/guides/capture-a-note.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Capture A Note
|
||||
|
||||
Use capture when you need to get an idea into the vault before you know where it belongs.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Open the command palette with `Cmd+K` or `Ctrl+K`.
|
||||
2. Run `New Note`.
|
||||
3. Write a clear H1.
|
||||
4. Add the rough content.
|
||||
5. Leave structure for later if you are still thinking.
|
||||
|
||||
## Capture Well
|
||||
|
||||
Prefer a useful title over a perfect taxonomy. You can add type, status, and relationships during inbox review.
|
||||
|
||||
## When To Add Structure Immediately
|
||||
|
||||
Add structure while capturing if the note is obviously a Project, Person, Event, or Procedure and the context is already known.
|
||||
|
||||
19
site/guides/commit-and-push.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Commit And Push
|
||||
|
||||
Tolaria lets you commit and push vault changes from inside the app.
|
||||
|
||||
## Commit
|
||||
|
||||
1. Open the Git or changes surface.
|
||||
2. Review changed files.
|
||||
3. Write a short commit message.
|
||||
4. Commit locally.
|
||||
|
||||
## Push
|
||||
|
||||
Push after committing when a remote is configured. If the remote has changed, pull first and resolve any conflicts.
|
||||
|
||||
## Use Small Commits
|
||||
|
||||
Small commits make it easier to understand what changed, roll back safely, and review AI-generated edits.
|
||||
|
||||
23
site/guides/connect-a-git-remote.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Connect A Git Remote
|
||||
|
||||
Connect a remote when you want backup or sync beyond the current machine.
|
||||
|
||||
## Before You Start
|
||||
|
||||
Make sure the remote repository exists and your system Git can authenticate to it. Tolaria uses system Git rather than storing provider-specific credentials.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Open the bottom status bar remote chip, or run `Add Remote` from the command palette.
|
||||
2. Paste the remote URL.
|
||||
3. Confirm the remote name.
|
||||
4. Fetch or push according to the app prompt.
|
||||
|
||||
## Recommended Auth
|
||||
|
||||
- SSH keys.
|
||||
- GitHub CLI authentication.
|
||||
- Existing Git credential helpers.
|
||||
|
||||
If authentication fails, see [Git Authentication](/troubleshooting/git-auth).
|
||||
|
||||
27
site/guides/create-types.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Create Types
|
||||
|
||||
Create a type when several notes share the same role in your system.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Create a note in the `type/` folder.
|
||||
2. Set `type: Type` in frontmatter.
|
||||
3. Give the document a clear H1.
|
||||
4. Add optional icon, color, sort, and sidebar label.
|
||||
|
||||
```yaml
|
||||
---
|
||||
type: Type
|
||||
icon: briefcase
|
||||
color: blue
|
||||
sidebar_label: Projects
|
||||
sort: modified:desc
|
||||
---
|
||||
|
||||
# Project
|
||||
```
|
||||
|
||||
## Use Types Sparingly
|
||||
|
||||
A type should represent a recurring category, not a one-off label. If you only need a temporary grouping, use a saved view or property instead.
|
||||
|
||||
26
site/guides/organize-inbox.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Organize The Inbox
|
||||
|
||||
Inbox review turns quick captures into usable knowledge.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- Rename unclear notes.
|
||||
- Add or correct the first H1.
|
||||
- Set `type`.
|
||||
- Add `status` for actionable notes.
|
||||
- Add `belongs_to`, `related_to`, or other relationship fields when useful.
|
||||
- Archive or delete notes that no longer matter.
|
||||
|
||||
## Make Notes Navigable
|
||||
|
||||
A note is organized when you can answer:
|
||||
|
||||
- What kind of thing is this?
|
||||
- What is it connected to?
|
||||
- What should happen next?
|
||||
- Where would I expect to find it later?
|
||||
|
||||
## Avoid Over-Structuring
|
||||
|
||||
Do not add fields just because they exist. Add the structure that will help future navigation, review, or automation.
|
||||
|
||||
19
site/guides/use-ai-panel.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Use The AI Panel
|
||||
|
||||
The AI panel connects Tolaria to local CLI agents.
|
||||
|
||||
## Before Using It
|
||||
|
||||
Install a supported agent such as Claude Code and make sure it is available from your shell. Tolaria detects common install locations, but explicit shell availability is still the simplest setup.
|
||||
|
||||
## Good Requests
|
||||
|
||||
- "Find notes related to this project."
|
||||
- "Summarize what changed in this note."
|
||||
- "Draft a weekly review from these linked notes."
|
||||
- "Update this checklist based on the current project status."
|
||||
|
||||
## Review Changes
|
||||
|
||||
AI edits are file edits. Review them with Tolaria's diff and Git history before committing.
|
||||
|
||||
24
site/guides/use-command-palette.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Use The Command Palette
|
||||
|
||||
The command palette is the fastest way to move around Tolaria.
|
||||
|
||||
Open it with:
|
||||
|
||||
- `Cmd+K` on macOS.
|
||||
- `Ctrl+K` on Linux and Windows.
|
||||
|
||||
## Common Commands
|
||||
|
||||
- New Note.
|
||||
- Search.
|
||||
- Open Settings.
|
||||
- Reload Vault.
|
||||
- Add Remote.
|
||||
- Open Getting Started Vault.
|
||||
- Toggle Raw Mode.
|
||||
- Open in New Window.
|
||||
|
||||
## Keyboard-First Workflow
|
||||
|
||||
Use the palette when you know what you want to do but do not want to hunt through panels. It is also the best place to discover commands as the app grows.
|
||||
|
||||
25
site/guides/use-wikilinks.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Use Wikilinks
|
||||
|
||||
Wikilinks connect notes by name.
|
||||
|
||||
```md
|
||||
This project belongs to [[content-systems]] and is related to [[git-workflows]].
|
||||
```
|
||||
|
||||
## Link From The Body
|
||||
|
||||
Use body links when the connection is part of the sentence you are writing.
|
||||
|
||||
## Link From Frontmatter
|
||||
|
||||
Use frontmatter links when the relationship should become structured metadata.
|
||||
|
||||
```yaml
|
||||
related_to:
|
||||
- "[[git-workflows]]"
|
||||
```
|
||||
|
||||
## Keep Links Stable
|
||||
|
||||
Prefer clear note titles and filenames. Use aliases when people or projects are known by multiple names.
|
||||
|
||||
10
site/index.md
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
layout: page
|
||||
sidebar: false
|
||||
aside: false
|
||||
landing: true
|
||||
title: Tolaria
|
||||
description: A second brain for the AI era. Free forever.
|
||||
---
|
||||
|
||||
<LandingHome />
|
||||
BIN
site/public/landing/Ai chat 3.png
Normal file
|
After Width: | Height: | Size: 726 KiB |
BIN
site/public/landing/Block editor.png
Normal file
|
After Width: | Height: | Size: 214 KiB |
BIN
site/public/landing/Luca hello.jpeg
Normal file
|
After Width: | Height: | Size: 287 KiB |
BIN
site/public/landing/ananth.webp
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
site/public/landing/favicon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
site/public/landing/git-history.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
site/public/landing/gregor.webp
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
site/public/landing/jordan.webp
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
site/public/landing/les-saintes.jpg
Normal file
|
After Width: | Height: | Size: 785 KiB |
BIN
site/public/landing/pulse.png
Normal file
|
After Width: | Height: | Size: 234 KiB |
BIN
site/public/landing/relationships.png
Normal file
|
After Width: | Height: | Size: 279 KiB |
BIN
site/public/landing/simply-files.png
Normal file
|
After Width: | Height: | Size: 287 KiB |
BIN
site/public/landing/tolaria-full-layout.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
site/public/landing/tolaria-icon.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
site/public/landing/track changes and push.png
Normal file
|
After Width: | Height: | Size: 401 KiB |
BIN
site/public/landing/wikilinks.png
Normal file
|
After Width: | Height: | Size: 157 KiB |
BIN
site/public/landing/yaml frontmatter.png
Normal file
|
After Width: | Height: | Size: 179 KiB |
10
site/public/tolaria-icon.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg width="266" height="363" viewBox="0 0 266 363" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_111_151)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M119.524 5.31219C126.607 -1.77073 138.117 -1.77073 145.2 5.31219C178.844 40.7268 265.61 147.856 265.61 230.195C265.61 303.68 206.29 363 132.805 363C59.3195 363 0 303.68 0 230.195C0 147.856 86.7659 40.7268 119.524 5.31219ZM59.5947 242.88C58.1619 234.744 50.3952 229.074 42.2195 230.195C34.0438 231.316 28.5932 238.799 30.0259 246.935C37.6847 290.425 72.2734 325.622 116.094 334.485C117.821 334.836 119.552 334.864 121.178 334.641C127.269 333.806 132.289 329.26 133.371 322.934C134.756 314.893 129.25 307.054 121.086 305.401C89.7767 299.057 65.0615 273.923 59.5947 242.88Z" fill="#155DFF"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_111_151">
|
||||
<rect width="266" height="363" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 889 B |
38
site/reference/docs-maintenance.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Docs Maintenance
|
||||
|
||||
The public docs live in the app repo so documentation changes can ship with behavior changes.
|
||||
|
||||
## Update Docs When You Change
|
||||
|
||||
- A Tauri command.
|
||||
- A new component or hook that changes user behavior.
|
||||
- A data model or frontmatter convention.
|
||||
- Git, AI, onboarding, or release behavior.
|
||||
- Platform support.
|
||||
- Keyboard shortcuts.
|
||||
|
||||
## Suggested Workflow
|
||||
|
||||
1. Make the code change.
|
||||
2. Update the matching concept, guide, or reference page.
|
||||
3. Add a troubleshooting page if the change creates a new failure mode.
|
||||
4. Run `pnpm docs:build`.
|
||||
5. Check the home page, search, and changed docs pages in a browser.
|
||||
|
||||
## Page Types
|
||||
|
||||
| Type | Purpose |
|
||||
| --- | --- |
|
||||
| Start | Helps a new user get into the app. |
|
||||
| Concepts | Explains mental models. |
|
||||
| Guides | Teaches workflows. |
|
||||
| Reference | Gives stable facts and tables. |
|
||||
| Troubleshooting | Starts from a symptom and ends with recovery. |
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- Does the page describe current behavior?
|
||||
- Does it mention macOS primary and Linux/Windows best effort when platform support matters?
|
||||
- Are links relative and VitePress-compatible?
|
||||
- Can a user discover the page with local search?
|
||||
|
||||
35
site/reference/file-layout.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# File Layout
|
||||
|
||||
A typical vault stays simple.
|
||||
|
||||
```txt
|
||||
my-vault/
|
||||
project-alpha.md
|
||||
weekly-review.md
|
||||
people/
|
||||
ada-lovelace.md
|
||||
attachments/
|
||||
diagram.png
|
||||
type/
|
||||
project.md
|
||||
person.md
|
||||
views/
|
||||
active-projects.yml
|
||||
```
|
||||
|
||||
## Root Notes
|
||||
|
||||
Tolaria can work with flat vaults and nested folders. Type is not inferred from folder location; it comes from frontmatter.
|
||||
|
||||
## Special Folders
|
||||
|
||||
| Folder | Purpose |
|
||||
| --- | --- |
|
||||
| `type/` | Type definition documents. |
|
||||
| `views/` | Saved custom views. |
|
||||
| `attachments/` | Images and other attached files. |
|
||||
|
||||
## Git Files
|
||||
|
||||
If the vault is a Git repository, `.git/` belongs to Git. Tolaria reads Git state but does not treat `.git/` as notes.
|
||||
|
||||
26
site/reference/frontmatter-fields.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Frontmatter Fields
|
||||
|
||||
Tolaria uses conventions instead of a required schema.
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `type` | The note's entity type. |
|
||||
| `status` | Lifecycle state. |
|
||||
| `icon` | Per-note icon. |
|
||||
| `url` | External URL. |
|
||||
| `date` | Single date. |
|
||||
| `start_date` | Start of a date range. |
|
||||
| `end_date` | End of a date range. |
|
||||
| `aliases` | Alternative names for link resolution. |
|
||||
| `belongs_to` | Parent relationship. |
|
||||
| `related_to` | Lateral relationship. |
|
||||
| `has` | Contained relationship. |
|
||||
|
||||
## Custom Fields
|
||||
|
||||
You can add your own fields. If a field contains wikilinks, Tolaria can treat it as a relationship.
|
||||
|
||||
## System Fields
|
||||
|
||||
Fields starting with `_` are reserved for system behavior and hidden from standard property editing.
|
||||
|
||||
15
site/reference/keyboard-shortcuts.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Keyboard Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
| --- | --- |
|
||||
| `Cmd+K` / `Ctrl+K` | Open command palette. |
|
||||
| `Cmd+N` / `Ctrl+N` | Create a new note. |
|
||||
| `Cmd+S` / `Ctrl+S` | Save current note. |
|
||||
| `Cmd+[` / `Alt+Left` | Navigate back when available. |
|
||||
| `Cmd+]` / `Alt+Right` | Navigate forward when available. |
|
||||
| `Cmd+Shift+O` / `Ctrl+Shift+O` | Open current note in a new window. |
|
||||
|
||||
Some shortcuts vary by platform because macOS, Linux, and Windows reserve different key combinations.
|
||||
|
||||
Use the command palette to discover the current command set.
|
||||
|
||||
24
site/reference/supported-platforms.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Supported Platforms
|
||||
|
||||
Tolaria is a desktop app built with Tauri.
|
||||
|
||||
| Platform | Current support | Notes |
|
||||
| --- | --- | --- |
|
||||
| macOS | Primary | Main development and QA target. Apple Silicon is first-class. |
|
||||
| Linux | Best effort | Depends on distribution WebKitGTK packaging and desktop integration behavior. |
|
||||
| Windows | Best effort | Builds exist, but platform-specific behavior may lag macOS. |
|
||||
|
||||
## Support Policy
|
||||
|
||||
Primary support means the platform is part of normal development and release validation. Best-effort support means builds may be available, but bugs can take longer to diagnose and fix.
|
||||
|
||||
## Reporting Platform Bugs
|
||||
|
||||
Include:
|
||||
|
||||
- Tolaria version.
|
||||
- Operating system and version.
|
||||
- CPU architecture.
|
||||
- Whether the vault is local-only or connected to a remote.
|
||||
- Steps to reproduce.
|
||||
|
||||
26
site/reference/view-filters.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# View Filters
|
||||
|
||||
View filters define saved lists of notes.
|
||||
|
||||
## Common Filter Ideas
|
||||
|
||||
| Goal | Filter direction |
|
||||
| --- | --- |
|
||||
| Active projects | `type` is Project and `status` is Active |
|
||||
| Drafts | `type` is Article and `status` is Draft |
|
||||
| People follow-up | `type` is Person and date is before today |
|
||||
| Recent work | modified date is within a recent range |
|
||||
|
||||
## Sorting
|
||||
|
||||
Useful sorts include:
|
||||
|
||||
- Recently modified first.
|
||||
- Title ascending.
|
||||
- Status ascending.
|
||||
- A custom property ascending or descending.
|
||||
|
||||
## Keep Views Focused
|
||||
|
||||
A view should answer one recurring question. If it becomes too broad, split it into two views.
|
||||
|
||||
16
site/releases/index.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Releases
|
||||
|
||||
Tolaria releases are published on GitHub.
|
||||
|
||||
- [Latest release](https://github.com/refactoringhq/tolaria/releases/latest)
|
||||
- [All releases](https://github.com/refactoringhq/tolaria/releases)
|
||||
- [Download page](/download/)
|
||||
|
||||
## Release Channels
|
||||
|
||||
Stable builds are intended for normal use. Pre-release builds may contain newer features and rougher edges.
|
||||
|
||||
## Before Updating
|
||||
|
||||
Commit or push important vault changes before updating the app. Your notes are local files, but having a clean Git state makes recovery easier.
|
||||
|
||||
32
site/start/first-launch.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# First Launch
|
||||
|
||||
The first launch flow is designed to get you into a real vault quickly without hiding the local-first model.
|
||||
|
||||
## What You Choose
|
||||
|
||||
Tolaria asks whether you want to:
|
||||
|
||||
- Create or clone the Getting Started vault.
|
||||
- Open an existing local vault.
|
||||
- Create a new empty vault.
|
||||
|
||||
The Getting Started vault is cloned locally and then disconnected from its remote. That keeps the sample safe to edit without accidentally pushing tutorial changes.
|
||||
|
||||
## What Tolaria Creates
|
||||
|
||||
Tolaria stores app-level settings on the local machine. Your notes stay in the vault folder you choose.
|
||||
|
||||
| Data | Stored in |
|
||||
| --- | --- |
|
||||
| Notes and attachments | Your vault folder |
|
||||
| Type definitions and saved views | Your vault folder |
|
||||
| Window size, zoom, recent vaults | Local app settings |
|
||||
| Cache data | Rebuildable local cache |
|
||||
|
||||
## First Commands To Try
|
||||
|
||||
- `Cmd+K` / `Ctrl+K`: open the command palette.
|
||||
- `New Note`: create a note in the current vault.
|
||||
- `Open Getting Started Vault`: clone the public sample vault.
|
||||
- `Reload Vault`: rescan files after external edits.
|
||||
|
||||
24
site/start/getting-started-vault.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Getting Started Vault
|
||||
|
||||
The Getting Started vault is a small public sample vault hosted at [refactoringhq/tolaria-getting-started](https://github.com/refactoringhq/tolaria-getting-started).
|
||||
|
||||
It exists to show Tolaria's conventions without requiring you to restructure your own notes first.
|
||||
|
||||
## What It Demonstrates
|
||||
|
||||
- Markdown notes with YAML frontmatter.
|
||||
- Types such as Project, Person, Topic, and Procedure.
|
||||
- Wikilinks in note bodies.
|
||||
- Relationship fields in frontmatter.
|
||||
- A local Git repository that can be connected to a remote later.
|
||||
|
||||
## Local-Only By Default
|
||||
|
||||
When Tolaria clones the sample, it removes the remote from the local copy. This makes the sample vault disposable. You can edit it freely, commit locally, and delete it later.
|
||||
|
||||
To connect a vault to your own remote, use the bottom status bar remote chip or run `Add Remote` from the command palette.
|
||||
|
||||
## When To Move On
|
||||
|
||||
After you understand the sample, open your own vault. Tolaria does not require a special folder structure: a folder of Markdown files is enough to start.
|
||||
|
||||
28
site/start/install.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Install Tolaria
|
||||
|
||||
Tolaria is developed and tested primarily on macOS. Linux and Windows builds are published on a best-effort basis while the app matures.
|
||||
|
||||
## Download
|
||||
|
||||
Use the latest stable release unless you are intentionally testing pre-release builds:
|
||||
|
||||
- [Download the latest stable build](https://refactoringhq.github.io/tolaria/download/)
|
||||
- [Browse all GitHub releases](https://github.com/refactoringhq/tolaria/releases)
|
||||
- [Read the release notes](/releases/)
|
||||
|
||||
## Platform Status
|
||||
|
||||
| Platform | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| macOS | Primary | Apple Silicon is the first-class desktop target. |
|
||||
| Linux | Best effort | Builds exist, but desktop integration varies by distribution and WebKitGTK packaging. |
|
||||
| Windows | Best effort | Builds exist, but behavior may lag macOS while the app is still early. |
|
||||
|
||||
See [Supported Platforms](/reference/supported-platforms) for the current support policy.
|
||||
|
||||
## After Installing
|
||||
|
||||
1. Open Tolaria.
|
||||
2. Choose the Getting Started vault if you want a guided sample.
|
||||
3. Or open an existing folder of Markdown files as a vault.
|
||||
4. Use the command palette with `Cmd+K` on macOS or `Ctrl+K` on Linux and Windows.
|
||||
25
site/start/open-or-create-vault.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Open Or Create A Vault
|
||||
|
||||
A Tolaria vault is a folder on disk. The folder can contain Markdown notes, attachments, type definitions, saved views, and Git metadata.
|
||||
|
||||
## Open An Existing Folder
|
||||
|
||||
Choose an existing folder if you already have Markdown notes. Tolaria scans `.md` files and uses frontmatter when it exists.
|
||||
|
||||
Good starting points:
|
||||
|
||||
- A folder of plain Markdown files.
|
||||
- An Obsidian-style vault.
|
||||
- A Git repository containing notes.
|
||||
- A copy of the Getting Started vault.
|
||||
|
||||
## Create A New Vault
|
||||
|
||||
Choose a new empty folder if you want Tolaria conventions from the start. New notes are created as Markdown files, and optional type definitions live in the `type/` folder.
|
||||
|
||||
## Git Repository Requirement
|
||||
|
||||
Tolaria's history and sync features expect the vault to be a Git repository. If a vault is not already a repository, Tolaria can initialize one for you.
|
||||
|
||||
Use Git because it gives Tolaria reliable local history, diff views, recovery, and remote sync without a proprietary backend.
|
||||
|
||||
23
site/troubleshooting/ai-agent-not-found.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# AI Agent Not Found
|
||||
|
||||
Tolaria can only launch local CLI agents that are installed and discoverable.
|
||||
|
||||
## Symptoms
|
||||
|
||||
- The AI panel says no supported agent is available.
|
||||
- Claude Code or another agent works in one shell but not in Tolaria.
|
||||
|
||||
## Checks
|
||||
|
||||
Open a terminal and run the agent command directly. For Claude Code:
|
||||
|
||||
```bash
|
||||
claude --version
|
||||
```
|
||||
|
||||
If the command fails, install or repair the agent first.
|
||||
|
||||
## Path Issues
|
||||
|
||||
Desktop apps can inherit a different `PATH` from your interactive shell. Tolaria checks common install locations, but shell setup can still vary. Prefer installing CLI tools in standard locations or making them available from your login shell.
|
||||
|
||||
26
site/troubleshooting/git-auth.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Git Authentication
|
||||
|
||||
Tolaria uses system Git authentication. It does not manage provider passwords directly.
|
||||
|
||||
## Symptoms
|
||||
|
||||
- Push fails.
|
||||
- Pull asks for credentials repeatedly.
|
||||
- Remote fetch works in one terminal but not in Tolaria.
|
||||
|
||||
## Checks
|
||||
|
||||
1. Open a terminal.
|
||||
2. `cd` into the vault.
|
||||
3. Run `git remote -v`.
|
||||
4. Run `git fetch`.
|
||||
|
||||
If `git fetch` fails in the terminal, fix system Git auth first.
|
||||
|
||||
## Common Fixes
|
||||
|
||||
- Sign in with GitHub CLI.
|
||||
- Configure SSH keys.
|
||||
- Update the remote URL.
|
||||
- Check your credential helper.
|
||||
|
||||
20
site/troubleshooting/sync-conflicts.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Sync Conflicts
|
||||
|
||||
Sync conflicts happen when local and remote changes touch the same content.
|
||||
|
||||
## What To Do
|
||||
|
||||
1. Stop editing the conflicted note.
|
||||
2. Open the conflict resolver if Tolaria presents it.
|
||||
3. Review both sides.
|
||||
4. Choose the correct content or merge manually.
|
||||
5. Commit the resolved file.
|
||||
6. Push again.
|
||||
|
||||
## Prevent Conflicts
|
||||
|
||||
- Pull before starting work on another device.
|
||||
- Push after meaningful sessions.
|
||||
- Keep AI-generated edits in small commits.
|
||||
- Avoid editing the same note on multiple devices at the same time.
|
||||
|
||||
25
site/troubleshooting/vault-not-loading.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Vault Not Loading
|
||||
|
||||
Use this checklist when Tolaria cannot open or refresh a vault.
|
||||
|
||||
## Check The Folder
|
||||
|
||||
- Confirm the folder exists.
|
||||
- Confirm the folder contains readable files.
|
||||
- Confirm Tolaria has permission to access the folder.
|
||||
- Try opening a smaller test vault to isolate the issue.
|
||||
|
||||
## Check Git
|
||||
|
||||
If the vault is a Git repository, verify it is not in a broken state:
|
||||
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
|
||||
Resolve interrupted merges or corrupted repository state before retrying.
|
||||
|
||||
## Reload
|
||||
|
||||
Run `Reload Vault` from the command palette. This clears derived cache and rescans the filesystem.
|
||||
|
||||
83
src-tauri/Cargo.lock
generated
@@ -1239,6 +1239,15 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fsevent-sys"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "funty"
|
||||
version = "2.0.0"
|
||||
@@ -2069,6 +2078,26 @@ dependencies = [
|
||||
"cfb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inotify"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"inotify-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inotify-sys"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.11.0"
|
||||
@@ -2207,6 +2236,26 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kqueue"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
|
||||
dependencies = [
|
||||
"kqueue-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kqueue-sys"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kuchikiki"
|
||||
version = "0.8.8-speedreader"
|
||||
@@ -2386,6 +2435,18 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.1.1"
|
||||
@@ -2489,6 +2550,25 @@ version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
|
||||
|
||||
[[package]]
|
||||
name = "notify"
|
||||
version = "6.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"crossbeam-channel",
|
||||
"filetime",
|
||||
"fsevent-sys",
|
||||
"inotify",
|
||||
"kqueue",
|
||||
"libc",
|
||||
"log",
|
||||
"mio 0.8.11",
|
||||
"walkdir",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.0"
|
||||
@@ -5042,7 +5122,7 @@ checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"mio 1.1.1",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
@@ -5103,6 +5183,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"gray_matter",
|
||||
"log",
|
||||
"notify",
|
||||
"regex",
|
||||
"sentry",
|
||||
"serde",
|
||||
|
||||
@@ -20,6 +20,7 @@ serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_yaml = "0.9"
|
||||
log = "0.4"
|
||||
notify = "6.1"
|
||||
tauri = { version = "2.10.0", features = ["protocol-asset", "devtools"] }
|
||||
tauri-plugin-log = "2"
|
||||
gray_matter = "0.2"
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::BufRead;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::process::Stdio;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AiAgentId {
|
||||
ClaudeCode,
|
||||
Codex,
|
||||
Opencode,
|
||||
Pi,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -20,6 +22,8 @@ pub struct AiAgentAvailability {
|
||||
pub struct AiAgentsStatus {
|
||||
pub claude_code: AiAgentAvailability,
|
||||
pub codex: AiAgentAvailability,
|
||||
pub opencode: AiAgentAvailability,
|
||||
pub pi: AiAgentAvailability,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -63,6 +67,8 @@ pub fn get_ai_agents_status() -> AiAgentsStatus {
|
||||
AiAgentsStatus {
|
||||
claude_code: availability_from_claude(),
|
||||
codex: availability_from_codex(),
|
||||
opencode: availability_from_opencode(),
|
||||
pi: availability_from_pi(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +90,22 @@ where
|
||||
})
|
||||
}
|
||||
AiAgentId::Codex => run_codex_agent_stream(request, emit),
|
||||
AiAgentId::Opencode => {
|
||||
let mapped = crate::opencode_cli::AgentStreamRequest {
|
||||
message: request.message,
|
||||
system_prompt: request.system_prompt,
|
||||
vault_path: request.vault_path,
|
||||
};
|
||||
crate::opencode_cli::run_agent_stream(mapped, emit)
|
||||
}
|
||||
AiAgentId::Pi => {
|
||||
let mapped = crate::pi_cli::AgentStreamRequest {
|
||||
message: request.message,
|
||||
system_prompt: request.system_prompt,
|
||||
vault_path: request.vault_path,
|
||||
};
|
||||
crate::pi_cli::run_agent_stream(mapped, emit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,8 +134,16 @@ fn availability_from_codex() -> AiAgentAvailability {
|
||||
}
|
||||
}
|
||||
|
||||
fn availability_from_opencode() -> AiAgentAvailability {
|
||||
crate::opencode_cli::check_cli()
|
||||
}
|
||||
|
||||
fn availability_from_pi() -> AiAgentAvailability {
|
||||
crate::pi_cli::check_cli()
|
||||
}
|
||||
|
||||
fn version_for_binary(binary: &PathBuf) -> Option<String> {
|
||||
Command::new(binary)
|
||||
crate::hidden_command(binary)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.ok()
|
||||
@@ -138,7 +168,7 @@ fn find_codex_binary() -> Result<PathBuf, String> {
|
||||
}
|
||||
|
||||
fn find_codex_binary_on_path() -> Option<PathBuf> {
|
||||
Command::new("which")
|
||||
crate::hidden_command("which")
|
||||
.arg("codex")
|
||||
.output()
|
||||
.ok()
|
||||
@@ -165,7 +195,7 @@ fn user_shell_candidates() -> Vec<PathBuf> {
|
||||
}
|
||||
|
||||
fn command_path_from_shell(shell: &Path, command: &str) -> Option<PathBuf> {
|
||||
Command::new(shell)
|
||||
crate::hidden_command(shell)
|
||||
.arg("-lc")
|
||||
.arg(format!("command -v {command}"))
|
||||
.output()
|
||||
@@ -225,13 +255,7 @@ where
|
||||
let args = build_codex_args(&request)?;
|
||||
let prompt = build_codex_prompt(&request);
|
||||
|
||||
let mut command = Command::new(binary);
|
||||
command
|
||||
.args(args)
|
||||
.arg(prompt)
|
||||
.current_dir(&request.vault_path)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
let mut command = build_codex_command(&binary, args, prompt, &request.vault_path);
|
||||
|
||||
let mut child = command
|
||||
.spawn()
|
||||
@@ -289,6 +313,22 @@ where
|
||||
Ok(thread_id)
|
||||
}
|
||||
|
||||
fn build_codex_command(
|
||||
binary: &Path,
|
||||
args: Vec<String>,
|
||||
prompt: String,
|
||||
vault_path: &str,
|
||||
) -> std::process::Command {
|
||||
let mut command = crate::hidden_command(binary);
|
||||
command
|
||||
.args(args)
|
||||
.arg(prompt)
|
||||
.current_dir(vault_path)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
command
|
||||
}
|
||||
|
||||
fn build_codex_args(request: &AiAgentStreamRequest) -> Result<Vec<String>, String> {
|
||||
let mcp_server = crate::mcp::mcp_server_dir()?.join("index.js");
|
||||
let mcp_server_path = mcp_server
|
||||
@@ -449,6 +489,9 @@ fn map_claude_event(event: crate::claude_cli::ClaudeStreamEvent) -> Option<AiAge
|
||||
Some(AiAgentStreamEvent::Error { message })
|
||||
}
|
||||
crate::claude_cli::ClaudeStreamEvent::Done => Some(AiAgentStreamEvent::Done),
|
||||
crate::claude_cli::ClaudeStreamEvent::Result { text, .. } if !text.is_empty() => {
|
||||
Some(AiAgentStreamEvent::TextDelta { text })
|
||||
}
|
||||
crate::claude_cli::ClaudeStreamEvent::Result { .. } => None,
|
||||
}
|
||||
}
|
||||
@@ -456,12 +499,15 @@ fn map_claude_event(event: crate::claude_cli::ClaudeStreamEvent) -> Option<AiAge
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::ffi::OsStr;
|
||||
|
||||
#[test]
|
||||
fn normalize_status_contains_both_agents() {
|
||||
fn normalize_status_contains_all_agents() {
|
||||
let status = get_ai_agents_status();
|
||||
assert!(matches!(status.claude_code.installed, true | false));
|
||||
assert!(matches!(status.codex.installed, true | false));
|
||||
assert!(matches!(status.opencode.installed, true | false));
|
||||
assert!(matches!(status.pi.installed, true | false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -497,6 +543,25 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_codex_command_keeps_agent_process_contract() {
|
||||
let binary = PathBuf::from("codex");
|
||||
let args = vec!["exec".to_string(), "--json".to_string()];
|
||||
let command = build_codex_command(&binary, args, "Summarize".into(), "/tmp/vault");
|
||||
let actual_args: Vec<&OsStr> = command.get_args().collect();
|
||||
|
||||
assert_eq!(command.get_program(), OsStr::new("codex"));
|
||||
assert_eq!(
|
||||
actual_args,
|
||||
vec![
|
||||
OsStr::new("exec"),
|
||||
OsStr::new("--json"),
|
||||
OsStr::new("Summarize")
|
||||
]
|
||||
);
|
||||
assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_binary_candidates_include_supported_macos_installs() {
|
||||
let home = PathBuf::from("/Users/alex");
|
||||
@@ -629,4 +694,17 @@ mod tests {
|
||||
|
||||
assert!(matches!(mapped, Some(AiAgentStreamEvent::Done)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_claude_result_event_preserves_final_text() {
|
||||
let mapped = map_claude_event(crate::claude_cli::ClaudeStreamEvent::Result {
|
||||
text: "Final answer from Claude".into(),
|
||||
session_id: "session-1".into(),
|
||||
});
|
||||
|
||||
assert!(matches!(
|
||||
mapped,
|
||||
Some(AiAgentStreamEvent::TextDelta { text }) if text == "Final answer from Claude"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::io::BufRead;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, ExitStatus, Stdio};
|
||||
use std::process::{ExitStatus, Stdio};
|
||||
|
||||
/// Status returned by `check_claude_cli`.
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
@@ -50,7 +50,7 @@ pub struct ChatStreamRequest {
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Parameters accepted by `stream_claude_agent`.
|
||||
/// Parameters accepted by Claude Code agent streams.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AgentStreamRequest {
|
||||
pub message: String,
|
||||
@@ -79,7 +79,7 @@ pub(crate) fn find_claude_binary() -> Result<PathBuf, String> {
|
||||
}
|
||||
|
||||
fn find_claude_binary_on_path() -> Option<PathBuf> {
|
||||
Command::new(claude_path_lookup_command())
|
||||
crate::hidden_command(claude_path_lookup_command())
|
||||
.arg("claude")
|
||||
.output()
|
||||
.ok()
|
||||
@@ -114,7 +114,7 @@ fn user_shell_candidates() -> Vec<PathBuf> {
|
||||
}
|
||||
|
||||
fn command_path_from_shell(shell: &Path, command: &str) -> Option<PathBuf> {
|
||||
Command::new(shell)
|
||||
crate::hidden_command(shell)
|
||||
.arg("-lc")
|
||||
.arg(format!("command -v {command}"))
|
||||
.output()
|
||||
@@ -193,7 +193,7 @@ pub fn check_cli() -> ClaudeCliStatus {
|
||||
}
|
||||
};
|
||||
|
||||
let version = Command::new(&bin)
|
||||
let version = crate::hidden_command(&bin)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.ok()
|
||||
@@ -269,7 +269,11 @@ fn build_agent_args(req: &AgentStreamRequest) -> Result<Vec<String>, String> {
|
||||
"--include-partial-messages".into(),
|
||||
"--mcp-config".into(),
|
||||
mcp_config,
|
||||
"--dangerously-skip-permissions".into(),
|
||||
"--strict-mcp-config".into(),
|
||||
"--permission-mode".into(),
|
||||
"acceptEdits".into(),
|
||||
"--tools".into(),
|
||||
"Read,Edit,MultiEdit,Write,Glob,Grep,LS".into(),
|
||||
"--no-session-persistence".into(),
|
||||
];
|
||||
|
||||
@@ -323,14 +327,7 @@ fn run_claude_subprocess<F>(
|
||||
where
|
||||
F: FnMut(ClaudeStreamEvent),
|
||||
{
|
||||
let mut cmd = Command::new(bin);
|
||||
cmd.args(args)
|
||||
.env_remove("CLAUDECODE") // prevent "nested session" guard
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
if let Some(dir) = cwd {
|
||||
cmd.current_dir(dir);
|
||||
}
|
||||
let mut cmd = build_claude_command(bin, args, cwd);
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to spawn claude: {e}"))?;
|
||||
@@ -387,6 +384,23 @@ where
|
||||
Ok(state.session_id)
|
||||
}
|
||||
|
||||
fn build_claude_command(
|
||||
bin: &PathBuf,
|
||||
args: &[String],
|
||||
cwd: Option<&str>,
|
||||
) -> std::process::Command {
|
||||
let mut cmd = crate::hidden_command(bin);
|
||||
cmd.args(args)
|
||||
.env_remove("CLAUDECODE") // prevent "nested session" guard
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
if let Some(dir) = cwd {
|
||||
cmd.current_dir(dir);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
fn format_failed_claude_exit(stderr_output: &str, status: ExitStatus) -> String {
|
||||
if is_claude_auth_error(stderr_output) {
|
||||
return "Claude CLI is not authenticated. Run `claude auth login` in your terminal.".into();
|
||||
@@ -589,6 +603,9 @@ fn extract_tool_result_text(json: &serde_json::Value) -> Option<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::ffi::OsStr;
|
||||
use std::ffi::OsString;
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn check_cli_returns_status() {
|
||||
@@ -902,6 +919,33 @@ mod tests {
|
||||
|
||||
// --- run_claude_subprocess with mock scripts ---
|
||||
|
||||
#[test]
|
||||
fn build_claude_command_keeps_streaming_process_contract() {
|
||||
let bin = PathBuf::from("claude");
|
||||
let args = vec!["-p".to_string(), "hello".to_string()];
|
||||
let command = build_claude_command(&bin, &args, Some("/tmp/vault"));
|
||||
let actual_args: Vec<OsString> = command.get_args().map(OsStr::to_os_string).collect();
|
||||
let claude_code_env = command
|
||||
.get_envs()
|
||||
.find(|(key, _)| *key == OsStr::new("CLAUDECODE"))
|
||||
.map(|(_, value)| value.map(OsStr::to_os_string));
|
||||
|
||||
assert_eq!(
|
||||
(
|
||||
command.get_program().to_os_string(),
|
||||
actual_args,
|
||||
command.get_current_dir().map(Path::to_path_buf),
|
||||
claude_code_env,
|
||||
),
|
||||
(
|
||||
OsString::from("claude"),
|
||||
vec![OsString::from("-p"), OsString::from("hello")],
|
||||
Some(PathBuf::from("/tmp/vault")),
|
||||
Some(None),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn run_mock_script(script: &str) -> (Result<String, String>, Vec<ClaudeStreamEvent>) {
|
||||
run_mock_script_with_args(script, &[])
|
||||
@@ -938,6 +982,99 @@ mod tests {
|
||||
assert!(matches!(&events[3], ClaudeStreamEvent::Done));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_subprocess_closes_stdin_even_when_parent_stdin_pipe_is_open() {
|
||||
use std::io::Read;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
let mut child = Command::new(std::env::current_exe().unwrap())
|
||||
.arg("stdin_probe_parent_child")
|
||||
.arg("--ignored")
|
||||
.arg("--nocapture")
|
||||
.env("TOLARIA_STDIN_PROBE_CHILD", "1")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let child_stdin = child.stdin.take().unwrap();
|
||||
let mut stdout = child.stdout.take().unwrap();
|
||||
let mut stderr = child.stderr.take().unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
|
||||
let status = loop {
|
||||
if let Some(status) = child.try_wait().unwrap() {
|
||||
break status;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
child.kill().unwrap();
|
||||
drop(child_stdin);
|
||||
panic!("stdin probe child timed out");
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
|
||||
drop(child_stdin);
|
||||
let mut stdout_text = String::new();
|
||||
let mut stderr_text = String::new();
|
||||
stdout.read_to_string(&mut stdout_text).unwrap();
|
||||
stderr.read_to_string(&mut stderr_text).unwrap();
|
||||
|
||||
assert!(
|
||||
status.success(),
|
||||
"stdin probe child failed with {status}\nstdout:\n{stdout_text}\nstderr:\n{stderr_text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "spawned by run_subprocess_closes_stdin_even_when_parent_stdin_pipe_is_open"]
|
||||
#[test]
|
||||
fn stdin_probe_parent_child() {
|
||||
if std::env::var_os("TOLARIA_STDIN_PROBE_CHILD").is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
let fake_bin = std::env::current_exe().unwrap();
|
||||
let args = vec![
|
||||
"stdin_probe_mock_claude_child".to_string(),
|
||||
"--ignored".to_string(),
|
||||
"--nocapture".to_string(),
|
||||
];
|
||||
std::env::set_var("TOLARIA_STDIN_PROBE_MOCK_CLAUDE_CHILD", "1");
|
||||
let mut events = vec![];
|
||||
let result = run_claude_subprocess(&fake_bin, &args, None, &mut |event| events.push(event));
|
||||
std::env::remove_var("TOLARIA_STDIN_PROBE_MOCK_CLAUDE_CHILD");
|
||||
|
||||
assert_eq!(result.unwrap(), "stdin-ok");
|
||||
assert!(matches!(
|
||||
events.first(),
|
||||
Some(ClaudeStreamEvent::Result { text, session_id })
|
||||
if text == "stdin closed" && session_id == "stdin-ok"
|
||||
));
|
||||
assert!(matches!(events.last(), Some(ClaudeStreamEvent::Done)));
|
||||
}
|
||||
|
||||
#[ignore = "spawned by stdin_probe_parent_child"]
|
||||
#[test]
|
||||
fn stdin_probe_mock_claude_child() {
|
||||
if std::env::var_os("TOLARIA_STDIN_PROBE_MOCK_CLAUDE_CHILD").is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
let mut stdin = String::new();
|
||||
std::io::stdin().read_to_string(&mut stdin).unwrap();
|
||||
assert!(stdin.is_empty(), "stdin was not EOF");
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"type": "result",
|
||||
"result": "stdin closed",
|
||||
"session_id": "stdin-ok"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_subprocess_skips_blank_and_non_json_lines() {
|
||||
@@ -1066,11 +1203,16 @@ mod tests {
|
||||
assert!(args.contains(&"-p".to_string()));
|
||||
assert!(args.contains(&"create note".to_string()));
|
||||
assert!(args.contains(&"--mcp-config".to_string()));
|
||||
assert!(args.contains(&"--dangerously-skip-permissions".to_string()));
|
||||
assert!(args.contains(&"--strict-mcp-config".to_string()));
|
||||
assert!(args.contains(&"--permission-mode".to_string()));
|
||||
assert!(args.contains(&"acceptEdits".to_string()));
|
||||
assert!(args.contains(&"--tools".to_string()));
|
||||
assert!(args.contains(&"Read,Edit,MultiEdit,Write,Glob,Grep,LS".to_string()));
|
||||
assert!(!args.contains(&"--dangerously-skip-permissions".to_string()));
|
||||
assert!(!args.contains(&"bypassPermissions".to_string()));
|
||||
assert!(!args.contains(&"Bash".to_string()));
|
||||
assert!(args.contains(&"--no-session-persistence".to_string()));
|
||||
assert!(!args.contains(&"--append-system-prompt".to_string()));
|
||||
// Native tools must NOT be disabled
|
||||
assert!(!args.contains(&"--tools".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#[cfg(desktop)]
|
||||
use crate::ai_agents::{AiAgentStreamRequest, AiAgentsStatus};
|
||||
use crate::claude_cli::{AgentStreamRequest, ChatStreamRequest, ClaudeCliStatus};
|
||||
use crate::claude_cli::{ChatStreamRequest, ClaudeCliStatus};
|
||||
use crate::vault::VaultAiGuidanceStatus;
|
||||
|
||||
use super::expand_tilde;
|
||||
@@ -81,14 +81,6 @@ define_desktop_stream_command!(
|
||||
crate::claude_cli::run_chat_stream
|
||||
);
|
||||
|
||||
#[cfg(desktop)]
|
||||
define_desktop_stream_command!(
|
||||
stream_claude_agent,
|
||||
AgentStreamRequest,
|
||||
"claude-agent-stream",
|
||||
crate::claude_cli::run_agent_stream
|
||||
);
|
||||
|
||||
#[cfg(desktop)]
|
||||
define_desktop_stream_command!(
|
||||
stream_ai_agent,
|
||||
@@ -120,6 +112,14 @@ pub fn get_ai_agents_status() -> AiAgentsStatus {
|
||||
installed: false,
|
||||
version: None,
|
||||
},
|
||||
opencode: crate::ai_agents::AiAgentAvailability {
|
||||
installed: false,
|
||||
version: None,
|
||||
},
|
||||
pi: crate::ai_agents::AiAgentAvailability {
|
||||
installed: false,
|
||||
version: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,15 +132,6 @@ pub async fn stream_claude_chat(
|
||||
Err("Claude CLI is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn stream_claude_agent(
|
||||
_app_handle: tauri::AppHandle,
|
||||
_request: AgentStreamRequest,
|
||||
) -> Result<String, String> {
|
||||
Err("Claude CLI is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn stream_ai_agent(
|
||||
|
||||
@@ -158,10 +158,62 @@ pub fn is_git_repo(vault_path: VaultPathArg) -> bool {
|
||||
.is_dir()
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn validate_git_init_target(vault_path: &str) -> Result<(), String> {
|
||||
let path = std::path::Path::new(vault_path);
|
||||
if !path.exists() {
|
||||
return Err("Choose an existing vault folder before initializing Git".to_string());
|
||||
}
|
||||
if !path.is_dir() {
|
||||
return Err("Choose a folder before initializing Git".to_string());
|
||||
}
|
||||
|
||||
if is_broad_personal_folder(path) && !has_tolaria_vault_marker(path) {
|
||||
return Err(format!(
|
||||
"Choose a dedicated vault folder before initializing Git. '{}' looks like a broad personal folder; create or select a subfolder such as '{}' instead.",
|
||||
path.display(),
|
||||
path.join("Tolaria").display()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn is_broad_personal_folder(path: &std::path::Path) -> bool {
|
||||
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
matches!(
|
||||
name.to_ascii_lowercase().as_str(),
|
||||
"desktop"
|
||||
| "documents"
|
||||
| "downloads"
|
||||
| "movies"
|
||||
| "music"
|
||||
| "pictures"
|
||||
| "public"
|
||||
| "templates"
|
||||
| "videos"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn has_tolaria_vault_marker(path: &std::path::Path) -> bool {
|
||||
["AGENTS.md", "CLAUDE.md", "type.md", "note.md"]
|
||||
.iter()
|
||||
.any(|file| path.join(file).is_file())
|
||||
|| ["attachments", "type", "views"]
|
||||
.iter()
|
||||
.any(|dir| path.join(dir).is_dir())
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn init_git_repo(vault_path: VaultPathArg) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
validate_git_init_target(&vault_path)?;
|
||||
crate::git::init_repo(&vault_path)
|
||||
}
|
||||
|
||||
@@ -374,6 +426,33 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_git_repo_rejects_broad_personal_folders() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let documents = dir.path().join("Documents");
|
||||
fs::create_dir_all(&documents).unwrap();
|
||||
fs::write(documents.join("unrelated.txt"), "not a vault").unwrap();
|
||||
|
||||
let err = init_git_repo(documents.to_string_lossy().into_owned())
|
||||
.expect_err("expected Documents itself to be rejected before git init");
|
||||
|
||||
assert!(err.contains("dedicated vault folder"));
|
||||
assert!(!documents.join(".git").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_git_repo_allows_named_vault_subfolder_under_documents() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("Documents").join("Tolaria");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
||||
let vault = vault.to_string_lossy().into_owned();
|
||||
|
||||
init_git_repo(vault.clone()).unwrap();
|
||||
|
||||
assert!(is_git_repo(vault));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn desktop_remote_commands_report_no_remote() {
|
||||
let (_dir, vault) = create_initialized_vault();
|
||||
|
||||
@@ -131,6 +131,22 @@ pub async fn check_mcp_status(vault_path: String) -> Result<crate::mcp::McpStatu
|
||||
.map_err(|e| format!("MCP status check failed: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn sync_mcp_bridge_vault(
|
||||
app: tauri::AppHandle,
|
||||
vault_path: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
let expanded_vault_path = vault_path
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|path| !path.is_empty())
|
||||
.map(|path| super::expand_tilde(path).into_owned());
|
||||
let vault_path = expanded_vault_path.as_deref().map(std::path::Path::new);
|
||||
|
||||
crate::sync_ws_bridge_for_vault(&app, vault_path).map(str::to_string)
|
||||
}
|
||||
|
||||
// ── MCP commands (mobile stubs) ─────────────────────────────────────────────
|
||||
|
||||
#[cfg(mobile)]
|
||||
@@ -151,6 +167,12 @@ pub async fn check_mcp_status(_vault_path: String) -> Result<crate::mcp::McpStat
|
||||
Ok(crate::mcp::McpStatus::NotInstalled)
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn sync_mcp_bridge_vault(_vault_path: Option<String>) -> Result<String, String> {
|
||||
Err("MCP is not available on mobile".into())
|
||||
}
|
||||
|
||||
// ── Menu commands ───────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
||||
@@ -165,6 +165,24 @@ fn ensure_missing_folder(folder_path: &Path, folder_name: &str) -> Result<(), St
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn scan_visible_vault_entries(vault_path: &Path) -> Result<Vec<VaultEntry>, String> {
|
||||
let entries = vault::scan_vault_cached(vault_path)?;
|
||||
Ok(vault::filter_gitignored_entries(
|
||||
vault_path,
|
||||
entries,
|
||||
crate::settings::hide_gitignored_files_enabled(),
|
||||
))
|
||||
}
|
||||
|
||||
fn scan_visible_vault_folders(vault_path: &Path) -> Result<Vec<FolderNode>, String> {
|
||||
let folders = vault::scan_vault_folders(vault_path)?;
|
||||
Ok(vault::filter_gitignored_folders(
|
||||
vault_path,
|
||||
folders,
|
||||
crate::settings::hide_gitignored_files_enabled(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Sync the `title` frontmatter field with the filename on note open.
|
||||
/// Returns `true` if the file was modified (title was absent or desynced).
|
||||
#[tauri::command]
|
||||
@@ -207,12 +225,12 @@ pub fn copy_image_to_vault(
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_vault(path: PathBuf) -> Result<Vec<VaultEntry>, String> {
|
||||
with_expanded_vault_root(path.as_path(), vault::scan_vault_cached)
|
||||
with_expanded_vault_root(path.as_path(), scan_visible_vault_entries)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_vault_folders(path: PathBuf) -> Result<Vec<FolderNode>, String> {
|
||||
with_expanded_vault_root(path.as_path(), vault::scan_vault_folders)
|
||||
with_expanded_vault_root(path.as_path(), scan_visible_vault_folders)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -83,8 +83,14 @@ pub async fn reload_vault(
|
||||
let path = expand_tilde(&path).into_owned();
|
||||
crate::sync_vault_asset_scope(&app_handle, Path::new(&path))?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
vault::invalidate_cache(Path::new(&path));
|
||||
vault::scan_vault_cached(Path::new(&path))
|
||||
let vault_path = Path::new(&path);
|
||||
vault::invalidate_cache(vault_path);
|
||||
let entries = vault::scan_vault_cached(vault_path)?;
|
||||
Ok(vault::filter_gitignored_entries(
|
||||
vault_path,
|
||||
entries,
|
||||
crate::settings::hide_gitignored_files_enabled(),
|
||||
))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task panicked: {e}"))?
|
||||
|
||||
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::process::Output;
|
||||
|
||||
use super::git_command;
|
||||
use super::{ensure_author_config, git_command};
|
||||
|
||||
const DEFAULT_REMOTE_NAME: &str = "origin";
|
||||
|
||||
@@ -92,6 +92,8 @@ pub fn git_add_remote(vault_path: &str, remote_url: &str) -> Result<GitAddRemote
|
||||
));
|
||||
}
|
||||
|
||||
ensure_author_config(vault)?;
|
||||
|
||||
let branch = current_branch(vault)?;
|
||||
if branch.is_empty() {
|
||||
return Ok(connect_result(
|
||||
@@ -444,6 +446,28 @@ mod tests {
|
||||
git_commit(path.to_str().unwrap(), message).unwrap();
|
||||
}
|
||||
|
||||
fn clear_local_author(path: &Path) {
|
||||
for key in ["user.name", "user.email"] {
|
||||
StdCommand::new("git")
|
||||
.args(["config", "--local", "--unset-all", key])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn local_author_is_configured(path: &Path) -> bool {
|
||||
["user.name", "user.email"].into_iter().all(|key| {
|
||||
let output = StdCommand::new("git")
|
||||
.args(["config", "--local", key])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
output.status.success() && !String::from_utf8_lossy(&output.stdout).trim().is_empty()
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disconnect_all_remotes_removes_every_remote() {
|
||||
let dir = setup_git_repo();
|
||||
@@ -489,6 +513,26 @@ mod tests {
|
||||
assert_eq!((status.ahead, status.behind), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_add_remote_sets_local_identity_when_existing_repo_has_none() {
|
||||
let local = setup_git_repo();
|
||||
create_local_commit(local.path(), "note.md", "Local", "Initial local commit");
|
||||
clear_local_author(local.path());
|
||||
assert!(!local_author_is_configured(local.path()));
|
||||
|
||||
let bare = TempDir::new().unwrap();
|
||||
init_bare_remote(bare.path());
|
||||
|
||||
let result = git_add_remote(
|
||||
local.path().to_str().unwrap(),
|
||||
bare.path().to_str().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.status, "connected");
|
||||
assert!(local_author_is_configured(local.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_add_remote_pushes_when_remote_is_the_local_branch_ancestor() {
|
||||
let local = setup_git_repo();
|
||||
|
||||
@@ -129,21 +129,20 @@ fn git_command_label<'a>(args: &'a [&'a str]) -> &'a str {
|
||||
}
|
||||
|
||||
/// Set local user.name and user.email if not already configured.
|
||||
fn ensure_author_config(dir: &Path) -> Result<(), String> {
|
||||
for (key, fallback) in [
|
||||
("user.name", "Tolaria"),
|
||||
("user.email", "vault@tolaria.app"),
|
||||
] {
|
||||
let check = git_command()
|
||||
.args(["config", key])
|
||||
pub(crate) fn ensure_author_config(dir: &Path) -> Result<(), String> {
|
||||
for (key, fallback) in [("user.name", "Tolaria"), ("user.email", "vault@tolaria.md")] {
|
||||
let local = git_command()
|
||||
.args(["config", "--local", key])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to check git config: {}", e))?;
|
||||
.map_err(|e| format!("Failed to check git config {key}: {e}"))?;
|
||||
|
||||
let value = String::from_utf8_lossy(&check.stdout);
|
||||
if !check.status.success() || value.trim().is_empty() {
|
||||
run_git(dir, &["config", key, fallback])?;
|
||||
let value = String::from_utf8_lossy(&local.stdout);
|
||||
if local.status.success() && !value.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
run_git(dir, &["config", "--local", key, fallback])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -7,11 +7,20 @@ pub mod git;
|
||||
pub mod mcp;
|
||||
#[cfg(desktop)]
|
||||
pub mod menu;
|
||||
pub mod opencode_cli;
|
||||
mod opencode_config;
|
||||
mod opencode_discovery;
|
||||
mod opencode_events;
|
||||
pub mod pi_cli;
|
||||
mod pi_config;
|
||||
mod pi_discovery;
|
||||
mod pi_events;
|
||||
pub mod search;
|
||||
pub mod settings;
|
||||
pub mod telemetry;
|
||||
pub mod vault;
|
||||
pub mod vault_list;
|
||||
pub mod vault_watcher;
|
||||
#[cfg(desktop)]
|
||||
mod window_state;
|
||||
|
||||
@@ -97,6 +106,78 @@ fn log_startup_result(label: &str, result: Result<usize, String>) {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn selected_mcp_bridge_vault_path(vault_list: &vault_list::VaultList) -> Option<PathBuf> {
|
||||
vault_list
|
||||
.active_vault
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|path| !path.is_empty())
|
||||
.map(PathBuf::from)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn validate_mcp_bridge_vault_path(vault_path: &Path) -> Result<PathBuf, String> {
|
||||
let resolved = std::fs::canonicalize(vault_path).map_err(|e| {
|
||||
format!(
|
||||
"MCP bridge vault is not available: {} ({e})",
|
||||
vault_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
if !resolved.is_dir() {
|
||||
return Err(format!(
|
||||
"MCP bridge vault is not available: {} is not a directory",
|
||||
vault_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn stop_ws_bridge_child(active_child: &mut Option<Child>) {
|
||||
if let Some(mut child) = active_child.take() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
log::info!("ws-bridge child process stopped");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
pub(crate) fn sync_ws_bridge_for_vault(
|
||||
app_handle: &tauri::AppHandle,
|
||||
vault_path: Option<&Path>,
|
||||
) -> Result<&'static str, String> {
|
||||
use tauri::Manager;
|
||||
|
||||
let state: tauri::State<'_, WsBridgeChild> = app_handle.state();
|
||||
let mut active_child = state
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock ws-bridge state".to_string())?;
|
||||
|
||||
let Some(vault_path) = vault_path else {
|
||||
stop_ws_bridge_child(&mut active_child);
|
||||
return Ok("stopped");
|
||||
};
|
||||
|
||||
let resolved_vault_path = match validate_mcp_bridge_vault_path(vault_path) {
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
stop_ws_bridge_child(&mut active_child);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
stop_ws_bridge_child(&mut active_child);
|
||||
|
||||
let child = mcp::spawn_ws_bridge(&resolved_vault_path)?;
|
||||
|
||||
*active_child = Some(child);
|
||||
Ok("started")
|
||||
}
|
||||
|
||||
/// Run startup housekeeping on the default vault (migrate legacy frontmatter, seed configs).
|
||||
#[cfg(desktop)]
|
||||
fn run_startup_tasks() {
|
||||
@@ -119,17 +200,21 @@ fn run_startup_tasks() {
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn spawn_ws_bridge(app: &mut tauri::App) {
|
||||
use tauri::Manager;
|
||||
let vault_path = dirs::home_dir()
|
||||
.map(|h| h.join("Laputa"))
|
||||
.unwrap_or_default();
|
||||
let vp_str = vault_path.to_string_lossy().to_string();
|
||||
match mcp::spawn_ws_bridge(&vp_str) {
|
||||
Ok(child) => {
|
||||
let state: tauri::State<'_, WsBridgeChild> = app.state();
|
||||
*state.0.lock().unwrap() = Some(child);
|
||||
let vault_path = match vault_list::load_vault_list() {
|
||||
Ok(vault_list) => selected_mcp_bridge_vault_path(&vault_list),
|
||||
Err(e) => {
|
||||
log::warn!("Failed to load active vault for ws-bridge startup: {}", e);
|
||||
None
|
||||
}
|
||||
Err(e) => log::warn!("Failed to start ws-bridge: {}", e),
|
||||
};
|
||||
|
||||
let Some(vault_path) = vault_path else {
|
||||
log::info!("ws-bridge not started: no active vault selected");
|
||||
return;
|
||||
};
|
||||
|
||||
if let Err(e) = sync_ws_bridge_for_vault(app.handle(), Some(&vault_path)) {
|
||||
log::warn!("Failed to start ws-bridge: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,7 +401,6 @@ macro_rules! app_invoke_handler {
|
||||
commands::get_vault_ai_guidance_status,
|
||||
commands::restore_vault_ai_guidance,
|
||||
commands::stream_claude_chat,
|
||||
commands::stream_claude_agent,
|
||||
commands::stream_ai_agent,
|
||||
commands::reload_vault,
|
||||
commands::reload_vault_entry,
|
||||
@@ -351,11 +435,14 @@ macro_rules! app_invoke_handler {
|
||||
commands::register_mcp_tools,
|
||||
commands::remove_mcp_tools,
|
||||
commands::check_mcp_status,
|
||||
commands::sync_mcp_bridge_vault,
|
||||
commands::repair_vault,
|
||||
commands::reinit_telemetry,
|
||||
commands::list_views,
|
||||
commands::save_view_cmd,
|
||||
commands::delete_view_cmd
|
||||
commands::delete_view_cmd,
|
||||
vault_watcher::start_vault_watcher,
|
||||
vault_watcher::stop_vault_watcher
|
||||
]
|
||||
};
|
||||
}
|
||||
@@ -389,7 +476,8 @@ pub fn run() {
|
||||
let builder = builder
|
||||
.manage(WsBridgeChild(Mutex::new(None)))
|
||||
.manage(ActiveAssetScopeRoots(Mutex::new(Vec::new())))
|
||||
.manage(window_state::MainWindowFrameState::default());
|
||||
.manage(window_state::MainWindowFrameState::default())
|
||||
.manage(vault_watcher::VaultWatcherState::new());
|
||||
|
||||
with_invoke_handler(builder)
|
||||
.setup(setup_app)
|
||||
@@ -408,6 +496,11 @@ mod tests {
|
||||
use super::MACOS_WEBVIEW_RESERVED_COMMAND_KEYS;
|
||||
use super::MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS;
|
||||
|
||||
#[cfg(desktop)]
|
||||
use super::{selected_mcp_bridge_vault_path, validate_mcp_bridge_vault_path};
|
||||
#[cfg(desktop)]
|
||||
use crate::vault_list::VaultList;
|
||||
|
||||
#[cfg(all(desktop, unix))]
|
||||
use super::vault_asset_scope_roots;
|
||||
|
||||
@@ -451,6 +544,48 @@ mod tests {
|
||||
assert!(overrides.is_empty());
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[test]
|
||||
fn selected_mcp_bridge_vault_path_uses_persisted_active_vault() {
|
||||
let list = VaultList {
|
||||
vaults: Vec::new(),
|
||||
active_vault: Some("/tmp/Selected Vault".to_string()),
|
||||
hidden_defaults: Vec::new(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
selected_mcp_bridge_vault_path(&list),
|
||||
Some(std::path::PathBuf::from("/tmp/Selected Vault"))
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[test]
|
||||
fn selected_mcp_bridge_vault_path_ignores_blank_active_vault() {
|
||||
let list = VaultList {
|
||||
vaults: Vec::new(),
|
||||
active_vault: Some(" ".to_string()),
|
||||
hidden_defaults: Vec::new(),
|
||||
};
|
||||
|
||||
assert_eq!(selected_mcp_bridge_vault_path(&list), None);
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[test]
|
||||
fn validate_mcp_bridge_vault_path_requires_existing_directory() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = dir.path().join("Vault With Spaces");
|
||||
std::fs::create_dir(&vault).unwrap();
|
||||
|
||||
let resolved = validate_mcp_bridge_vault_path(&vault).unwrap();
|
||||
assert_eq!(resolved, vault.canonicalize().unwrap());
|
||||
|
||||
let missing = dir.path().join("Missing Vault");
|
||||
let err = validate_mcp_bridge_vault_path(&missing).unwrap_err();
|
||||
assert!(err.contains("MCP bridge vault is not available"));
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, unix))]
|
||||
#[test]
|
||||
fn vault_asset_scope_roots_include_requested_symlink_path() {
|
||||
|
||||
@@ -21,19 +21,66 @@ pub(crate) fn find_node() -> Result<PathBuf, String> {
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to locate node on PATH: {e}"))?;
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
return Ok(PathBuf::from(path));
|
||||
if let Some(path) = first_node_lookup_path(&output.stdout) {
|
||||
verify_node_version(&path)?;
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(path) = fallback_node_path() {
|
||||
verify_node_version(&path)?;
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
Err("node not found in PATH or common install locations".into())
|
||||
}
|
||||
|
||||
fn first_node_lookup_path(stdout: &[u8]) -> Option<PathBuf> {
|
||||
String::from_utf8_lossy(stdout)
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|line| !line.is_empty())
|
||||
.map(PathBuf::from)
|
||||
}
|
||||
|
||||
fn verify_node_version(node: &Path) -> Result<(), String> {
|
||||
let output = crate::hidden_command(node)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run {} --version: {e}", node.display()))?;
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"{} --version failed; install Node.js 18+ and make it available on PATH",
|
||||
node.display()
|
||||
));
|
||||
}
|
||||
|
||||
let raw_version = String::from_utf8_lossy(&output.stdout);
|
||||
let Some(major) = node_major_version(&raw_version) else {
|
||||
return Err(format!(
|
||||
"Cannot parse Node.js version from '{}'",
|
||||
raw_version.trim()
|
||||
));
|
||||
};
|
||||
if major < 18 {
|
||||
return Err(format!(
|
||||
"Node.js 18+ is required for Tolaria MCP tools; found {}",
|
||||
raw_version.trim()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn node_major_version(version: &str) -> Option<u32> {
|
||||
version
|
||||
.trim()
|
||||
.trim_start_matches('v')
|
||||
.split('.')
|
||||
.next()
|
||||
.and_then(|major| major.parse().ok())
|
||||
}
|
||||
|
||||
fn node_lookup_command() -> Command {
|
||||
#[cfg(windows)]
|
||||
let mut command = crate::hidden_command("where.exe");
|
||||
@@ -50,8 +97,30 @@ fn fallback_node_path() -> Option<PathBuf> {
|
||||
PathBuf::from("/usr/local/bin/node"),
|
||||
];
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Some(program_files) = std::env::var_os("ProgramFiles") {
|
||||
candidates.push(PathBuf::from(program_files).join("nodejs").join("node.exe"));
|
||||
}
|
||||
if let Some(program_files_x86) = std::env::var_os("ProgramFiles(x86)") {
|
||||
candidates.push(
|
||||
PathBuf::from(program_files_x86)
|
||||
.join("nodejs")
|
||||
.join("node.exe"),
|
||||
);
|
||||
}
|
||||
if let Some(local_app_data) = std::env::var_os("LOCALAPPDATA") {
|
||||
candidates.push(
|
||||
PathBuf::from(local_app_data)
|
||||
.join("Programs")
|
||||
.join("nodejs")
|
||||
.join("node.exe"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
candidates.push(home.join(".volta").join("bin").join("node"));
|
||||
candidates.push(home.join(".volta").join("bin").join(node_binary_name()));
|
||||
|
||||
let nvm_dir = home.join(".nvm").join("versions").join("node");
|
||||
if let Ok(entries) = std::fs::read_dir(nvm_dir) {
|
||||
@@ -71,6 +140,14 @@ fn fallback_node_path() -> Option<PathBuf> {
|
||||
candidates.into_iter().find(|path| path.is_file())
|
||||
}
|
||||
|
||||
fn node_binary_name() -> &'static str {
|
||||
if cfg!(windows) {
|
||||
"node.exe"
|
||||
} else {
|
||||
"node"
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the path to `mcp-server/`.
|
||||
///
|
||||
/// In dev mode, uses `CARGO_MANIFEST_DIR` (set at compile time).
|
||||
@@ -79,34 +156,70 @@ pub(crate) fn mcp_server_dir() -> Result<PathBuf, String> {
|
||||
let dev_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("mcp-server");
|
||||
if dev_path.join("ws-bridge.js").exists() {
|
||||
return Ok(std::fs::canonicalize(&dev_path).unwrap_or(dev_path));
|
||||
}
|
||||
|
||||
let exe = std::env::current_exe().map_err(|e| format!("Cannot find executable: {e}"))?;
|
||||
// On macOS the exe lives at Contents/MacOS/<binary>.
|
||||
// Resources are placed at Contents/Resources/ by Tauri.
|
||||
let release_path = exe
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.map(|p| p.join("Resources").join("mcp-server"))
|
||||
.ok_or_else(|| "Cannot resolve mcp-server directory".to_string())?;
|
||||
if release_path.join("ws-bridge.js").exists() {
|
||||
return Ok(release_path);
|
||||
let appdir = std::env::var_os("APPDIR").map(PathBuf::from);
|
||||
let candidates = mcp_server_dir_candidates(&dev_path, &exe, appdir.as_deref());
|
||||
if let Some(path) = candidates
|
||||
.iter()
|
||||
.find(|path| mcp_server_dir_has_files(path))
|
||||
{
|
||||
return Ok(std::fs::canonicalize(path).unwrap_or_else(|_| path.clone()));
|
||||
}
|
||||
|
||||
let searched = candidates
|
||||
.iter()
|
||||
.map(|path| path.display().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
Err(format!(
|
||||
"mcp-server not found at {} or {}",
|
||||
dev_path.display(),
|
||||
release_path.display()
|
||||
"mcp-server not found. Searched these paths: {searched}"
|
||||
))
|
||||
}
|
||||
|
||||
fn mcp_server_dir_candidates(
|
||||
dev_path: &Path,
|
||||
exe_path: &Path,
|
||||
appdir: Option<&Path>,
|
||||
) -> Vec<PathBuf> {
|
||||
let mut candidates = vec![dev_path.to_path_buf()];
|
||||
|
||||
if let Some(exe_dir) = exe_path.parent() {
|
||||
candidates.push(exe_dir.join("mcp-server"));
|
||||
if let Some(bundle_root) = exe_dir.parent() {
|
||||
candidates.push(bundle_root.join("Resources").join("mcp-server"));
|
||||
candidates.push(bundle_root.join("lib").join("tolaria").join("mcp-server"));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(appdir) = appdir {
|
||||
candidates.push(
|
||||
appdir
|
||||
.join("usr")
|
||||
.join("lib")
|
||||
.join("tolaria")
|
||||
.join("mcp-server"),
|
||||
);
|
||||
}
|
||||
|
||||
candidates.push(
|
||||
PathBuf::from("/usr")
|
||||
.join("lib")
|
||||
.join("tolaria")
|
||||
.join("mcp-server"),
|
||||
);
|
||||
candidates
|
||||
}
|
||||
|
||||
fn mcp_server_dir_has_files(path: &Path) -> bool {
|
||||
path.join("index.js").is_file() && path.join("ws-bridge.js").is_file()
|
||||
}
|
||||
|
||||
/// Spawn the WebSocket bridge as a child process.
|
||||
pub fn spawn_ws_bridge(vault_path: &str) -> Result<Child, String> {
|
||||
pub fn spawn_ws_bridge(vault_path: impl AsRef<Path>) -> Result<Child, String> {
|
||||
let node = find_node()?;
|
||||
let server_dir = mcp_server_dir()?;
|
||||
let script = server_dir.join("ws-bridge.js");
|
||||
let vault_path = vault_path.as_ref();
|
||||
|
||||
let child = crate::hidden_command(node)
|
||||
.arg(&script)
|
||||
@@ -119,7 +232,11 @@ pub fn spawn_ws_bridge(vault_path: &str) -> Result<Child, String> {
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to spawn ws-bridge: {e}"))?;
|
||||
|
||||
log::info!("ws-bridge spawned (pid: {})", child.id());
|
||||
log::info!(
|
||||
"ws-bridge spawned (pid: {}, vault: {})",
|
||||
child.id(),
|
||||
vault_path.display()
|
||||
);
|
||||
Ok(child)
|
||||
}
|
||||
|
||||
@@ -160,6 +277,14 @@ fn entry_index_js_exists(entry: &serde_json::Value) -> bool {
|
||||
.is_some_and(|index_js| Path::new(index_js).exists())
|
||||
}
|
||||
|
||||
fn entry_uses_stdio(entry: &serde_json::Value) -> bool {
|
||||
entry["type"].as_str() == Some("stdio")
|
||||
}
|
||||
|
||||
fn entry_has_ui_port(entry: &serde_json::Value) -> bool {
|
||||
entry["env"]["WS_UI_PORT"].as_str() == Some("9711")
|
||||
}
|
||||
|
||||
fn entry_targets_vault(entry: &serde_json::Value, vault_path: &Path) -> bool {
|
||||
let Some(entry_vault_path) = entry["env"]["VAULT_PATH"].as_str() else {
|
||||
return false;
|
||||
@@ -176,11 +301,15 @@ fn entry_targets_vault(entry: &serde_json::Value, vault_path: &Path) -> bool {
|
||||
}
|
||||
|
||||
/// Build the MCP server entry JSON for a given vault path and index.js path.
|
||||
fn build_mcp_entry(index_js: &str, vault_path: &str) -> serde_json::Value {
|
||||
fn build_mcp_entry(node_command: &str, index_js: &str, vault_path: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"command": "node",
|
||||
"type": "stdio",
|
||||
"command": node_command,
|
||||
"args": [index_js],
|
||||
"env": { "VAULT_PATH": vault_path }
|
||||
"env": {
|
||||
"VAULT_PATH": vault_path,
|
||||
"WS_UI_PORT": "9711"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -200,10 +329,14 @@ fn register_mcp_to_configs(entry: &serde_json::Value, config_paths: &[PathBuf])
|
||||
|
||||
/// Register Tolaria as an MCP server in external AI tool config files.
|
||||
pub fn register_mcp(vault_path: &str) -> Result<String, String> {
|
||||
let node = find_node().map_err(|e| {
|
||||
format!("Node.js 18+ is required on PATH before Tolaria can register MCP tools: {e}")
|
||||
})?;
|
||||
let server_dir = mcp_server_dir()?;
|
||||
let index_js = server_dir.join("index.js").to_string_lossy().into_owned();
|
||||
let node_command = node.to_string_lossy().into_owned();
|
||||
|
||||
let entry = build_mcp_entry(&index_js, vault_path);
|
||||
let entry = build_mcp_entry(&node_command, &index_js, vault_path);
|
||||
|
||||
Ok(register_mcp_to_configs(&entry, &mcp_config_paths()))
|
||||
}
|
||||
@@ -317,7 +450,10 @@ pub fn check_mcp_status(vault_path: &str) -> McpStatus {
|
||||
let active_vault_path = Path::new(vault_path);
|
||||
if mcp_config_paths().into_iter().any(|config_path| {
|
||||
read_registered_mcp_entry(&config_path).is_some_and(|entry| {
|
||||
entry_index_js_exists(&entry) && entry_targets_vault(&entry, active_vault_path)
|
||||
entry_uses_stdio(&entry)
|
||||
&& entry_index_js_exists(&entry)
|
||||
&& entry_has_ui_port(&entry)
|
||||
&& entry_targets_vault(&entry, active_vault_path)
|
||||
})
|
||||
}) {
|
||||
McpStatus::Installed
|
||||
@@ -347,12 +483,17 @@ mod tests {
|
||||
|
||||
fn managed_server(index_js: &str, vault_path: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "stdio",
|
||||
"command": "node",
|
||||
"args": [index_js],
|
||||
"env": { "VAULT_PATH": vault_path }
|
||||
"env": { "VAULT_PATH": vault_path, "WS_UI_PORT": "9711" }
|
||||
})
|
||||
}
|
||||
|
||||
fn test_mcp_entry(index_js: &str, vault_path: &str) -> serde_json::Value {
|
||||
build_mcp_entry("node", index_js, vault_path)
|
||||
}
|
||||
|
||||
fn write_mcp_servers_config(config_path: &Path, servers: Vec<(&str, serde_json::Value)>) {
|
||||
let servers = servers
|
||||
.into_iter()
|
||||
@@ -369,17 +510,56 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn build_mcp_entry_produces_correct_json() {
|
||||
let entry = build_mcp_entry("/path/to/index.js", "/my/vault");
|
||||
assert_eq!(entry["command"], "node");
|
||||
let entry = build_mcp_entry("/usr/local/bin/node", "/path/to/index.js", "/my/vault");
|
||||
assert_eq!(entry["type"], "stdio");
|
||||
assert_eq!(entry["command"], "/usr/local/bin/node");
|
||||
assert_eq!(entry["args"][0], "/path/to/index.js");
|
||||
assert_eq!(entry["env"]["VAULT_PATH"], "/my/vault");
|
||||
assert_eq!(entry["env"]["WS_UI_PORT"], "9711");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_node_lookup_path_uses_first_non_empty_line() {
|
||||
let stdout = b"\nC:\\Program Files\\nodejs\\node.exe\r\nC:\\Other\\node.exe\r\n";
|
||||
assert_eq!(
|
||||
first_node_lookup_path(stdout).unwrap(),
|
||||
PathBuf::from("C:\\Program Files\\nodejs\\node.exe")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_major_version_accepts_current_node_output() {
|
||||
assert_eq!(node_major_version("v24.13.1\n"), Some(24));
|
||||
assert_eq!(node_major_version("18.19.0"), Some(18));
|
||||
assert_eq!(node_major_version("not-node"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_server_dir_candidates_prefer_exe_dir_before_macos_resources() {
|
||||
let dev_path = Path::new("/repo/mcp-server");
|
||||
let exe_path = Path::new("/Users/tester/AppData/Local/Tolaria/tolaria.exe");
|
||||
let candidates = mcp_server_dir_candidates(dev_path, exe_path, None);
|
||||
|
||||
let windows_dir = PathBuf::from("/Users/tester/AppData/Local/Tolaria/mcp-server");
|
||||
let macos_dir = PathBuf::from("/Users/tester/AppData/Local/Resources/mcp-server");
|
||||
let windows_pos = candidates
|
||||
.iter()
|
||||
.position(|path| path == &windows_dir)
|
||||
.unwrap();
|
||||
let macos_pos = candidates
|
||||
.iter()
|
||||
.position(|path| path == &macos_dir)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(candidates[0], dev_path);
|
||||
assert!(windows_pos < macos_pos);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_creates_new_config() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("mcp.json");
|
||||
let entry = build_mcp_entry("/test/index.js", "/test/vault");
|
||||
let entry = test_mcp_entry("/test/index.js", "/test/vault");
|
||||
|
||||
let was_update = upsert_mcp_config(&config_path, &entry).unwrap();
|
||||
assert!(!was_update);
|
||||
@@ -400,10 +580,10 @@ mod tests {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("mcp.json");
|
||||
|
||||
let entry1 = build_mcp_entry("/test/index.js", "/vault/v1");
|
||||
let entry1 = test_mcp_entry("/test/index.js", "/vault/v1");
|
||||
upsert_mcp_config(&config_path, &entry1).unwrap();
|
||||
|
||||
let entry2 = build_mcp_entry("/test/index.js", "/vault/v2");
|
||||
let entry2 = test_mcp_entry("/test/index.js", "/vault/v2");
|
||||
let was_update = upsert_mcp_config(&config_path, &entry2).unwrap();
|
||||
assert!(was_update);
|
||||
|
||||
@@ -430,7 +610,7 @@ mod tests {
|
||||
});
|
||||
std::fs::write(&config_path, serde_json::to_string(&existing).unwrap()).unwrap();
|
||||
|
||||
let entry = build_mcp_entry("/test/index.js", "/vault");
|
||||
let entry = test_mcp_entry("/test/index.js", "/vault");
|
||||
let was_update = upsert_mcp_config(&config_path, &entry).unwrap();
|
||||
assert!(was_update);
|
||||
|
||||
@@ -453,7 +633,7 @@ mod tests {
|
||||
)],
|
||||
);
|
||||
|
||||
let entry = build_mcp_entry("/test/index.js", "/vault");
|
||||
let entry = test_mcp_entry("/test/index.js", "/vault");
|
||||
upsert_mcp_config(&config_path, &entry).unwrap();
|
||||
|
||||
let raw = std::fs::read_to_string(&config_path).unwrap();
|
||||
@@ -476,7 +656,7 @@ mod tests {
|
||||
}),
|
||||
);
|
||||
|
||||
let entry = build_mcp_entry("/test/index.js", "/vault");
|
||||
let entry = test_mcp_entry("/test/index.js", "/vault");
|
||||
upsert_mcp_config(&config_path, &entry).unwrap();
|
||||
|
||||
let config = read_config(&config_path);
|
||||
@@ -490,7 +670,7 @@ mod tests {
|
||||
fn upsert_creates_parent_dirs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("nested").join("dir").join("mcp.json");
|
||||
let entry = build_mcp_entry("/test/index.js", "/vault");
|
||||
let entry = test_mcp_entry("/test/index.js", "/vault");
|
||||
|
||||
upsert_mcp_config(&config_path, &entry).unwrap();
|
||||
assert!(config_path.exists());
|
||||
@@ -500,7 +680,7 @@ mod tests {
|
||||
fn register_mcp_to_configs_returns_registered_for_new() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config = tmp.path().join("claude").join("mcp.json");
|
||||
let entry = build_mcp_entry("/test/index.js", "/vault");
|
||||
let entry = test_mcp_entry("/test/index.js", "/vault");
|
||||
|
||||
let status = register_mcp_to_configs(&entry, &[config]);
|
||||
assert_eq!(status, "registered");
|
||||
@@ -510,7 +690,7 @@ mod tests {
|
||||
fn register_mcp_to_configs_returns_updated_for_existing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config = tmp.path().join("mcp.json");
|
||||
let entry = build_mcp_entry("/test/index.js", "/vault");
|
||||
let entry = test_mcp_entry("/test/index.js", "/vault");
|
||||
|
||||
// First call
|
||||
register_mcp_to_configs(&entry, std::slice::from_ref(&config));
|
||||
@@ -558,7 +738,7 @@ mod tests {
|
||||
let claude_cfg = tmp.path().join("claude").join("mcp.json");
|
||||
let cursor_cfg = tmp.path().join("cursor").join("mcp.json");
|
||||
let generic_cfg = tmp.path().join(".config").join("mcp").join("mcp.json");
|
||||
let entry = build_mcp_entry("/test/index.js", "/vault");
|
||||
let entry = test_mcp_entry("/test/index.js", "/vault");
|
||||
|
||||
register_mcp_to_configs(
|
||||
&entry,
|
||||
@@ -603,14 +783,14 @@ mod tests {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("mcp.json");
|
||||
std::fs::write(&config_path, "not valid json{{{{").unwrap();
|
||||
let entry = build_mcp_entry("/test/index.js", "/vault");
|
||||
let entry = test_mcp_entry("/test/index.js", "/vault");
|
||||
let result = upsert_mcp_config(&config_path, &entry);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_mcp_to_configs_handles_empty_list() {
|
||||
let entry = build_mcp_entry("/test/index.js", "/vault");
|
||||
let entry = test_mcp_entry("/test/index.js", "/vault");
|
||||
// Empty config list — function should return "registered" (no existing)
|
||||
let status = register_mcp_to_configs(&entry, &[]);
|
||||
// With empty config list, there were no updates, so status should be "registered"
|
||||
@@ -695,7 +875,7 @@ mod tests {
|
||||
let config_path = tmp.path().join("mcp.json");
|
||||
std::fs::write(&config_path, "[]").unwrap();
|
||||
|
||||
let entry = build_mcp_entry("/test/index.js", "/vault");
|
||||
let entry = test_mcp_entry("/test/index.js", "/vault");
|
||||
let result = upsert_mcp_config(&config_path, &entry);
|
||||
assert!(matches!(result, Err(ref error) if error.contains("Config is not a JSON object")));
|
||||
}
|
||||
@@ -709,7 +889,7 @@ mod tests {
|
||||
});
|
||||
std::fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap();
|
||||
|
||||
let entry = build_mcp_entry("/test/index.js", "/vault");
|
||||
let entry = test_mcp_entry("/test/index.js", "/vault");
|
||||
let result = upsert_mcp_config(&config_path, &entry);
|
||||
assert!(
|
||||
matches!(result, Err(ref error) if error.contains("mcpServers is not a JSON object"))
|
||||
|
||||
58
src-tauri/src/opencode_cli.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent};
|
||||
use std::io::BufRead;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentStreamRequest {
|
||||
pub message: String,
|
||||
pub system_prompt: Option<String>,
|
||||
pub vault_path: String,
|
||||
}
|
||||
|
||||
pub fn check_cli() -> AiAgentAvailability {
|
||||
crate::opencode_discovery::check_cli()
|
||||
}
|
||||
|
||||
pub fn run_agent_stream<F>(request: AgentStreamRequest, mut emit: F) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let binary = crate::opencode_discovery::find_binary()?;
|
||||
let mut command = crate::opencode_config::build_command(&binary, &request)?;
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|error| format!("Failed to spawn opencode: {error}"))?;
|
||||
|
||||
let stdout = child.stdout.take().ok_or("No stdout handle")?;
|
||||
let reader = std::io::BufReader::new(stdout);
|
||||
let mut session_id = String::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
let json = match crate::opencode_events::parse_line(line, &mut emit) {
|
||||
Some(json) => json,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
if let Some(id) = crate::opencode_events::session_id(&json) {
|
||||
session_id = id.to_string();
|
||||
}
|
||||
|
||||
crate::opencode_events::dispatch_event(&json, &mut emit);
|
||||
}
|
||||
|
||||
let stderr_output = child
|
||||
.stderr
|
||||
.take()
|
||||
.and_then(|stderr| std::io::read_to_string(stderr).ok())
|
||||
.unwrap_or_default();
|
||||
let status = child
|
||||
.wait()
|
||||
.map_err(|error| format!("Wait failed: {error}"))?;
|
||||
if !status.success() {
|
||||
emit(AiAgentStreamEvent::Error {
|
||||
message: crate::opencode_events::format_error(stderr_output, status.to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
emit(AiAgentStreamEvent::Done);
|
||||
Ok(session_id)
|
||||
}
|
||||
149
src-tauri/src/opencode_config.rs
Normal file
@@ -0,0 +1,149 @@
|
||||
use crate::opencode_cli::AgentStreamRequest;
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
|
||||
pub(crate) fn build_command(
|
||||
binary: &Path,
|
||||
request: &AgentStreamRequest,
|
||||
) -> Result<std::process::Command, String> {
|
||||
let mut command = crate::hidden_command(binary);
|
||||
command
|
||||
.args(build_args())
|
||||
.arg(build_prompt(request))
|
||||
.env(
|
||||
"OPENCODE_CONFIG_CONTENT",
|
||||
build_config(&request.vault_path)?,
|
||||
)
|
||||
.current_dir(&request.vault_path)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
fn build_args() -> Vec<String> {
|
||||
vec!["run".into(), "--format".into(), "json".into()]
|
||||
}
|
||||
|
||||
fn build_prompt(request: &AgentStreamRequest) -> String {
|
||||
match request
|
||||
.system_prompt
|
||||
.as_ref()
|
||||
.map(|prompt| prompt.trim())
|
||||
.filter(|prompt| !prompt.is_empty())
|
||||
{
|
||||
Some(system_prompt) => format!(
|
||||
"System instructions:\n{system_prompt}\n\nUser request:\n{}",
|
||||
request.message
|
||||
),
|
||||
None => request.message.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_config(vault_path: &str) -> Result<String, String> {
|
||||
let mcp_server = crate::mcp::mcp_server_dir()?.join("index.js");
|
||||
let mcp_server_path = mcp_server
|
||||
.to_str()
|
||||
.ok_or("Invalid MCP server path")?
|
||||
.to_string();
|
||||
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"permission": permission_config(),
|
||||
"mcp": {
|
||||
"tolaria": {
|
||||
"type": "local",
|
||||
"command": ["node", mcp_server_path],
|
||||
"environment": { "VAULT_PATH": vault_path },
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}))
|
||||
.map_err(|error| format!("Failed to serialize opencode config: {error}"))
|
||||
}
|
||||
|
||||
fn permission_config() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"read": "allow",
|
||||
"edit": "allow",
|
||||
"glob": "allow",
|
||||
"grep": "allow",
|
||||
"list": "allow",
|
||||
"external_directory": "deny",
|
||||
"bash": "deny"
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::ffi::OsStr;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn request() -> AgentStreamRequest {
|
||||
AgentStreamRequest {
|
||||
message: "Rename the note".into(),
|
||||
system_prompt: None,
|
||||
vault_path: "/tmp/vault".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn args_use_documented_safe_run_mode() {
|
||||
let args = build_args();
|
||||
|
||||
assert_eq!(args, vec!["run", "--format", "json"]);
|
||||
assert!(!args.contains(&"--dangerously-skip-permissions".to_string()));
|
||||
assert!(!args.contains(&"--dir".to_string()));
|
||||
assert!(!args.contains(&"--thinking".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_sets_vault_cwd_and_mcp_config() {
|
||||
let command = build_command(&PathBuf::from("opencode"), &request()).unwrap();
|
||||
let actual_args: Vec<&OsStr> = command.get_args().collect();
|
||||
let config_value = command
|
||||
.get_envs()
|
||||
.find(|(key, _)| *key == OsStr::new("OPENCODE_CONFIG_CONTENT"))
|
||||
.and_then(|(_, value)| value);
|
||||
|
||||
assert_eq!(command.get_program(), OsStr::new("opencode"));
|
||||
assert_eq!(actual_args[0], OsStr::new("run"));
|
||||
assert_eq!(actual_args[1], OsStr::new("--format"));
|
||||
assert_eq!(actual_args[2], OsStr::new("json"));
|
||||
assert_eq!(actual_args.last(), Some(&OsStr::new("Rename the note")));
|
||||
assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault")));
|
||||
assert!(config_value.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_includes_permissions_and_tolaria_mcp_server() {
|
||||
if let Ok(config) = build_config("/tmp/vault") {
|
||||
let json: serde_json::Value = serde_json::from_str(&config).unwrap();
|
||||
assert_eq!(json["permission"]["edit"], "allow");
|
||||
assert_eq!(json["permission"]["external_directory"], "deny");
|
||||
assert_eq!(json["permission"]["bash"], "deny");
|
||||
assert_eq!(json["mcp"]["tolaria"]["type"], "local");
|
||||
assert_eq!(json["mcp"]["tolaria"]["command"][0], "node");
|
||||
assert_eq!(
|
||||
json["mcp"]["tolaria"]["environment"]["VAULT_PATH"],
|
||||
"/tmp/vault"
|
||||
);
|
||||
assert!(json["mcp"]["tolaria"]["command"][1]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.ends_with("index.js"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_keeps_system_prompt_first() {
|
||||
let prompt = build_prompt(&AgentStreamRequest {
|
||||
system_prompt: Some("Be concise".into()),
|
||||
..request()
|
||||
});
|
||||
|
||||
assert!(prompt.starts_with("System instructions:\nBe concise"));
|
||||
assert!(prompt.contains("User request:\nRename the note"));
|
||||
}
|
||||
}
|
||||
159
src-tauri/src/opencode_discovery.rs
Normal file
@@ -0,0 +1,159 @@
|
||||
use crate::ai_agents::AiAgentAvailability;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(crate) fn check_cli() -> AiAgentAvailability {
|
||||
match find_binary() {
|
||||
Ok(binary) => AiAgentAvailability {
|
||||
installed: true,
|
||||
version: version_for_binary(&binary),
|
||||
},
|
||||
Err(_) => AiAgentAvailability {
|
||||
installed: false,
|
||||
version: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn find_binary() -> Result<PathBuf, String> {
|
||||
if let Some(binary) = find_binary_on_path() {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
if let Some(binary) = find_binary_in_user_shell() {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
if let Some(binary) = find_existing_binary(opencode_binary_candidates()) {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
Err("OpenCode CLI not found. Install it: https://opencode.ai/docs/".into())
|
||||
}
|
||||
|
||||
fn version_for_binary(binary: &PathBuf) -> Option<String> {
|
||||
crate::hidden_command(binary)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|output| output.status.success())
|
||||
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
fn find_binary_on_path() -> Option<PathBuf> {
|
||||
crate::hidden_command("which")
|
||||
.arg("opencode")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| path_from_successful_output(&output))
|
||||
}
|
||||
|
||||
fn find_binary_in_user_shell() -> Option<PathBuf> {
|
||||
user_shell_candidates()
|
||||
.into_iter()
|
||||
.filter(|shell| shell.exists())
|
||||
.find_map(|shell| command_path_from_shell(&shell, "opencode"))
|
||||
}
|
||||
|
||||
fn user_shell_candidates() -> Vec<PathBuf> {
|
||||
let mut shells = Vec::new();
|
||||
if let Some(shell) = std::env::var_os("SHELL") {
|
||||
if !shell.is_empty() {
|
||||
shells.push(PathBuf::from(shell));
|
||||
}
|
||||
}
|
||||
shells.push(PathBuf::from("/bin/zsh"));
|
||||
shells.push(PathBuf::from("/bin/bash"));
|
||||
shells
|
||||
}
|
||||
|
||||
fn command_path_from_shell(shell: &Path, command: &str) -> Option<PathBuf> {
|
||||
crate::hidden_command(shell)
|
||||
.arg("-lc")
|
||||
.arg(format!("command -v {command}"))
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| path_from_successful_output(&output))
|
||||
}
|
||||
|
||||
fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf> {
|
||||
if output.status.success() {
|
||||
first_existing_path(&String::from_utf8_lossy(&output.stdout))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn first_existing_path(stdout: &str) -> Option<PathBuf> {
|
||||
stdout.lines().find_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let candidate = PathBuf::from(trimmed);
|
||||
candidate.exists().then_some(candidate)
|
||||
})
|
||||
}
|
||||
|
||||
fn find_existing_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
|
||||
candidates.into_iter().find(|candidate| candidate.exists())
|
||||
}
|
||||
|
||||
fn opencode_binary_candidates() -> Vec<PathBuf> {
|
||||
dirs::home_dir()
|
||||
.map(|home| opencode_binary_candidates_for_home(&home))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn opencode_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
vec![
|
||||
home.join(".local/bin/opencode"),
|
||||
home.join(".opencode/bin/opencode"),
|
||||
home.join(".local/share/mise/shims/opencode"),
|
||||
home.join(".asdf/shims/opencode"),
|
||||
home.join(".npm-global/bin/opencode"),
|
||||
home.join(".npm/bin/opencode"),
|
||||
home.join(".bun/bin/opencode"),
|
||||
PathBuf::from("/usr/local/bin/opencode"),
|
||||
PathBuf::from("/opt/homebrew/bin/opencode"),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn binary_candidates_include_supported_local_installs() {
|
||||
let home = PathBuf::from("/Users/alex");
|
||||
let candidates = opencode_binary_candidates_for_home(&home);
|
||||
let expected = [
|
||||
home.join(".local/bin/opencode"),
|
||||
home.join(".opencode/bin/opencode"),
|
||||
home.join(".local/share/mise/shims/opencode"),
|
||||
home.join(".asdf/shims/opencode"),
|
||||
home.join(".npm-global/bin/opencode"),
|
||||
home.join(".bun/bin/opencode"),
|
||||
PathBuf::from("/opt/homebrew/bin/opencode"),
|
||||
];
|
||||
|
||||
for candidate in expected {
|
||||
assert!(
|
||||
candidates.contains(&candidate),
|
||||
"missing {}",
|
||||
candidate.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_existing_path_skips_empty_and_missing_lines() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let missing = dir.path().join("missing-opencode");
|
||||
let opencode = dir.path().join("opencode");
|
||||
std::fs::write(&opencode, "#!/bin/sh\n").unwrap();
|
||||
|
||||
let stdout = format!("\n{}\n{}\n", missing.display(), opencode.display());
|
||||
|
||||
assert_eq!(first_existing_path(&stdout), Some(opencode));
|
||||
}
|
||||
}
|
||||
171
src-tauri/src/opencode_events.rs
Normal file
@@ -0,0 +1,171 @@
|
||||
use crate::ai_agents::AiAgentStreamEvent;
|
||||
|
||||
pub(crate) fn parse_line<F>(
|
||||
line: Result<String, std::io::Error>,
|
||||
emit: &mut F,
|
||||
) -> Option<serde_json::Value>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let line = match line {
|
||||
Ok(line) => line,
|
||||
Err(error) => {
|
||||
emit(AiAgentStreamEvent::Error {
|
||||
message: format!("Read error: {error}"),
|
||||
});
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
serde_json::from_str::<serde_json::Value>(trimmed).ok()
|
||||
}
|
||||
|
||||
pub(crate) fn dispatch_event<F>(json: &serde_json::Value, emit: &mut F)
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
if json["type"].as_str() == Some("session") {
|
||||
emit_session_event(json, emit);
|
||||
}
|
||||
|
||||
match json["type"].as_str().unwrap_or_default() {
|
||||
"message" | "text" => emit_text(json, emit),
|
||||
"reasoning" => emit_reasoning(json, emit),
|
||||
"tool_use" | "tool" => emit_tool_start(json, emit),
|
||||
"tool_result" | "tool_done" => emit_tool_done(json, emit),
|
||||
"error" => emit_error_event(json, emit),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn session_id(json: &serde_json::Value) -> Option<&str> {
|
||||
json["sessionID"]
|
||||
.as_str()
|
||||
.or_else(|| json["session_id"].as_str())
|
||||
.or_else(|| json["session"]["id"].as_str())
|
||||
}
|
||||
|
||||
pub(crate) fn format_error(stderr_output: String, status: String) -> String {
|
||||
let lower = stderr_output.to_ascii_lowercase();
|
||||
if is_auth_error(&lower) {
|
||||
return "OpenCode CLI is not authenticated or has no provider configured. Run `opencode auth login` or configure a provider in OpenCode, then retry.".into();
|
||||
}
|
||||
|
||||
if stderr_output.trim().is_empty() {
|
||||
format!("opencode exited with status {status}")
|
||||
} else {
|
||||
stderr_output.lines().take(3).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_session_event<F>(json: &serde_json::Value, emit: &mut F)
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
if let Some(session_id) = session_id(json) {
|
||||
emit(AiAgentStreamEvent::Init {
|
||||
session_id: session_id.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_text<F>(json: &serde_json::Value, emit: &mut F)
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
if let Some(text) = text_value(json) {
|
||||
emit(AiAgentStreamEvent::TextDelta {
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_reasoning<F>(json: &serde_json::Value, emit: &mut F)
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
if let Some(text) = text_value(json) {
|
||||
emit(AiAgentStreamEvent::ThinkingDelta {
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_tool_start<F>(json: &serde_json::Value, emit: &mut F)
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let tool_id = tool_id(json).unwrap_or("tool").to_string();
|
||||
let tool_name = json["name"]
|
||||
.as_str()
|
||||
.or_else(|| json["tool"].as_str())
|
||||
.unwrap_or("tool")
|
||||
.to_string();
|
||||
let input = json.get("input").map(|input| input.to_string());
|
||||
|
||||
emit(AiAgentStreamEvent::ToolStart {
|
||||
tool_name,
|
||||
tool_id,
|
||||
input,
|
||||
});
|
||||
}
|
||||
|
||||
fn emit_tool_done<F>(json: &serde_json::Value, emit: &mut F)
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let tool_id = tool_id(json).unwrap_or("tool").to_string();
|
||||
let output = json["output"]
|
||||
.as_str()
|
||||
.map(|output| output.to_string())
|
||||
.or_else(|| json.get("result").map(|result| result.to_string()));
|
||||
|
||||
emit(AiAgentStreamEvent::ToolDone { tool_id, output });
|
||||
}
|
||||
|
||||
fn emit_error_event<F>(json: &serde_json::Value, emit: &mut F)
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
if let Some(message) = message_value(json) {
|
||||
emit(AiAgentStreamEvent::Error {
|
||||
message: message.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_id(json: &serde_json::Value) -> Option<&str> {
|
||||
json["id"]
|
||||
.as_str()
|
||||
.or_else(|| json["toolID"].as_str())
|
||||
.or_else(|| json["tool_id"].as_str())
|
||||
}
|
||||
|
||||
fn text_value(json: &serde_json::Value) -> Option<&str> {
|
||||
json["text"]
|
||||
.as_str()
|
||||
.or_else(|| json["content"].as_str())
|
||||
.or_else(|| json["message"].as_str())
|
||||
}
|
||||
|
||||
fn message_value(json: &serde_json::Value) -> Option<&str> {
|
||||
json["message"]
|
||||
.as_str()
|
||||
.or_else(|| json["error"].as_str())
|
||||
.or_else(|| json["text"].as_str())
|
||||
}
|
||||
|
||||
fn is_auth_error(lower: &str) -> bool {
|
||||
["auth", "login", "sign in", "api key", "provider"]
|
||||
.iter()
|
||||
.any(|pattern| lower.contains(pattern))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "opencode_events_tests.rs"]
|
||||
mod tests;
|
||||
63
src-tauri/src/opencode_events_tests.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dispatch_maps_session_reasoning_and_text() {
|
||||
let mut events = Vec::new();
|
||||
let started = serde_json::json!({ "type": "session", "sessionID": "ses_1" });
|
||||
let reasoning = serde_json::json!({ "type": "reasoning", "text": "Checking links" });
|
||||
let text = serde_json::json!({ "type": "message", "text": "Done" });
|
||||
|
||||
for event in [started, reasoning, text] {
|
||||
dispatch_event(&event, &mut |event| events.push(event));
|
||||
}
|
||||
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::Init { session_id } if session_id == "ses_1"
|
||||
));
|
||||
assert!(matches!(
|
||||
&events[1],
|
||||
AiAgentStreamEvent::ThinkingDelta { text } if text == "Checking links"
|
||||
));
|
||||
assert!(matches!(
|
||||
&events[2],
|
||||
AiAgentStreamEvent::TextDelta { text } if text == "Done"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_maps_tool_events() {
|
||||
let mut events = Vec::new();
|
||||
let tool_start = serde_json::json!({
|
||||
"type": "tool_use",
|
||||
"id": "tool_1",
|
||||
"name": "read",
|
||||
"input": { "path": "Note.md" }
|
||||
});
|
||||
let tool_done = serde_json::json!({ "type": "tool_result", "id": "tool_1", "output": "ok" });
|
||||
|
||||
dispatch_event(&tool_start, &mut |event| events.push(event));
|
||||
dispatch_event(&tool_done, &mut |event| events.push(event));
|
||||
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::ToolStart { tool_name, tool_id, input }
|
||||
if tool_name == "read" && tool_id == "tool_1" && input.as_deref() == Some(r#"{"path":"Note.md"}"#)
|
||||
));
|
||||
assert!(matches!(
|
||||
&events[1],
|
||||
AiAgentStreamEvent::ToolDone { tool_id, output }
|
||||
if tool_id == "tool_1" && output.as_deref() == Some("ok")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_error_explains_missing_auth_or_provider_setup() {
|
||||
let message = format_error(
|
||||
"provider auth failed: please login".into(),
|
||||
"exit status: 1".into(),
|
||||
);
|
||||
|
||||
assert!(message.contains("OpenCode CLI is not authenticated"));
|
||||
assert!(message.contains("opencode auth login"));
|
||||
}
|
||||
62
src-tauri/src/pi_cli.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent};
|
||||
use std::io::BufRead;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentStreamRequest {
|
||||
pub message: String,
|
||||
pub system_prompt: Option<String>,
|
||||
pub vault_path: String,
|
||||
}
|
||||
|
||||
pub fn check_cli() -> AiAgentAvailability {
|
||||
crate::pi_discovery::check_cli()
|
||||
}
|
||||
|
||||
pub fn run_agent_stream<F>(request: AgentStreamRequest, mut emit: F) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let binary = crate::pi_discovery::find_binary()?;
|
||||
let agent_dir = tempfile::Builder::new()
|
||||
.prefix("tolaria-pi-agent-")
|
||||
.tempdir()
|
||||
.map_err(|error| format!("Failed to create Pi config directory: {error}"))?;
|
||||
let mut command = crate::pi_config::build_command(&binary, &request, agent_dir.path())?;
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|error| format!("Failed to spawn pi: {error}"))?;
|
||||
|
||||
let stdout = child.stdout.take().ok_or("No stdout handle")?;
|
||||
let reader = std::io::BufReader::new(stdout);
|
||||
let mut session_id = String::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
let json = match crate::pi_events::parse_line(line, &mut emit) {
|
||||
Some(json) => json,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
if let Some(id) = crate::pi_events::session_id(&json) {
|
||||
session_id = id.to_string();
|
||||
}
|
||||
|
||||
crate::pi_events::dispatch_event(&json, &mut emit);
|
||||
}
|
||||
|
||||
let stderr_output = child
|
||||
.stderr
|
||||
.take()
|
||||
.and_then(|stderr| std::io::read_to_string(stderr).ok())
|
||||
.unwrap_or_default();
|
||||
let status = child
|
||||
.wait()
|
||||
.map_err(|error| format!("Wait failed: {error}"))?;
|
||||
if !status.success() {
|
||||
emit(AiAgentStreamEvent::Error {
|
||||
message: crate::pi_events::format_error(stderr_output, status.to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
emit(AiAgentStreamEvent::Done);
|
||||
Ok(session_id)
|
||||
}
|
||||
160
src-tauri/src/pi_config.rs
Normal file
@@ -0,0 +1,160 @@
|
||||
use crate::pi_cli::AgentStreamRequest;
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
|
||||
pub(crate) fn build_command(
|
||||
binary: &Path,
|
||||
request: &AgentStreamRequest,
|
||||
agent_dir: &Path,
|
||||
) -> Result<std::process::Command, String> {
|
||||
write_mcp_config(agent_dir, &request.vault_path)?;
|
||||
|
||||
let mut command = crate::hidden_command(binary);
|
||||
command
|
||||
.args(build_args())
|
||||
.arg(build_prompt(request))
|
||||
.env("PI_CODING_AGENT_DIR", agent_dir)
|
||||
.current_dir(&request.vault_path)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
fn build_args() -> Vec<String> {
|
||||
vec![
|
||||
"--mode".into(),
|
||||
"json".into(),
|
||||
"--no-session".into(),
|
||||
"--extension".into(),
|
||||
"npm:pi-mcp-adapter".into(),
|
||||
]
|
||||
}
|
||||
|
||||
fn build_prompt(request: &AgentStreamRequest) -> String {
|
||||
match request
|
||||
.system_prompt
|
||||
.as_ref()
|
||||
.map(|prompt| prompt.trim())
|
||||
.filter(|prompt| !prompt.is_empty())
|
||||
{
|
||||
Some(system_prompt) => format!(
|
||||
"System instructions:\n{system_prompt}\n\nUser request:\n{}",
|
||||
request.message
|
||||
),
|
||||
None => request.message.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_mcp_config(agent_dir: &Path, vault_path: &str) -> Result<(), String> {
|
||||
std::fs::create_dir_all(agent_dir)
|
||||
.map_err(|error| format!("Failed to create Pi agent directory: {error}"))?;
|
||||
let config = build_mcp_config(vault_path)?;
|
||||
std::fs::write(agent_dir.join("mcp.json"), config)
|
||||
.map_err(|error| format!("Failed to write Pi MCP config: {error}"))
|
||||
}
|
||||
|
||||
fn build_mcp_config(vault_path: &str) -> Result<String, String> {
|
||||
let mcp_server = crate::mcp::mcp_server_dir()?.join("index.js");
|
||||
let mcp_server_path = mcp_server
|
||||
.to_str()
|
||||
.ok_or("Invalid MCP server path")?
|
||||
.to_string();
|
||||
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"settings": {
|
||||
"toolPrefix": "none",
|
||||
"idleTimeout": 10
|
||||
},
|
||||
"mcpServers": {
|
||||
"tolaria": {
|
||||
"command": "node",
|
||||
"args": [mcp_server_path],
|
||||
"env": {
|
||||
"VAULT_PATH": vault_path,
|
||||
"WS_UI_PORT": "9711"
|
||||
},
|
||||
"lifecycle": "lazy",
|
||||
"directTools": true
|
||||
}
|
||||
}
|
||||
}))
|
||||
.map_err(|error| format!("Failed to serialize Pi MCP config: {error}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::ffi::OsStr;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn request() -> AgentStreamRequest {
|
||||
AgentStreamRequest {
|
||||
message: "Rename the note".into(),
|
||||
system_prompt: None,
|
||||
vault_path: "/tmp/vault".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn args_use_documented_json_mode_with_mcp_adapter() {
|
||||
let args = build_args();
|
||||
|
||||
assert_eq!(args[0], "--mode");
|
||||
assert_eq!(args[1], "json");
|
||||
assert!(args.contains(&"--no-session".to_string()));
|
||||
assert!(args.contains(&"--extension".to_string()));
|
||||
assert!(args.contains(&"npm:pi-mcp-adapter".to_string()));
|
||||
assert!(!args.contains(&"--no-tools".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_sets_vault_cwd_closed_stdin_and_config_dir() {
|
||||
let agent_dir = tempfile::tempdir().unwrap();
|
||||
let command = build_command(&PathBuf::from("pi"), &request(), agent_dir.path()).unwrap();
|
||||
let actual_args: Vec<&OsStr> = command.get_args().collect();
|
||||
let config_dir = command
|
||||
.get_envs()
|
||||
.find(|(key, _)| *key == OsStr::new("PI_CODING_AGENT_DIR"))
|
||||
.and_then(|(_, value)| value);
|
||||
|
||||
assert_eq!(command.get_program(), OsStr::new("pi"));
|
||||
assert_eq!(actual_args[0], OsStr::new("--mode"));
|
||||
assert_eq!(actual_args[1], OsStr::new("json"));
|
||||
assert_eq!(actual_args.last(), Some(&OsStr::new("Rename the note")));
|
||||
assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault")));
|
||||
assert_eq!(config_dir, Some(agent_dir.path().as_os_str()));
|
||||
assert!(agent_dir.path().join("mcp.json").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_config_includes_tolaria_server_for_active_vault() {
|
||||
if let Ok(config) = build_mcp_config("/tmp/vault") {
|
||||
let json: serde_json::Value = serde_json::from_str(&config).unwrap();
|
||||
assert_eq!(json["settings"]["toolPrefix"], "none");
|
||||
assert_eq!(json["mcpServers"]["tolaria"]["command"], "node");
|
||||
assert_eq!(json["mcpServers"]["tolaria"]["lifecycle"], "lazy");
|
||||
assert_eq!(json["mcpServers"]["tolaria"]["directTools"], true);
|
||||
assert_eq!(
|
||||
json["mcpServers"]["tolaria"]["env"]["VAULT_PATH"],
|
||||
"/tmp/vault"
|
||||
);
|
||||
assert_eq!(json["mcpServers"]["tolaria"]["env"]["WS_UI_PORT"], "9711");
|
||||
assert!(json["mcpServers"]["tolaria"]["args"][0]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.ends_with("index.js"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_keeps_system_prompt_first() {
|
||||
let prompt = build_prompt(&AgentStreamRequest {
|
||||
system_prompt: Some("Be concise".into()),
|
||||
..request()
|
||||
});
|
||||
|
||||
assert!(prompt.starts_with("System instructions:\nBe concise"));
|
||||
assert!(prompt.contains("User request:\nRename the note"));
|
||||
}
|
||||
}
|
||||
159
src-tauri/src/pi_discovery.rs
Normal file
@@ -0,0 +1,159 @@
|
||||
use crate::ai_agents::AiAgentAvailability;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(crate) fn check_cli() -> AiAgentAvailability {
|
||||
match find_binary() {
|
||||
Ok(binary) => AiAgentAvailability {
|
||||
installed: true,
|
||||
version: version_for_binary(&binary),
|
||||
},
|
||||
Err(_) => AiAgentAvailability {
|
||||
installed: false,
|
||||
version: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn find_binary() -> Result<PathBuf, String> {
|
||||
if let Some(binary) = find_binary_on_path() {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
if let Some(binary) = find_binary_in_user_shell() {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
if let Some(binary) = find_existing_binary(pi_binary_candidates()) {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
Err("Pi CLI not found. Install it: https://pi.dev".into())
|
||||
}
|
||||
|
||||
fn version_for_binary(binary: &PathBuf) -> Option<String> {
|
||||
crate::hidden_command(binary)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|output| output.status.success())
|
||||
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
fn find_binary_on_path() -> Option<PathBuf> {
|
||||
crate::hidden_command("which")
|
||||
.arg("pi")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| path_from_successful_output(&output))
|
||||
}
|
||||
|
||||
fn find_binary_in_user_shell() -> Option<PathBuf> {
|
||||
user_shell_candidates()
|
||||
.into_iter()
|
||||
.filter(|shell| shell.exists())
|
||||
.find_map(|shell| command_path_from_shell(&shell, "pi"))
|
||||
}
|
||||
|
||||
fn user_shell_candidates() -> Vec<PathBuf> {
|
||||
let mut shells = Vec::new();
|
||||
if let Some(shell) = std::env::var_os("SHELL") {
|
||||
if !shell.is_empty() {
|
||||
shells.push(PathBuf::from(shell));
|
||||
}
|
||||
}
|
||||
shells.push(PathBuf::from("/bin/zsh"));
|
||||
shells.push(PathBuf::from("/bin/bash"));
|
||||
shells
|
||||
}
|
||||
|
||||
fn command_path_from_shell(shell: &Path, command: &str) -> Option<PathBuf> {
|
||||
crate::hidden_command(shell)
|
||||
.arg("-lc")
|
||||
.arg(format!("command -v {command}"))
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| path_from_successful_output(&output))
|
||||
}
|
||||
|
||||
fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf> {
|
||||
if output.status.success() {
|
||||
first_existing_path(&String::from_utf8_lossy(&output.stdout))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn first_existing_path(stdout: &str) -> Option<PathBuf> {
|
||||
stdout.lines().find_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let candidate = PathBuf::from(trimmed);
|
||||
candidate.exists().then_some(candidate)
|
||||
})
|
||||
}
|
||||
|
||||
fn find_existing_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
|
||||
candidates.into_iter().find(|candidate| candidate.exists())
|
||||
}
|
||||
|
||||
fn pi_binary_candidates() -> Vec<PathBuf> {
|
||||
dirs::home_dir()
|
||||
.map(|home| pi_binary_candidates_for_home(&home))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn pi_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
vec![
|
||||
home.join(".local/bin/pi"),
|
||||
home.join(".pi/bin/pi"),
|
||||
home.join(".local/share/mise/shims/pi"),
|
||||
home.join(".asdf/shims/pi"),
|
||||
home.join(".npm-global/bin/pi"),
|
||||
home.join(".npm/bin/pi"),
|
||||
home.join(".bun/bin/pi"),
|
||||
PathBuf::from("/usr/local/bin/pi"),
|
||||
PathBuf::from("/opt/homebrew/bin/pi"),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn binary_candidates_include_supported_local_installs() {
|
||||
let home = PathBuf::from("/Users/alex");
|
||||
let candidates = pi_binary_candidates_for_home(&home);
|
||||
let expected = [
|
||||
home.join(".local/bin/pi"),
|
||||
home.join(".pi/bin/pi"),
|
||||
home.join(".local/share/mise/shims/pi"),
|
||||
home.join(".asdf/shims/pi"),
|
||||
home.join(".npm-global/bin/pi"),
|
||||
home.join(".bun/bin/pi"),
|
||||
PathBuf::from("/opt/homebrew/bin/pi"),
|
||||
];
|
||||
|
||||
for candidate in expected {
|
||||
assert!(
|
||||
candidates.contains(&candidate),
|
||||
"missing {}",
|
||||
candidate.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_existing_path_skips_empty_and_missing_lines() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let missing = dir.path().join("missing-pi");
|
||||
let pi = dir.path().join("pi");
|
||||
std::fs::write(&pi, "#!/bin/sh\n").unwrap();
|
||||
|
||||
let stdout = format!("\n{}\n{}\n", missing.display(), pi.display());
|
||||
|
||||
assert_eq!(first_existing_path(&stdout), Some(pi));
|
||||
}
|
||||
}
|
||||