Jotura
Updated · v0.1.39

Your AI, in your vault. Not the other way around.

Bring Claude Code, OpenAI Codex, or Gemini straight to your vault. Locally. Your notes are plain markdown files, and the CLI adds the same atomic writes and conflict detection the desktop app uses. The agents you already trust, talking to the notes you already own.

How it works

The same Rust core, two surfaces.

Jotura ships a companion command-line tool, jotura, built on the exact same Rust modules as the desktop app. Your agent talks to the CLI; the CLI talks to your vault — plain files on your own disk. There's no cloud bridge between your AI and your notes, and your AI provider only sees the snippets your agent explicitly chooses to send.

No unlock, ever.

Your notes are plain markdown files on disk, so every CLI command works the moment your agent runs it — no passphrase, no session dance. The only key in the system belongs to cloud sync, and the desktop and CLI share it automatically.

No clobbered work.

Every edit accepts a hash-based precondition. If you're typing in the desktop and your agent tries to overwrite the same note, the write fails with a clear conflict — never a silent loss. The agent re-reads, decides, retries.

Live updates everywhere.

When the CLI writes, the desktop's file tree, search index, and any open tab refresh within a second. Same in reverse. The two surfaces stay in sync without anyone touching a refresh button.

Step 1

Install Jotura.

The jotura command-line tool ships with the Jotura desktop app. Install the app for your platform; the CLI lands on your PATH automatically. One binary, one install — no separate download, no toolchain, no setup.

Once Jotura is installed, confirm the CLI is on your PATH:

jotura --version

That's it — vaults are plain folders of markdown files, so every command works immediately. No passphrase, no unlock step, nothing to configure before your agent can read and edit notes.

On macOS, the installer ships the CLI inside Jotura.app — open Settings → Command-line tool and click Install to symlink it to /usr/local/bin/jotura. On Windows and Linux (deb), the installer puts it on PATH automatically. On the Linux AppImage, the first launch offers to copy it to ~/.local/bin.

Step 2

Teach your agent.

Each agent has its own location for ambient instructions. The skill content is identical; only the file path changes.

Claude Code

Claude Code discovers skills from your global config. Drop the skill file in place; it auto-loads on the next session.

jotura skill install claude

Ask Claude something like "what's in my vault?" — the jotura-vault skill should trigger and the agent should run `jotura status --json` before anything else.

OpenAI Codex

Codex reads AGENTS.md files for ambient instructions. Drop the skill body in your global Codex config or a project-local AGENTS.md.

jotura skill install codex

Codex ignores the YAML frontmatter and reads the markdown body as system context. Start a new Codex session and ask it to read or search a note.

Gemini CLI

Gemini reads GEMINI.md files for project and global context. Same body content; the YAML frontmatter is ignored.

jotura skill install gemini

Start a new Gemini session and ask it to find a note. It should call jotura status first, then proceed.

Other agents work too — anything that can shell out and read ambient instructions. The skill content below tells the agent how to drive the CLI safely; the install location is the only thing that varies per provider.

Highlights

What makes it agent-pleasant.

The CLI is built around the way LLMs actually edit — small targeted changes, hash-checked safety, dry-run previews. Six features that compose into a workflow the desktop alone can't match.

Hash-checked edits.

Every mutating command accepts --if-hash. If the file changed under the agent, the write fails with a HashConflict and a unified diff between intended and current content — no silent clobbering, no guessing what to do next.

Atomic batch operations.

edit --apply runs a sequence of edits to one note with one hash check and one write. batch applies file-level ops across many notes — preconditions verified upfront, changes committed together.

Dry-run + diff preview.

--dry-run computes the change without writing; --diff returns unified-diff output. Compose both to preview any edit as a real diff before the agent commits to it.

Shared sync key with the desktop.

When cloud sync is on, `jotura sync login` on either surface caches the sync key in a 0600 file in your config dir — same trust model as your SSH keys. `jotura sync status` reports it; `jotura sync logout` clears it for both.

Vault-shape commands.

backlinks, links, templates, trash + restore, grep with the familiar ripgrep flags. The Obsidian-class workflows you'd expect, exposed for agent use.

Live change events.

jotura watch streams every vault change to stdout as one-JSON-per-line. Scripts and reactive agents can subscribe and respond to anything that touches the vault — yours, the desktop's, or sync's.

Capabilities

Every command, grouped.

Read & explore. Targeted edits. Atomic batches. Notes lifecycle. Frontmatter. Templates. Daily notes. Diagnostics. The full surface, with every flag worth knowing about.

Read & explore

Find and read notes. All commands support --json for structured output.

  • jotura read <path> Body to stdout; --numbered, --with-frontmatter
  • jotura ls [--folder F] List notes; recursive tree
  • jotura search <q> Fuzzy full-text via Tantivy
  • jotura quick-open <q> Filename-only fuzzy match
  • jotura grep <pattern> Regex over note content with --ignore-case, --include, --files-with-matches
  • jotura backlinks <path> Notes that link to <path>
  • jotura links <path> Outgoing links from <path>

Targeted edits

