Docs / The CLI / Reading and searching
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 writes nothing to it. 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
These five flags 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/, the .md_store/ document mirrors, 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 |
| 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 |
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 .mp4 or .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 or a .mp4 that no other command can see.
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. |
--numbered | boolean | false | Prefix every line with N: , counting from 1. |
--with-frontmatter | boolean | false | Include the YAML frontmatter block in the output. |
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. That is the whole point of reading in JSON mode before an edit.frontmatterisnullwhen the note has no YAML block, and it is alsonullwhenever the filename does not end in.md. The test is a case-insensitive.mdsuffix, nothing more, so a.markdownfile is treated as all body even though it is Markdown by any normal reading. That same test is why the frontmatter and tag commands refuse to operate on it. 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.
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, clip.mp4, and README lists three entries: alpha.md, notes, and notes/beta.md.
quick-open
Fuzzy match against filenames only. 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, 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. JSON is an array of { "path": ..., "score": ... }.
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. |
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 actually 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 before the index is built. It also adds the .md_store/ document mirrors to the candidate set, which is otherwise impossible because of the dot-prefix rule. That makes jotura search "invoice terms" --in .md_store/ a manual way to search converted document text. It is an escape hatch, not the mechanism behind search-documents, which builds its own index and is documented below.
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 builds an in-memory full-text index over the whole vault on every single search invocation. That is fast enough for vaults of a few thousand notes, but it means the cost is paid per call. Do not put jotura search inside a loop. 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. That downloads the embedding model and builds the index. The feature runs entirely on your device and is 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> |
Every one of those messages contains 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. Match on the code or on a substring, not on a suffix.
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.
Two environment variables affect semantic search. JOTURA_MODEL_DIR overrides where the CLI looks for the 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.
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/ and the .md_store/ mirrors stay out of the results. 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.
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]] and [[target|display text]]. Markdown links are [display text](target), and they only count when the target carries a recognized text extension after any anchor is stripped. A link to https://example.com is therefore ignored, and so is a Markdown link pointing at a PDF.
Anchors are stripped before matching, so [[roadmap#milestones]] and [Q3](roadmap.md#milestones) both resolve to the note roadmap.md.
How targets resolve
Resolution is tried in two steps, in order.
- Exact path match after normalization. The
.mdextension is optional, soprojects/roadmapandprojects/roadmap.mdboth resolve to the note atprojects/roadmap.md. - Filename match, but only when the link target contains no slash. A bare
[[roadmap]]resolves to every note namedroadmap.md, in every folder. That is whyresolvedis an array rather than a single value.
Resolution by frontmatter title is not implemented. A link written as the note’s title rather than its filename will not resolve.
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. 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, so links written inside .md_store/ mirrors are never counted as backlinks.
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. 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 simply 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 under .md_store/. These two commands are how you reach that mirror layer from the CLI. 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, doc, docx, ppt, pptx, xls, xlsx, epub, msg, rtf, odt, odp, or ods. 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 its own in-memory index over the Markdown mirrors on every invocation, exactly as search does over the notes, so the same advice applies: 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 file you actually have. JSON entries are { "documentPath", "mdStorePath", "title", "snippet", "score" }. 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 actually 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. |
| 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. |
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.