Reading and searching
Reference for the Jotura CLI commands that only read: read, ls, quick-open, search, grep, backlinks, links, and the two document commands.
Every command on this page reads the vault and changes none of its notes. The one thing any of them writes is the keyword search cache under .jotura/cli-search/, described under search. There is no unlock step, because a Jotura vault is an ordinary folder of Markdown files, so jotura read, jotura search, and the rest work the moment a vault is resolved. If you have not set one up yet, start with Install and setup.
Two commands here matter more than the others. jotura read --json is the source of the content hash that every safe edit depends on, so it is the first half of the loop described in Editing notes. jotura search is the only command with an optional prerequisite, because its semantic and smart modes read an index that the desktop app builds.
Flags that apply everywhere
Three of these flags, --vault, --json, and --verbose, are declared globally, so clap accepts them before or after the subcommand. jotura --json read note.md and jotura read note.md --json are the same command.
| Flag | Type | Default | What it does |
|---|---|---|---|
--vault <VAULT> |
path | none | Use this vault for this invocation. Resolution order is --vault, then $JOTURA_VAULT, then the desktop app’s last opened vault. |
--json |
boolean | false |
Emit structured JSON on stdout. Every command on this page respects it. |
-v, --verbose |
boolean | false |
Log progress to stderr. In practice this prints one line naming the resolved vault. |
-h, --help |
boolean | none | Print help for the command. |
-V, --version |
boolean | none | Print the CLI version. Top level only. |
Errors are always a single line of JSON on stderr whether or not you passed --json. Full details are in Exit codes and JSON output.
One caveat about -v: it is consumed by --verbose at the global level, which is why jotura grep has no GNU-style invert-match flag. See the grep section below.
Note that --json is universal on this page but not across the whole CLI. Roughly half the commands, all of them mutating ones, emit JSON whether or not you ask for it.
What the CLI can see
Every command on this page except read builds its candidate list from the same vault walk, and that walk applies two filters. Understanding them explains most cases of “my file is not in the results”.
The first filter is the dot prefix. Any name beginning with a dot is skipped at every level of the walk, so .jotura/, Obsidian’s .obsidian/ and .trash/, the legacy .md_store/ folder, and ordinary dotfiles never appear.
The second filter is the file extension. A file is only visible when its extension is on one of two lists, and the check is case-insensitive.
| List | Extensions |
|---|---|
| Text | md, markdown, mdc, txt, rst, org, tex, json, yaml, yml, toml, csv, tsv, xml, html, htm, css, scss, sass, js, mjs, ts, tsx, jsx, py, rb, rs, go, java, kt, swift, c, cpp, h, hpp, sh, bash, zsh, fish, ini, conf, env, sql, log, ps1, bat, canvas, base |
| Binary | pdf, docx, doc, xlsx, xls, pptx, ppt, odt, ods, odp, rtf, epub, png, jpg, jpeg, gif, webp, svg, bmp, ico, heic, heif, avif, tiff, tif, mp3, m4a, wav, ogg, flac, mp4, mov, webm, mkv, cbz, cbr |
Obsidian’s .canvas and .base files are JSON, so they sit on the text list and are read, indexed, and edited as plain text.
A file with no extension at all, such as README or Makefile, is on neither list and is therefore invisible. So is any extension the lists do not name, such as .zip. This is the single most surprising thing about the reading commands, and it applies to all of them:
| Command | Effect of the extension filter |
|---|---|
ls |
Folders are always listed. Files that fail the check are omitted with no warning. |
search |
An excluded file can never be a result, in any mode. |
quick-open |
An excluded file can never be a result. |
grep |
Narrower still: only the text list is scanned, so PDFs and Word files are never grepped. |
backlinks |
Excluded files are neither scanned for links nor available as resolution targets. |
links |
The source file is read directly and is exempt, but excluded files can never be resolution targets. |
ls-documents, search-documents |
An excluded file is not a document as far as these commands are concerned. |
read is the exception. It opens the path you name, so it will happily print a README that no other command can see. A file on the binary list, such as a PDF or an .mp4, is not printed: read describes it instead, as shown under read below.
How path arguments are handled
The commands here do not treat their path arguments identically. There are three levels of strictness, and mixing them up leads to expecting an error that never arrives.
| Argument | Handling |
|---|---|
read <PATH> |
Full validation: normalize, reject bad components, then confine to the vault. |
links <PATH> |
Normalize, then confine to the vault. No component rejection. |
backlinks <PATH> |
Normalize only. The path is never opened, so nothing is checked. |
ls --folder, ls-documents --folder, search --in |
Normalize, then use the value as a plain path-prefix filter. Nothing is rejected. |
Normalization is the shared part. Backslashes become forward slashes, so notes\today.md and notes/today.md are the same argument. Leading ./ is stripped, leading and trailing slashes are removed, repeated slashes collapse, and the result is put into Unicode NFC form, so a decomposed filename argument still matches a composed path on disk.
Normalization does not remove .. segments. Only read rejects them:
| Situation | Exit code | Message |
|---|---|---|
| Empty path after normalization | 9 | path is empty |
Path contains a . or .. component |
9 | path must not contain '.' or '..' components |
Path targets .jotura/, other than the templates subtree |
9 | path targets the vault data dir |
| Path resolves outside the vault, including through a symlink or a Windows junction | 8 | path resolves outside the vault |
| File does not exist | 3 | NotFound |
links applies only the last two rows: a source outside the vault exits 8 and a missing source exits 3, but a .. component or a .jotura/ target is not rejected as such.
For the prefix filters there is no error at all. A --folder or --in value that matches nothing, including a nonsense value like ../secrets, produces an empty result and exit code 0. There is no NotFound for a folder that does not exist. The same is true of backlinks, which returns no hits and exits 0 for a target that is not a real path.
The desktop app enforces read-level validation through the same shared code.
read
Print a note’s body to stdout, or return it as JSON along with its hash.
jotura read [OPTIONS] <PATH>
| Argument or flag | Type | Default | What it does |
|---|---|---|---|
<PATH> |
string | required | The vault-relative path to read, or a bare note name. |
--numbered |
boolean | false |
Prefix every line with N: , counting from 1. |
--with-frontmatter |
boolean | false |
Include the YAML frontmatter block in the output. |
A bare name with no slash or extension resolves like a wikilink to the unique note with that filename; several matches exit 4 with candidates, so prefer the path when you know it. Anything containing a slash, ending in an extension, or existing on disk is taken as a path and never as a name, so an existing Plan.md at the vault root always wins over a nested one. Matching is exact on the filename stem apart from case; there is no fuzzy matching, because a fuzzy hit on an edit path is a write to the wrong note. A resolved name adds resolvedFrom to the JSON payload. jotura edit accepts the same form.
The plain unnumbered form is byte-faithful. Jotura prints the content as it is and adds a trailing newline only when the file does not already end in one, so reading a note never introduces a phantom newline.
jotura read projects/roadmap.md
jotura read projects/roadmap.md --numbered
jotura read projects/roadmap.md --with-frontmatter --numbered
--numbered gives up that fidelity, because it splits the text into lines and prints each one. CRLF line endings come out as LF, and a file that did not end in a newline gains one. Use the plain form when you care about the exact bytes.
Passing both flags numbers the whole file including the frontmatter lines, because the numbering is applied to whatever text was selected for output.
Both flags are ignored in --json mode. JSON always separates the frontmatter into its own field and always returns unnumbered text, so there is nothing left for either flag to change.
jotura read projects/roadmap.md --json
{
"path": "projects/roadmap.md",
"hash": "9f2c...",
"frontmatter": "---\ntitle: Roadmap\n---\n",
"body": "# Roadmap\n...",
"lineCount": 42,
"share": { "shareId": "...", "name": "Team notes", "role": "editor" }
}
Four details in that payload are easy to get wrong.
hashis the value you pass back as--if-hashon a write, which is why you read in JSON mode before an edit.frontmatterisnullwhen the note has no YAML block, and it is alsonullwhenever the filename does not end in.mdor.markdown. The test is a case-insensitive suffix check, nothing more, so a.txtfile that starts with a YAML block is treated as all body. That same test is why the frontmatter and tag commands refuse to operate on other files. See Frontmatter and tags.lineCountcounts the lines of the body, not of the whole file. A note with frontmatter has more physical lines thanlineCountreports.shareappears only when the path routes into a mounted share, and it is the one field on this page that depends on a paid Sync subscription, priced on the pricing page.
How share mounts behave from the command line is covered in Sync and shares.
A path whose extension is on the binary list is never dumped to the terminal. Text mode prints one line naming the MIME type, the size, and the file’s absolute path, with a hint to open it in a tool that reads the format. JSON mode returns { "path", "absolutePath", "kind", "mime", "bytes" }, where kind is image or binary. There is no hash field, because binary files are not edited through the CLI.
ls
List the folders and visible files in the vault.
jotura ls [OPTIONS]
| Flag | Type | Default | What it does |
|---|---|---|---|
--folder <FOLDER> |
string | none, meaning the whole vault | Restrict the listing to a subfolder. |
The folder value is normalized and then used as a <folder>/ prefix filter, so --folder projects, --folder projects/, and --folder projects\ all behave the same. A value that normalizes to an empty string applies no filter at all.
jotura ls
jotura ls --folder projects
jotura ls --json
The text form prints one path per line. The JSON form is an array sorted by path:
[
{ "path": "projects", "isFolder": true, "size": 0, "mtime": 1724899200000 },
{ "path": "projects/roadmap.md", "isFolder": false, "size": 4210, "mtime": 1724899200000,
"shareId": "...", "shareRole": "editor" }
]
Folders are included as entries with size set to 0. mtime is milliseconds since the Unix epoch, and it falls back to 0 when the filesystem does not report a modification time. shareId and shareRole are omitted for paths outside a share mount, and they are only ever populated in JSON mode, because the plain-text listing has no column for them.
Both filters from “What the CLI can see” apply here. Folders are listed whatever they are called, as long as the name does not start with a dot, but a file is listed only when its extension is on one of the two lists. A vault containing alpha.md, notes/beta.md, backup.zip, and README lists three entries: alpha.md, notes, and notes/beta.md.
quick-open
Fuzzy match against filenames and frontmatter aliases. This is the CLI equivalent of the desktop app’s quick-open palette, described in Keyboard shortcuts.
jotura quick-open [OPTIONS] <QUERY>
| Argument or flag | Type | Default | What it does |
|---|---|---|---|
<QUERY> |
string | required | The fuzzy query, matched against filenames. |
--limit <LIMIT> |
integer | 20 |
Maximum number of results. A limit of 0 is treated as 1. |
jotura quick-open roadmap
jotura quick-open roadmap --limit 5 --json
Matching runs against the full filename including its extension and against every alias a note lists under aliases in its frontmatter, not the folder path and not the body, and it builds no index. Results are ordered by descending fuzzy score, then by lowercased filename, then by full path, so ties are stable between runs. The text form prints path, a tab, then the raw integer score, followed by a tab and the alias when the hit came through one. JSON is an array of { "path", "score", "alias" }, where alias holds the alias that matched, or null for a filename match.
An empty query is not an error. It matches every visible file with a score of 0, so the tiebreakers decide the whole ordering and you get an alphabetical listing by filename, truncated by the limit.
search
Full-text and meaning-based search across the vault.
jotura search [OPTIONS] <QUERY>
| Argument or flag | Type | Default | What it does |
|---|---|---|---|
<QUERY> |
string | required | The query text. An empty or whitespace-only query returns an empty result set rather than an error. |
--limit <LIMIT> |
integer | 20 |
Maximum number of results. Keyword mode passes the value through, treating 0 as 1. Semantic and smart modes clamp it to the range 1 to 100. |
--in <SCOPE> |
string | none | Restrict results to paths under this prefix, for example projects/. Keyword mode only. |
--mode <MODE> |
keyword, semantic, or smart |
keyword |
Which search strategy to use. |
--no-cache |
boolean | false |
Build a throwaway index for this one search instead of using the cached keyword index. Keyword mode only; with a semantic mode it exits 9. |
The three modes
| Mode | How it works | Prerequisite |
|---|---|---|
keyword |
Ranked full-text search, plus a fuzzy filename pass. | None. Works on any vault. |
semantic |
Embedding similarity. One result per note, and the best-matching chunk within that note wins. | The desktop app’s semantic index. |
smart |
Runs keyword and semantic together and fuses the two rankings by reciprocal rank. | The desktop app’s semantic index. |
jotura search "release checklist"
jotura search "release checklist" --limit 50 --in projects/
jotura search "how do we handle refunds" --mode smart --json
What keyword mode matches
Keyword search runs two passes and concatenates them.
The first pass fuzzy-matches the query against the filename stem, which is the filename with its last extension removed. So the query roadmap matches the file projects/roadmap.md through its stem roadmap. This is the one place where search and quick-open differ: quick-open matches the whole filename, extension included.
That pass is capped at the smaller of your --limit and 20. With --limit 5 the filename pass can return at most 5 hits; with --limit 50 it can return at most 20 and the body pass fills the other 30. Filename hits come back with an empty snippet.
Every visible file takes part in the filename pass, including binary documents. A PDF cannot be read as text, so it is never in the body index, but jotura search invoice can still return attachments/invoice-2026.pdf with an empty snippet, from plain keyword mode, with no document command involved.
The second pass queries the body index and fills the remaining slots, skipping any note the first pass already returned. It is case-insensitive and requires whole words. Multiple terms are combined with AND, so release checklist returns only notes containing both words. Wrapping the query in double quotes switches to a phrase query and skips the filename pass entirely, so jotura search '"release checklist"' matches the two words adjacent and in that order.
Keyword results in JSON are { "path", "title", "snippet", "score" }, where title is the filename stem. The text form is path, tab, score to three decimal places, tab, snippet.
Scoping with –in
--in filters candidates by path prefix, and a scoped search always builds a throwaway index rather than using the cache. Converted document text needs no special route: each document’s mirror is an ordinary sibling note such as report.pdf.md, so plain keyword search already finds it. search-documents, documented below, searches only those mirrors and names the original document.
Passing --in together with --mode semantic or --mode smart is rejected with exit code 9 and the message --in is only supported with --mode keyword.
Keyword performance
The CLI keeps its own keyword index at .jotura/cli-search/, separate from the desktop app’s index. Each unscoped search checks every file’s modification time and size, re-indexes only the files that changed, and then queries. The cache is created only in a vault that already has a .jotura/ folder, so an Obsidian vault opened read-only gets nothing written into it: there, and with --in or --no-cache, each search builds a throwaway in-memory index over the whole vault instead, and the cost is paid per call. Use jotura grep when you want a regex sweep, or one search with a larger --limit when you want more results.
Semantic and smart prerequisites
The CLI is query-only. It embeds your query, reads the vector index, and never writes to it. Building and refreshing that index is the desktop app’s job, which has two consequences worth planning around.
First, semantic search must be turned on in the desktop app before the CLI can use it. Open Settings, then Semantic search, then Enable, and pick a provider: OpenAI embeddings under your own API key, or a local model on the device. The app builds the index with that provider, and the index records which one. The CLI embeds your query with the same provider, so an index built with OpenAI needs an OpenAI API key on the machine running the CLI: OPENAI_API_KEY in the environment wins, otherwise the key the desktop app saved as openai.json in <config>/jotura/ (%LOCALAPPDATA%\jotura\ on Windows) is used. In a vault that syncs, the desktop app fills that file from the encrypted copy it keeps in the vault, so a second machine that has opened the vault in the app with its sync key cached already has the key; the CLI itself never reads the vault copy. With the local provider the feature runs entirely on your device; with OpenAI each query string is sent to OpenAI. Both are covered in Search and Settings reference.
Second, semantic results are only as fresh as the app’s last indexing pass. A note you just wrote with jotura write is immediately findable by keyword and grep, but it is not semantically searchable until the desktop app has indexed it.
Any missing prerequisite exits with code 13, and the message names the specific one:
| Missing prerequisite | What the message says |
|---|---|
<vault>/.jotura/vectors.db does not exist |
no semantic index for this vault (.jotura/vectors.db missing) |
| The index exists but the feature is switched off | semantic search is disabled for this vault |
| No config directory could be resolved for the model | no config directory for the embedding model |
| The embedding model files are absent | embedding model missing at <path> |
| The ONNX runtime cannot be found. macOS only, and only for a standalone CLI build | ONNX Runtime dylib not found followed by which lookups failed |
| The runtime is present but the engine fails to load | embedding engine failed to load: <reason> |
| The index uses OpenAI embeddings and no key is available | semantic index uses OpenAI embeddings; set OPENAI_API_KEY or add the key in the desktop app (Settings -> Semantic search) |
| The index uses OpenAI embeddings and OpenAI rejected the key | OpenAI rejected the API key; update it in the desktop app or OPENAI_API_KEY |
The first six messages contain the same hint pointing you at the desktop app’s setting, but two of them do not end with it: the missing-model message appends to download it, and the ONNX message appends an alternative involving ORT_DYLIB_PATH. The two key messages have their own wording and never mention the model directory, so a script that matches on a suffix will miss some of them; match on the exit code or on a substring. A network failure or rate limit while embedding the query against OpenAI falls outside this table and exits 1 with the response text, key redacted.
Semantic and smart results have a different shape from keyword results. JSON is { "path", "headingPath", "snippet", "score" }, with headingPath in place of keyword mode’s title. The text form is again path, tab, score to three decimal places, tab, snippet. A client that parses both modes has to branch on which field is present.
Three environment variables affect semantic search. OPENAI_API_KEY supplies the key for an OpenAI index and takes precedence over the key saved by the desktop app. JOTURA_MODEL_DIR overrides where the CLI looks for the local embedding model, and an empty value falls back to the default location. On macOS, ORT_DYLIB_PATH points a standalone CLI at a libonnxruntime.dylib. The CLI bundled inside the desktop app resolves that library itself and sets the variable for you. The model directory and the ONNX runtime only matter for local indexes.
grep
Regex search across the text content of the vault.
jotura grep [OPTIONS] <PATTERN>
| Argument or flag | Type | Default | What it does |
|---|---|---|---|
<PATTERN> |
string | required | A regular expression. |
-i, --ignore-case |
boolean | false |
Match case-insensitively. |
-x, --line-regexp |
boolean | false |
Require the pattern to match the entire line. |
-w, --word-regexp |
boolean | false |
Require the match to fall on word boundaries. |
-c, --count |
boolean | false |
Print one match count per file instead of the matching lines. |
--max-count <N> |
integer | none | Stop after N matching lines in each file. There is no short form. |
--include <GLOB> |
string | none | Only search paths matching this glob. |
--exclude <GLOB> |
string | none | Skip paths matching this glob. |
-l, --files-with-matches |
boolean | false |
Print only the paths that contained a match. |
-L, --files-without-match |
boolean | false |
Print only the paths that contained no match. |
jotura grep "TODO"
jotura grep "^## " --include "projects/*.md"
jotura grep "api[_-]key" -i -l
jotura grep "FIXME" --exclude "archive/*" --max-count 3 --json
Output modes and their precedence
Four output modes exist and exactly one wins. The precedence is -L first, then -l, then -c, then the default line listing. So -L -l -c behaves as plain -L, and combining flags never merges their output.
| Mode | Text output | JSON output |
|---|---|---|
| Default | path:line:line_text |
An array of hits with path, line, lineText, and matchedText |
-c |
path:count |
An array of { "path", "count" } |
-l |
One path per line | An array of path strings |
-L |
One path per line | An array of path strings |
-c and -l list only files with at least one match. -L lists only files with none.
What grep scans, and what it skips
Only files whose extension is on the text list from “What the CLI can see” are scanned. Binary documents such as PDFs and Word files are on the binary list, so grep never opens them. Use jotura search-documents for those. Extensionless files are on neither list and are skipped as well.
Dot-prefixed entries are skipped, so .jotura/ stays out of the results. A document’s converted-text sibling, such as report.pdf.md, is an ordinary note and is scanned like any other. A file that is not valid UTF-8 text is skipped silently rather than failing the whole scan, because a plaintext vault can be written to by any tool on the machine.
Results are emitted in sorted path order so that output is stable between runs. At most one hit is recorded per line, which means --max-count 3 caps a file at three matching lines, not at three total matches. The same cap applies to -c, so a count reported under --max-count 3 never exceeds 3.
Both globs are matched against the whole vault-relative path, as in --include "projects/*.md". An invalid regex and an invalid glob both exit with code 9 and a message naming which one failed.
There is no invert-match flag
GNU grep’s -v is taken by Jotura’s global --verbose, and no long --invert-match was added to compensate. -L, which lists the files containing no match at all, is the closest available behavior. There is no way to list the lines that do not match.
tasks
jotura tasks scans every markdown note in the vault for checklist lines and groups them the way the desktop Tasks pane does.
jotura tasks [OPTIONS]
| Flag | Type | Default | What it does |
|---|---|---|---|
--folder <FOLDER> |
string | the daily folder jotura today would pick |
The daily-notes folder used to date tasks written in daily notes. |
--window-days <N> |
integer | 30 |
Only include tasks from notes touched or dated within the last N days. |
--all |
flag | off | Ignore the window and list every task in the vault. Conflicts with --window-days. |
--open |
flag | off | Only unchecked tasks. Conflicts with --done. |
--done |
flag | off | Only completed tasks. |
--section <S> |
string | none | One group only: overdue, today, upcoming, no-date, or done. Anything else exits 9. |
A checklist line is - [ ], - [x], or the same with *, +, or an ordered-list marker. The extra task states the editor renders ([/], [-], [>], [?]) are not tasks here, the same rule the desktop Tasks pane follows. A task’s effective date is the @due(YYYY-MM-DD) marker in its text if there is one, otherwise the date of the daily note it lives in. The window keeps a task when its effective date is in the future, when its daily-note date falls inside the window, or when its file was modified inside it, which is what stops a note you have not touched in months from filling the list.
The text form is one tab-separated line per task: path, line number, [ ] or [x], the task text as written, then the effective date or an empty field.
{ "window": { "days": 30, "today": "2026-09-06" },
"sections": {
"overdue": [ { "path": "projects/acme.md", "line": 2, "checked": false,
"text": "late @due(2000-01-01)", "dueDate": "2000-01-01",
"dailyDate": null, "effectiveDate": "2000-01-01" } ],
"today": [], "upcoming": [], "noDate": [], "done": [] } }
Every section key is always present, empty when nothing matched. window is null under --all. Paths are vault-relative. The desktop side of the same data is described in Daily notes, tasks, and templates.
backlinks and links
These two commands are the reading half of the vault’s link graph. backlinks finds every note that points at a target, and links lists what a single note points at.
jotura backlinks [OPTIONS] <PATH>
jotura links [OPTIONS] <PATH>
| Argument or flag | Type | Default | What it does |
|---|---|---|---|
<PATH> |
string | required | For backlinks, the link target to find references to. For links, the source note to read. |
--limit <LIMIT> |
integer | none, meaning unlimited | Cap the number of results. |
jotura backlinks projects/roadmap.md
jotura backlinks projects/roadmap --limit 10 --json
jotura links projects/roadmap.md --json
Which link syntaxes count
Two forms are recognized. Wikilinks are [[target]], [[target|display text]], and the embed form ![[target]]. Markdown links are [display text](target), and they only count when the target carries an extension from either list in “What the CLI can see” after any anchor is stripped, and is not a URL. A link to https://example.com is therefore ignored, while a Markdown link pointing at a PDF counts. A percent-encoded Markdown target such as [text](My%20Note.md) is decoded before matching.
Anchors are stripped before matching, so [[roadmap#milestones]], [[roadmap#^abc123]], and [Q3](roadmap.md#milestones) all resolve to the note roadmap.md. A link that is only an anchor, such as [[#milestones]], is left with an empty target and is not reported by either command.
How targets resolve
Targets resolve the way Obsidian resolves them, through the same shared resolver the desktop app uses, so the two surfaces always agree on what a link points at.
- Matching is case-insensitive throughout.
[[roadmap]]findsRoadmap.md. - A target with a slash is matched as a path from the vault root. The
.mdextension is optional, soprojects/roadmapandprojects/roadmap.mdboth resolve to the note atprojects/roadmap.md. - A bare name matches the note with the shortest path that ends in that name, so
[[roadmap]]resolves toprojects/roadmap.mdwhen there is noroadmap.mdnearer the root. Notes sharing a filename at equal depth all match, which is whyresolvedis an array rather than a single value. - A name that matches no filename is checked against every note’s frontmatter
aliases, so[[The Plan]]resolves to a note whose frontmatter listsaliases: [The Plan].
Resolution by frontmatter title is still not implemented. A link written as the note’s title rather than its filename or one of its aliases will not resolve.
Embeds of non-markdown files, such as ![[diagram.png]], resolve by filename anywhere in the vault and through the Obsidian attachment folder named by attachmentFolderPath in .obsidian/app.json. They are reported by links but are never backlink sources.
What each command scans
The two commands differ in a way that explains why one is cheap and the other is not.
backlinks walks the whole vault to find candidate sources, and it scans only files whose path ends in a lowercase .md and that are no larger than 25 MB. That filter is case-sensitive, unlike the .md test used by read, so a note saved as NOTES.MD is never scanned for outgoing links by backlinks. It also records at most one hit per line, so a line containing [[roadmap]] twice produces a single hit. That matters when you set --limit, which counts hits.
links walks the vault only to build the set of resolution targets, then reads the single file you named. There is no extension test on that file at all, so jotura links notes/data.txt works and reports the links inside a plain text file.
Neither command keeps a persistent index. The result is always current at the cost of a walk per invocation. Dot-prefixed paths are excluded from that walk.
Output
backlinks returns a target field holding the normalized path you asked about, plus a hits array. Each hit carries path, line counting from 1, snippet holding the whole matching line, and linkKind, which is either wiki or markdown. The text form is path:line, a tab, then the snippet.
{ "target": "projects/roadmap.md",
"hits": [ { "path": "daily/2026-08-29.md", "line": 7,
"snippet": "see [[roadmap]] for context", "linkKind": "wiki" } ] }
links returns a source field plus a links array, where each entry carries line, snippet, target as written in the note, linkKind, and resolved, which is the array of vault paths the target resolves to. An empty resolved array means a dead link. A resolved entry also carries absolutePath, the absolute filesystem path of its first resolved target, so an agent can hand it straight to its own file tools. The text form is source:line, a tab, the target, a tab, then -> followed by the comma-separated resolved paths, or the literal (unresolved) when nothing matched.
{ "source": "projects/roadmap.md",
"links": [ { "line": 3, "snippet": "next: [[q3-plan]]", "target": "q3-plan",
"linkKind": "wiki", "resolved": ["projects/q3-plan.md"] } ] }
The two commands fail differently on a missing path. jotura links opens the source note, so a path that does not exist exits with code 3. jotura backlinks never opens its target, so asking for backlinks to a note that does not exist returns no hits and exits 0.
search-documents and ls-documents
Jotura stores imported documents such as PDFs and Word files as-is, and generates a searchable Markdown mirror for each one as a sibling note, so report.pdf gets report.pdf.md beside it. These two commands work from the document’s side of that pair. The desktop side is described in Documents, and importing is covered in Documents from the CLI.
jotura search-documents [OPTIONS] <QUERY>
jotura ls-documents [OPTIONS]
| Command | Argument or flag | Type | Default | What it does |
|---|---|---|---|---|
search-documents |
<QUERY> |
string | required | The query text. |
search-documents |
--limit <LIMIT> |
integer | 20 |
Maximum number of results. |
ls-documents |
--folder <FOLDER> |
string | none | Restrict the listing to a subfolder. |
jotura search-documents "invoice terms"
jotura search-documents "invoice terms" --limit 5 --json
jotura ls-documents --folder attachments --json
Both commands consider a file a document only when its extension is one of pdf, docx, pptx, xls, xlsx, epub, msg, rtf, odt, odp, or ods. The old binary Office formats doc and ppt are stored without a mirror, because the bundled converter cannot read them. Images, audio, video, and archives are stored without a mirror, because converting them would only scrape metadata. One inconsistency is worth knowing: msg is on the document list but not on the visible-extension list, so Outlook message files never reach either command.
search-documents builds a throwaway in-memory index over the Markdown mirrors on every invocation, so call it once with a useful --limit rather than in a loop. It then maps each hit back to the original document, so the results name the original file in your vault. JSON entries are { "documentPath", "mdStorePath", "title", "snippet", "score" }, where mdStorePath despite its name holds the sibling note’s path. The text form is documentPath, tab, score, tab, snippet. A hit whose mirror path cannot be mapped back to an original document is dropped from the results without a warning.
ls-documents lists eligible documents whether or not a mirror exists for them. The text form is path, tab, then the conversion status as true or false. JSON entries carry path, size, hasMdStore, and conversionOk. The two booleans differ: hasMdStore says the mirror file exists, while conversionOk says the conversion recorded in that mirror succeeded. A stub mirror written when the converter was unavailable has hasMdStore true and conversionOk false, and it heals on a later jotura regenerate-md-store --stale-only.
Exit codes you will see here
Reading commands use a narrow slice of the full table.
| Code | Symbol | When it happens on this page |
|---|---|---|
| 0 | success | Including a search or grep that matched nothing, backlinks against a nonexistent target, and a --folder or --in value that matches nothing. |
| 1 | Error |
Unclassified I/O or serialization failure. |
| 3 | NotFound |
read or links given a path that does not exist, or a bare name no note matches. |
| 4 | Ambiguous |
A bare name given to read that several notes match; the error carries candidates. |
| 6 | NoVault |
No vault resolved, or the resolved path is missing or is not a directory. |
| 8 | PermissionDenied |
A filesystem permission error, or a read or links path resolving outside the vault. |
| 9 | InvalidArgs |
An invalid regex or glob for grep, a path component rejected by read, or --in combined with a semantic mode. |
| 13 | SemanticUnavailable |
--mode semantic or --mode smart with a prerequisite missing, including no usable API key for an OpenAI index. |
One collision is worth knowing about. Clap’s own usage errors, such as a missing required argument or an unknown flag, exit with code 2, which is also Jotura’s HashConflict code. Those errors print human-readable text on stderr rather than the JSON envelope. Nothing on this page can produce a genuine hash conflict, so a script that treats exit 2 as a conflict should confirm that stderr parses as JSON before drawing that conclusion. The full table lives in Exit codes and JSON output.
Where to go next
Reading is half of the safe-edit loop. Editing notes covers the write side, including how the hash from read --json becomes --if-hash. Frontmatter and tags covers the YAML block that read splits out. Watching and diagnostics covers jotura watch and jotura doctor for when search results do not look right.