String-match by default — strict, fails on ambiguous. Line-based when you have positions.

  • edit --replace OLD --with NEW Strict; --all or --nth N for multiples
  • edit --replace-line N Replace single line
  • edit --replace-lines N:M Replace inclusive range
  • edit --insert-before/--insert-after N Insert at line position
  • edit --delete-lines N:M Remove range
  • edit --append / --prepend Top (after frontmatter) or end
  • write <path> Body-default; --full for whole file

Atomic batches

Many edits in one call — fewer round trips, transactional precondition checks.

  • edit --apply <FILE> Multi-edit one note; ops apply sequentially
  • batch <FILE> Multi-file batch; plan + preconditions atomic

Notes lifecycle

Create, rename, soft-delete or hard-delete. Trash is recoverable.

  • jotura create <parent> <name> Auto-suffixes on collision; --template
  • jotura rename <old> <new> Refuses if destination exists
  • jotura trash <path> Soft-delete; recoverable
  • jotura trash list/restore/empty/purge Manage trashed items
  • jotura delete <path> Permanent delete; escape hatch

Frontmatter & tags

Edit commands never touch frontmatter. These do — safely.

  • frontmatter get <path> [key] Read full YAML or one key
  • frontmatter set <path> <key> <val> Set value; preserves order
  • frontmatter delete <path> <key> Remove a key
  • tag add <path> <tag> Append to tags array (idempotent)
  • tag remove <path> <tag> Remove from tags array
  • tag list <path> List tags

Templates

Reusable note skeletons stored alongside your notes.

  • templates list / show <name> Browse available templates
  • templates create <name> From stdin or --from <existing>
  • templates delete <name> Remove a template
  • create --template <name> Spin up from template
  • today --template <name> Today's daily note from template
  • Variables {{date}}, {{datetime}}, {{filename}}, {{title}}

Daily notes

First-class workflow for journals and daily logs.

  • jotura today Open or create today's note (Daily/YYYY/MM/...)
  • today --read Print today's body
  • today --append <TEXT> Append a line (or from stdin)
  • today --date YYYY-MM-DD Operate on a specific date
  • today --template daily Create from a template

Documents

Import PDFs / Word / Excel / etc. Each gets a searchable markdown mirror in .md_store/ — stored and synced like any note.

  • jotura import <file> Copy into the vault; --folder, --as
  • jotura search-documents <q> Search the mirrors; returns ORIGINAL paths
  • jotura ls-documents List documents + conversion status
  • jotura read .md_store/<path>.<ext>.md Read the converted text
  • jotura regenerate-md-store --stale-only Refresh stale conversions

Diagnostics & extras

Sync key management, health checks, change streaming, shell + agent integration.

  • jotura sync status / login / logout Sync key cache, shared with the desktop
  • jotura status / doctor Vault state; diagnostic checks
  • jotura vault info Note count, total size, sync state
  • jotura watch Stream change events as JSON lines
  • jotura completion <shell> bash/zsh/fish/elvish/powershell
  • jotura skill install <agent> Install agent skill file

Every mutating command (write, edit, create, rename, delete, trash, frontmatter, tag, batch) accepts --if-hash for safety, --dry-run for previews, --diff for unified-diff output, and --json for structured output. Errors arrive on stderr as JSON with distinct exit codes (HashConflict, NotFound, Ambiguous, OutOfRange) so an agent can react without parsing prose.

The skill file

What the agent reads.

One markdown file teaches any agent the canonical safe-edit workflow, the available commands, and the exit codes it should react to. Below is the file in full — it's what gets installed by the commands above.

---
name: jotura-vault
description: Use this skill to read, search, or edit notes in the user's Jotura markdown vault. Triggers on explicit mentions of "Jotura", "my vault", "my notes", "my journal", "daily note"; questions whose answer lives in personal notes ("what did I write about X", "find that meeting note", "add Y to today's note"); requests to create, rename, delete, tag, or modify any note. Vault files are plain .md files on disk and MAY be edited directly, but the `jotura` CLI is the preferred, safe interface — it provides hash-CAS conditional writes, strict unique-match replaces, frontmatter-preserving edits, and search/trash/sync-consistent operations that raw sed/awk edits cannot. Do NOT use this skill for editing files outside the user's vault.
---

# Jotura vault

The user keeps their notes in a Jotura markdown vault — a plain directory of `.md` files (plus imported documents). You access it through the `jotura` CLI binary (`~/.cargo/bin/jotura`), which uses the same Rust core as the Jotura desktop app and guarantees atomic writes, round-trip Markdown fidelity, and hash-based conflict detection. Cloud sync (when the user has enabled it) is end-to-end encrypted, but on disk everything is plaintext — there is nothing to unlock.

The user often runs the Jotura desktop app concurrently. The CLI and desktop coordinate automatically — the desktop's file watcher picks up any change you make within ~1 second, and the CLI's `--if-hash` checks protect you from clobbering the desktop's writes.

You MAY read vault files directly (`cat`, `grep`, editors), but prefer the CLI for every mutation: it enforces unique-match replaces, preserves frontmatter, writes atomically, and honors `--if-hash` concurrency checks.

## Before you do anything

Run `jotura status --json` first (or `jotura doctor --json` for a deeper health check). It returns one of:

```json
{"vault": "/path/to/vault", "noteCount": 247, "sync": {"enabled": true, "keyCached": true, "paused": false}}
{"error": {"code": "NoVault", ...}}
```

- A `vault` path → you can proceed. Data commands never need a password.
- `NoVault` → ask the user to set `JOTURA_VAULT=/path/to/vault` in their shell, pass `--vault` to commands, or open a vault in the desktop app first.
- The `sync` block is informational: `enabled: false` means the vault is local-only; `keyCached: false` on a sync-enabled vault means uploads are paused until the user provides their sync password (`jotura sync login`, or unlock in the desktop). Reads and writes always work regardless.

## The canonical safe-edit workflow

Every edit should follow this pattern. The `--if-hash` check stops you from clobbering work the user did in the desktop app, in another CLI invocation, or that arrived via sync.

```sh
# 1. Read with a hash
HASH=$(jotura read "<path>" --json | jq -r .hash)

# 2. Edit using the hash as a precondition
jotura edit "<path>" \
  --replace "<exact old text>" \
  --with "<new text>" \
  --if-hash "$HASH"
```

If exit code is 2 (`HashConflict`), the file changed under you. The error JSON now includes a `currentVsIntended` unified-diff between what's on disk *right now* and what you were about to write, so you can decide whether to retry, merge, or abandon without re-reading:

```json
{
  "code": "HashConflict",
  "path": "notes/today.md",
  "expectedHash": "...",
  "actualHash": "...",
  "currentVsIntended": "@@ -1,3 +1,3 @@\n line 1\n-current line\n+intended line\n line 3\n"
}
```

### Previewing edits with `--dry-run`

Every mutating command (`write`, `edit`, `create`, `rename`, `delete`, `frontmatter set/delete`, `tag add/remove`, `batch`) accepts `--dry-run`. The command computes exactly what it would write, prints a structured preview, and does NOT touch disk or manifest. Use this when you're not sure an edit will hit the right text:

```sh
jotura edit notes/today.md \
  --replace "TODO" --with "DONE" --dry-run --json
# → { "dryRun": true, "currentHash": "...", "newHash": "...",
#     "changes": 1, "preview": { "kind": "replace", "before": "TODO", "after": "DONE" } }
```

Exit codes still fire as expected — a dry-run that *would have* failed with `HashConflict` (code 2) or `Ambiguous` (code 4) returns the same error without writing anything.

### Seeing the diff with `--diff`

Every mutating command also accepts `--diff`. The JSON output gets an extra `"diff"` field containing a standard unified-diff between the current and new content (3 lines of context, `---`/`+++`/`@@` headers). Combine with `--dry-run` to preview without writing:

```sh
jotura edit notes/today.md --replace "TODO" --with "DONE" --diff --dry-run --json
# → { ..., "diff": "@@ -3,3 +3,3 @@\n line a\n-TODO\n+DONE\n line c\n" }
```

For `create`, `rename`, and `delete` the diff is structural (a `(file renamed)` / `(file created)` / `(file deleted)` marker block) rather than a real text diff — there's no old-vs-new body to compare.

## Reading

```sh
# Body only (most common)
jotura read inbox/today.md

# With line numbers — needed when planning a line-based edit
jotura read inbox/today.md --numbered

# Structured: { path, hash, frontmatter, body, lineCount }
jotura read inbox/today.md --json

# Include the YAML frontmatter block
jotura read inbox/today.md --with-frontmatter
```

## Finding notes

```sh
# Full-text search across all notes (uses Tantivy; rebuilds index per call)
jotura search "deadline next week" --json

# Restrict results to a path prefix
jotura search "deadline" --in projects/ --json

# Filename-only fuzzy match — much faster than search
jotura quick-open "meeting" --json

# List all notes
jotura ls --json

# List notes in a folder
jotura ls --folder projects --json
```

### Exact-match search with `grep`

`jotura grep <pattern>` runs a regex over every note body. It's the
right tool when you need exact, predictable matches — `search` is fuzzy /
ranked and `quick-open` only looks at filenames.

```sh
# basic regex match — output is path:line:matched-text per match
jotura grep 'TODO[:\s]'

# case-insensitive
jotura grep --ignore-case 'unicorn'

# whole-line match
jotura grep --line-regexp '^# Daily'

# whole-word match
jotura grep --word-regexp 'jot'

# restrict to a folder via include glob
jotura grep 'TODO' --include 'inbox/*'

# only paths that contain a match
jotura grep 'TODO' --files-with-matches

# count matches per file
jotura grep 'TODO' --count

# stop after N matches per file
jotura grep 'TODO' --max-count 3

# JSON output: [{ path, line, lineText, matchedText }]
jotura grep 'TODO' --json
```

Flags: `-i`/`--ignore-case`, `-x`/`--line-regexp`, `-w`/`--word-regexp`,
`-c`/`--count`, `--max-count N`, `--include GLOB`, `--exclude GLOB`,
`-l`/`--files-with-matches`, `-L`/`--files-without-match`.

Templates (`.jotura/templates/`) and trashed notes are excluded
automatically. Cost: every note matching `--include` is read and
scanned — sub-second for a few thousand notes, several seconds for 10k+.
For fuzzy/ranked text search use `search`; for filename lookup use
`quick-open`.

## Finding links between notes

```sh
# List notes that link TO this path (backlinks)
jotura backlinks inbox/today.md --json

# List links inside a note (outgoing)
jotura links projects/x.md --json

# Cap results
jotura backlinks inbox/today.md --limit 20
```

Two link syntaxes are recognized:

- **Wikilinks:** `[[target]]` and `[[target|display text]]`
- **Markdown links:** `[display](target.md)` (the target must end in `.md`)

`#anchor` suffixes (`[[foo#heading]]`, `[foo](foo.md#h)`) are stripped
before matching.

Target resolution rules, tried in order:

1. **Exact path match.** `[[inbox/today.md]]` and `[[inbox/today]]` both
   match the note at `inbox/today.md`.
2. **Filename match.** `[[today]]` matches every note whose filename is
   `today.md`, regardless of folder. If two notes share a filename in
   different folders, *both* show up as backlinks for either one.

Title-based resolution (matching frontmatter `title:`) is **not**
implemented in v1.

Implementation: both commands scan every note per invocation —
brute force, no persistent index. Sub-second for typical vaults; expect
seconds for 10k+ notes.

## Working with documents

The vault holds two kinds of files: **notes** (`.md`, edited directly) and
**documents** (PDFs, Word docs, spreadsheets, images, anything else). Documents
are stored as plain files just like notes, but their bytes aren't searchable
on their own. So each document gets a **searchable markdown mirror** in the
md_store at `.md_store/<original-path>.<ext>.md` — produced by a bundled
converter. The mirror is a normal note carrying the converted text; the
original document stays untouched.

The md_store is excluded from `ls`, `quick-open`, and plain `search`, so it
never clutters note-level results.

```sh
# Import a document. Defaults to attachments/, then generates the md_store mirror.
jotura import ~/Downloads/contract.pdf --json
# → { "path": "attachments/contract.pdf", "contentHash": "...", "size": 12345,
#     "mdStorePath": ".md_store/attachments/contract.pdf.md", "converted": true }

# Choose a folder, or an explicit full vault path:
jotura import ~/Downloads/contract.pdf --folder projects/acme --json
jotura import ~/Downloads/contract.pdf --as projects/acme/signed-contract.pdf --json

# Import bytes from stdin (--as is required):
cat report.pdf | jotura import - --as attachments/report.pdf --json

# Skip mirror generation (store only the document itself):
jotura import data.bin --no-convert --json

# List documents with their conversion status:
jotura ls-documents --json
# → [{ "path": "attachments/contract.pdf", "size": 12345,
#      "hasMdStore": true, "conversionOk": true }]

# Regenerate mirrors (after editing a document, or to retry a failed conversion):
jotura regenerate-md-store attachments/contract.pdf --json
jotura regenerate-md-store --all --json
jotura regenerate-md-store --stale-only --json   # only the ones that need it
```

If the converter isn't installed yet, `import` still succeeds: it writes a stub
mirror with `converted: false` and a `note` field saying conversion was
deferred. The mirror self-heals once a converter is present (`regenerate-md-store
--stale-only`).

Need the raw bytes of a document? It's a plain file — read it straight from
the vault directory (`<vault>/attachments/contract.pdf`).

## Searching documents

When the user asks to search their documents (PDFs, Word docs, spreadsheets,
etc.) — as opposed to their notes — use `jotura search-documents <query>`. It
searches the markdown representations in the md_store and returns the ORIGINAL
document paths. Do NOT use plain `jotura search` for documents; that searches
notes and excludes the md_store. Do NOT try to read PDF/docx bytes directly;
read the converted markdown via `jotura read .md_store/<path>.<ext>.md` or rely
on search-documents snippets.

```sh
jotura search-documents "quarterly revenue" --json
# → [{ "documentPath": "attachments/q3-report.pdf",
#      "mdStorePath": ".md_store/attachments/q3-report.pdf.md",
#      "title": "q3-report", "snippet": "...quarterly <mark>revenue</mark>...",
#      "score": 0.92 }]
jotura ls-documents --json
jotura read ".md_store/attachments/q3-report.pdf.md"   # read the converted text
```

`search-documents` returns the original `documentPath` (e.g.
`attachments/q3-report.pdf`), not the `.md_store/...` mirror path — so you can
hand it straight back to the user or read the file directly.

## Editing — string-based (preferred)

This is the safest primitive. Strict by default: fails if the old text isn't unique. Mirrors Claude Code's own `Edit` tool semantics.

```sh
# Single targeted change — fails with code 4 if "TODO: reply to alice" appears more than once
jotura edit notes/today.md \
  --replace "TODO: reply to alice" \
  --with "DONE: replied to alice" \
  --if-hash "$HASH"

# Replace every occurrence (use when you mean it)
jotura edit notes/today.md --replace "TODO" --with "DONE" --all

# Replace the Nth occurrence (1-indexed) — use when --replace returns code 4 (Ambiguous)
jotura edit notes/today.md --replace "TODO" --with "DONE" --nth 2
```

When `--replace` fails with exit code 4 (`Ambiguous`), stderr JSON includes `lineHits: [12, 47, 83]`. Use those numbers to pick the right `--nth`, or extend the old-text to include enough surrounding context that it becomes unique.

## Editing — line-based (when string matching doesn't fit)

Get the line numbers from `jotura read --numbered` first. Line numbers are 1-indexed and refer to the **body** of the note (frontmatter is stripped from the count).

```sh
jotura edit notes/today.md --replace-line 12 --with "new content for line 12"
jotura edit notes/today.md --replace-lines 12:15 --with "new\nmulti-line\ncontent"
jotura edit notes/today.md --insert-after 12 --content "new line inserted"
jotura edit notes/today.md --insert-before 1 --content "new first line of body"
jotura edit notes/today.md --delete-lines 12:15
jotura edit notes/today.md --append --content "appended at end"
jotura edit notes/today.md --prepend --content "prepended after frontmatter"
```

`--content` is also readable from stdin: `echo "stuff" | jotura edit ... --insert-after 5`.

## Editing — multi-edit in one call

`jotura edit ... --apply <FILE>` (or `-` for stdin) takes a JSON array of edit ops and applies them **sequentially to the evolving content**. One `--if-hash` precondition covers the whole batch; the final result is committed in a single atomic write.

```sh
cat > /tmp/edits.json <<'JSON'
[
  {"kind": "replace", "old": "TODO: x", "new": "DONE: x"},
  {"kind": "replace", "old": "TODO: y", "new": "DONE: y", "all": true},
  {"kind": "replace-line", "line": 12, "content": "new line 12"},
  {"kind": "insert-after", "line": 20, "content": "after 20"},
  {"kind": "delete-lines", "start": 25, "end": 27},
  {"kind": "append", "content": "end"}
]
JSON

jotura edit notes/today.md --apply /tmp/edits.json --if-hash "$HASH" --json
# or via stdin:
jotura edit notes/today.md --apply - --if-hash "$HASH" --json < /tmp/edits.json
```

Supported `kind` values: `replace`, `replace-line`, `replace-lines`, `insert-before`, `insert-after`, `delete-lines`, `append`, `prepend`. Field names match the matching CLI flag (`new` instead of `with`).

**Important — line numbers shift as ops apply.** Each op observes the state produced by every prior op. If op 0 deletes lines 1–3, then op 1's `line: 5` refers to what was originally line 8. Plan your edits with this in mind, or use string-based replaces which are stable across shifts.

**Atomic on failure.** If any op fails (`NotFound`, `Ambiguous`, `OutOfRange`), the whole batch aborts with **exit code 11** (`MultiEditOpFailed`) and nothing is written. The error names the op that failed:

```json
{
  "code": "MultiEditOpFailed",
  "opIndex": 2,
  "underlyingCode": "Ambiguous",
  "underlying": { "code": "Ambiguous", "data": { "needle": "...", "lineHits": [12, 47] } }
}
```

## Atomic multi-note changes — `jotura batch`

`jotura batch <FILE>` (or `-`) applies a JSON array of file-level operations across multiple notes. Use this when you want several notes updated together — e.g. "retag five notes and rename one folder" — and want all-or-nothing semantics on the most common failure (hash conflict).

```sh
cat > /tmp/ops.json <<'JSON'
[
  {"path": "inbox/a.md", "op": {"kind": "write",      "content": "...", "ifHash": "..."}},
  {"path": "inbox/b.md", "op": {"kind": "edit",       "apply": [{"kind":"replace","old":"x","new":"X"}], "ifHash": "..."}},
  {"path": "inbox/c.md", "op": {"kind": "create",     "parent": "inbox", "name": "c.md"}},
  {"path": "old/d.md",   "op": {"kind": "rename",     "to": "archive/d.md"}},
  {"path": "trash/e.md", "op": {"kind": "delete"}},
  {"path": "inbox/f.md", "op": {"kind": "frontmatter-set", "key": "status", "value": "done"}},
  {"path": "inbox/g.md", "op": {"kind": "tag-add",    "tag": "project-x"}}
]
JSON

jotura batch /tmp/ops.json --diff
```

Always emits JSON to stdout (the global `--json` flag is implied):

```json
{
  "opsApplied": 7,
  "results": [
    {"path": "inbox/a.md", "kind": "write", "newHash": "...", "diff": "..."},
    {"path": "inbox/b.md", "kind": "edit",  "newHash": "...", "diff": "..."},
    ...
  ]
}
```

### Atomicity guarantee

1. **Plan phase**: the CLI reads current content for every path, runs body-modifying ops against it in-memory, and surfaces any `NotFound`/`Ambiguous`/`OutOfRange` errors **before** writing anything. Exit code `11` (`MultiEditOpFailed`) on failure; disk untouched.
2. **Precondition phase**: every `ifHash` is verified against current on-disk state in one pass. Any mismatch produces a `HashConflict` error with **all** conflicting paths and their per-path `currentVsIntended` diffs. Exit code `2`. Disk untouched.
3. **Apply phase**: the batch executes through the existing per-op core API, each op committing in its own SQLite transaction. The plan phase eliminates the common failure modes, but if an unrelated error (filesystem, manifest corruption, AlreadyExists) hits mid-batch, prior ops in that same batch have already committed and will NOT be rolled back.

In practice this means: **safe for "hash conflict" and "op planning errors" (the common cases). Best-effort for unrelated errors mid-write.** Always pass `ifHash` where the user might be editing concurrently.

### Batch op kinds

| `kind` | Required fields | Optional |
|---|---|---|
| `write` | `content` | `full`, `ifHash` |
| `edit` | `apply` (array of edit ops) | `ifHash` |
| `create` | `parent`, `name` | — |
| `rename` | `to` | — |
| `delete` | — | — |
| `frontmatter-set` | `key`, `value` | `ifHash` |
| `frontmatter-delete` | `key` | `ifHash` |
| `tag-add` | `tag` | `ifHash` |
| `tag-remove` | `tag` | `ifHash` |

`--dry-run` and `--diff` work on `batch` just like on single commands.

## Frontmatter

The `edit` command **never touches frontmatter**. Use these dedicated operations instead. Frontmatter changes are isolated so YAML can't accidentally be mangled.

```sh
# Read
jotura frontmatter get notes/today.md          # full YAML block
jotura frontmatter get notes/today.md status   # one key

# Write
jotura frontmatter set notes/today.md status "in-progress"
jotura frontmatter delete notes/today.md draft

# Tag helpers (operate on the `tags:` array)
jotura tag add notes/today.md project-x
jotura tag remove notes/today.md old-tag
jotura tag list notes/today.md
```

The frontmatter editor handles top-level scalars and `tags:` arrays. Complex nested YAML (anchors, multi-line scalars, nested maps) is preserved verbatim but can't be edited from the CLI — for those, ask the user to edit in the desktop.

## Daily notes

```sh
# Print today's daily-note path (auto-creates if missing)
jotura today --json
# → { "path": "daily/2026/05/26.md", "hash": "...", "created": true }

# Print today's body
jotura today --read

# Append a line (also reads from stdin if --append has no value)
jotura today --append "took a 20-minute walk after lunch"

# Operate on a different date
jotura today --date 2026-05-20 --read

# Override the folder (default `daily`)
jotura today --folder Journal
```

Convention: `<folder>/YYYY/MM/YYYY-MM-DD.md`. The CLI's `today` is path computation + auto-create — once you know the path, `edit` and `read` work the same as for any other note.

## Reacting to vault changes

`jotura watch` streams change events as newline-delimited JSON. Useful when scripting against the vault while the desktop or another agent is also editing.

```sh
jotura watch
# {"kind":"Created","path":"inbox/new.md","ts":"2026-05-26T17:50:00Z"}
# {"kind":"Modified","path":"daily/2026/05/26.md","ts":"..."}

# Filter to a glob of paths
jotura watch --paths "daily/**"

# Filter event kinds (C,M,D,R)
jotura watch --kinds C,M

# Exit after the first matching event
jotura watch --once
```

Events are detected by polling the file tree about once per second; renames surface as a Deleted + Created pair.

## File operations

```sh
jotura create inbox "meeting-notes"   # creates inbox/meeting-notes.md (auto-suffixes (2) on collision)
jotura rename old/path.md new/path.md
jotura trash inbox/old.md             # SOFT-DELETE (recoverable, recommended)
jotura delete inbox/old.md            # PERMANENT delete — escape hatch only

# `jotura delete --trash <path>` is an alias for `jotura trash <path>`.
```

For agent use, prefer `jotura trash` — it's reversible. `jotura delete`
permanently removes the file with no recovery path.

## Soft-delete with `trash`

Soft-deletion moves the file into `<vault>/.jotura/trash/`, keeping its
relative path and a small metadata sidecar. The note becomes invisible to
`ls`, `read`, `search`, `quick-open`, and `grep`, but it can be restored at
any time. The desktop app uses the same trash directory.

```sh
# Soft-delete a note
jotura trash inbox/old.md

# List everything in the trash (sorted newest-first)
jotura trash list --json
# → [{ "originalPath": "...", "isFolder": false, "size": 123, "trashedAt": 1716... }]

# Restore at the original path (fails if a note now exists there)
jotura trash restore inbox/old.md

# Restore at a different path
jotura trash restore inbox/old.md --rename-to archive/old.md

# Permanently delete one trashed item
jotura trash purge inbox/old.md

# Permanently delete every trashed item (requires --force)
jotura trash empty --force
```

The original path is the key for `restore` and `purge` — pass the same
logical path the note had before it was trashed. `jotura trash list` shows
those keys.

Restoring into a path that's already occupied returns `InvalidArgs` (exit
code 9) with a message suggesting `--rename-to`. Folders can be trashed and
restored whole.

## Templates

Reusable note bodies live at `.jotura/templates/<name>.md` inside the
vault (plain files, but excluded from `ls`/`search`/`quick-open`/`grep`).

```sh
# List available templates
jotura templates list

# Print a template's body
jotura templates show meeting

# Create a template from stdin
echo "# {{title}}\n\nDate: {{date}}\n" | jotura templates create meeting

# Create a template from an existing note's body
jotura templates create from-current --from inbox/current.md

# Delete a template
jotura templates delete meeting
```

### Using a template

Pass `--template <name>` to `create` or `today` to initialize the new
note's body from a template. Existing notes are never overwritten — the
flag only affects newly-created notes.

```sh
jotura create inbox meeting-notes --template meeting
jotura today --template daily
```

### Template variables

Simple find-replace (no conditionals, loops, or partials). Both the
frontmatter block and the body have variables substituted.

| Variable | Substitution |
|---|---|
| `{{date}}` | today's date, `YYYY-MM-DD` (UTC) |
| `{{datetime}}` | current ISO 8601 timestamp |
| `{{filename}}` | the new note's filename without `.md` |
| `{{title}}` | the filename with `-`/`_` turned to spaces, title-cased |

`{{date}}` always reflects the **current** date, even when `today --date
<past>` is used — that flag controls the path, not the variable.

## Whole-body writes (rare — prefer `edit`)

Only use this when the user genuinely wants to replace a note's entire body. Reading-then-editing with `edit --replace` is almost always better because it preserves any content the user added that you might not have anticipated.

```sh
echo "new body content" | jotura write notes/today.md --if-hash "$HASH"

# --full replaces frontmatter too. Almost never the right move.
echo "---\nfoo: bar\n---\nbody" | jotura write notes/today.md --full --if-hash "$HASH"
```

## Exit codes — handle these by reading the JSON error on stderr

| Code | Name | Recovery |
|---|---|---|
| 0 | success | proceed |
| 2 | `HashConflict` | error includes `currentVsIntended` unified diff; inspect it to decide retry / merge / abandon. For `batch`, comes as a `conflicts: [...]` array of per-path entries |
| 3 | `NotFound` | the string you tried to replace isn't there, or the path is wrong — re-read and check |
| 4 | `Ambiguous` | parse `lineHits` from the JSON error; pick `--nth N` or extend `--replace` text for uniqueness |
| 5 | `OutOfRange` | line number exceeds `lineCount`; re-read with `--numbered` |
| 6 | `NoVault` | ask the user to set `JOTURA_VAULT` or open a vault in the desktop |
| 8 | `PermissionDenied` | surface the message to the user |
| 9 | `InvalidArgs` | bad flag combination; re-check `--help` |
| 11 | `MultiEditOpFailed` | one op in a `--apply` or `batch` failed; check `opIndex` + `underlyingCode` to identify which op and why; the whole batch was aborted with no writes |
| 12 | `SyncNotEnabled` | `sync login`/`sync logout` on a vault without sync configured; only relevant to sync key management |

Errors are emitted as a single-line JSON object on stderr, e.g.:
```
{"code":"Ambiguous","message":"...","needle":"TODO","occurrences":3,"lineHits":[5,12,47]}
```

## What the CLI deliberately does NOT do

Delegate these to the desktop app:

- Create a new vault (any folder works — but the desktop is the usual flow)
- Enable sync / set or rotate the sync password / restore from the server
  (the CLI only manages the local key cache: `sync status|login|logout`)
- Sync push / pull loops
- Daily-notes auto-creation flow (just `create` with a `YYYY-MM-DD.md` name if you need one)
- Task list views (the desktop has UI for this; CLI doesn't expose it)
- Recently-opened note list (UI feature)
- Move-to-OS-trash (the CLI's `trash` keeps soft-deleted notes inside the
  vault so they're available across machines)
- Opening files in external apps

## Common mistakes to avoid

1. **Prefer `jotura` over `sed`/`awk`/direct writes for mutations.** Vault files are plain `.md` and direct edits won't corrupt anything, but they skip the safety rails: no `--if-hash` protection against concurrent desktop/sync edits, no unique-match strictness, no frontmatter preservation guarantees, no atomic write.
2. **Never write into `<vault>/.jotura/`** (except templates via the `templates` commands). It holds the sync database, search index, and trash — the CLI and desktop manage it.
3. **Always use `--if-hash` on writes** of notes the user might be actively editing. Without it, you can silently clobber their desktop work.
4. **Always use `--json` for structured output** when you'll act on the result programmatically. Default output is human-readable.
5. **Don't use `--replace --all` defensively.** Strict mode catches "I thought there was one but there were five" mistakes. Use `--all` only when the user explicitly asked for "every occurrence."
6. **Don't whole-body-write when you mean to edit a section.** `jotura write` replaces the body wholesale. Use `jotura edit --replace` to surgically change one place.
7. **Don't run `jotura sync login` yourself.** It needs the user's sync password. If sync uploads are paused for a missing key, tell the user to run it (or unlock in the desktop) — your reads and edits work fine regardless.

## Shell completion

The CLI ships completion scripts for bash, zsh, fish, elvish, and powershell:

```sh
# zsh — install once
jotura completion zsh > "${fpath[1]}/_jotura"

# bash
jotura completion bash > /usr/local/etc/bash_completion.d/jotura

# fish
jotura completion fish > ~/.config/fish/completions/jotura.fish
```

After install, `jotura <TAB>` completes subcommands and flags. For path arguments, completion delegates to `jotura __complete-paths <prefix>`, which walks the vault's file tree. When no vault is selected the completer returns nothing silently (no error).

## Troubleshooting with `doctor`

`jotura doctor` runs a sequence of health checks — CLI on PATH, vault detected and readable, sync state, document conversion health, desktop app version, etc. Use this first when something feels off:

```sh
jotura doctor --json
```

Each check returns `{ name, status: pass|warn|fail|info, detail }`. Doctor never mutates state.

## Installing this skill in other agents

```sh
jotura skill install claude    # ~/.claude/skills/jotura-vault/SKILL.md
jotura skill install codex     # ~/.codex/AGENTS.md (frontmatter stripped)
jotura skill install gemini    # ~/.gemini/GEMINI.md (frontmatter stripped)
jotura skill install all       # all three
jotura skill list              # show which are installed and up-to-date
jotura skill show              # print canonical SKILL.md to stdout
```

If the target file exists with different content the command prompts before overwriting (or use `--force`).

## Quick reference (memorize this)

```
status / doctor   → vault + sync state / full health
sync status|login|logout → sync key cache management
ls --json         → list notes
search Q --json   → full-text search
search Q --in P   → full-text search scoped to a path prefix
quick-open Q      → filename search
import FILE [--folder F | --as P] [--no-convert]  → add a document + md_store mirror
search-documents Q → search documents (md_store); returns original paths
ls-documents [--folder F]  → list documents + conversion status
regenerate-md-store [P...] [--all] [--stale-only]  → rebuild document mirrors
read P --numbered → read with line numbers
read P --json     → read with hash for --if-hash
edit P --replace OLD --with NEW --if-hash H   → safe targeted edit
edit P --apply FILE --if-hash H               → multi-op edit, sequential, atomic
edit ... --dry-run / --diff                   → preview / unified diff
edit P --replace-line N --with C              → line-based edit
batch FILE [--dry-run] [--diff]               → atomic multi-file ops
frontmatter get/set/delete P [K] [V]          → YAML ops
tag add/remove/list P [T]                     → tags
create PARENT NAME [--template T]              → new note (optionally from template)
rename A B / delete P                          → rename / permanent delete
trash P                                        → soft-delete (recoverable)
trash list / restore P [--rename-to N]         → list / restore trashed
trash purge P / trash empty --force            → permanent removal
templates list/show/create/delete              → manage templates
today [--read] [--append T] [--date D] [--template T] → daily-note helper
backlinks P [--limit N]                        → notes linking TO P
links P [--limit N]                            → links inside P (outgoing)
grep PAT [-i -x -w -c -l -L]                   → regex search across bodies
                [--include G --exclude G]
                [--max-count N]
watch [--paths G] [--kinds CMDR] [--once]      → stream events
completion <shell>                             → emit shell completion
skill install <agent>                          → install this skill
```
Workflows

What the agent actually does.

Five real workflows your AI agent runs against the CLI. Each composes a handful of commands into a single user-level intent.

What did I write about Project X last week?

Check the vault, search across it, read the most relevant hit, hand a summary back to the user.

jotura status --json
jotura search "project x" --json | jq '.[:5]'
jotura read Daily/2026/05/2026-05-22.md

Summarize the PDF I dropped in the vault.

Documents become searchable markdown automatically. Search the mirrors, read the converted text — no PDF parsing on the agent side, markitdown already did it.

jotura search-documents "vendor agreement" --json
jotura read ".md_store/attachments/vendor-agreement.pdf.md"

Mark every TODO in today's note done.

Read with the hash, build a multi-op edit, apply atomically — five replacements with one hash check and one atomic write.

HASH=$(jotura read inbox/today.md --json | jq -r .hash)
echo '[
  {"kind":"replace","old":"TODO: reply to alice","new":"DONE: replied to alice"},
  {"kind":"replace","old":"TODO: file expenses","new":"DONE: filed expenses"},
  {"kind":"replace","old":"TODO","new":"DONE","all":true}
]' | jotura edit inbox/today.md --apply - --if-hash "$HASH" --diff

Find every note linking to a doc and add a tag.

Use backlinks to locate referrers, batch the tag-add across all of them in one atomic transaction.

jotura backlinks docs/policy.md --json \
  | jq -r '.hits[].path' \
  | jq -R -s 'split("\n") | map(select(length > 0))
              | map({path: ., op: {kind: "tag-add", tag: "policy-link"}})' \
  | jotura batch -

Spin up a meeting note from a template and watch for additions.

One command stamps the template variables; another streams changes back so the agent can react as the user types.

jotura create meetings 2026-05-27-product-sync --template meeting
jotura watch --paths "meetings/**" &
# Agent can now react to every save as the meeting progresses

Safely renaming with backlinks update.

Find every backlink first, plan a batch that renames the file and rewrites each referrer in lockstep, dry-run before committing.

jotura backlinks projects/old-name.md --json > /tmp/refs.json
# Agent builds a batch.json: rename + edit each referrer's [[old-name]] to [[new-name]]
jotura batch /tmp/batch.json --dry-run --diff   # preview
jotura batch /tmp/batch.json                     # commit

Your data. Your agent. Your terms.

Your notes are plain files on your machine. Your agent sees only what it asks for. And when you sync, the cloud sees only ciphertext — notes are encrypted on-device before upload.