Jotura

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.

FlagTypeDefaultWhat it does
--vault <VAULT>pathnoneUse this vault for this invocation. Resolution order is --vault, then $JOTURA_VAULT, then the desktop app’s last opened vault.
--jsonbooleanfalseEmit structured JSON on stdout. Every command on this page respects it.
-v, --verbosebooleanfalseLog progress to stderr. In practice this prints one line naming the resolved vault.
-h, --helpbooleannonePrint help for the command.
-V, --versionbooleannonePrint 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.

ListExtensions
Textmd, 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
Binarypdf, 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:

CommandEffect of the extension filter
lsFolders are always listed. Files that fail the check are omitted with no warning.
searchAn excluded file can never be a result, in any mode.
quick-openAn excluded file can never be a result.
grepNarrower still: only the text list is scanned, so PDFs and Word files are never grepped.
backlinksExcluded files are neither scanned for links nor available as resolution targets.
linksThe source file is read directly and is exempt, but excluded files can never be resolution targets.
ls-documents, search-documentsAn 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.

ArgumentHandling
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 --inNormalize, 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:

SituationExit codeMessage
Empty path after normalization9path is empty
Path contains a . or .. component9path must not contain '.' or '..' components
Path targets .jotura/, other than the templates subtree9path targets the vault data dir
Path resolves outside the vault, including through a symlink or a Windows junction8path resolves outside the vault
File does not exist3NotFound

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 flagTypeDefaultWhat it does
<PATH>stringrequiredThe vault-relative path to read.
--numberedbooleanfalsePrefix every line with N: , counting from 1.
--with-frontmatterbooleanfalseInclude 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.

  • hash is the value you pass back as --if-hash on a write. That is the whole point of reading in JSON mode before an edit.
  • frontmatter is null when the note has no YAML block, and it is also null whenever the filename does not end in .md. The test is a case-insensitive .md suffix, nothing more, so a .markdown file 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.
  • lineCount counts the lines of the body, not of the whole file. A note with frontmatter has more physical lines than lineCount reports.
  • share appears 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]
FlagTypeDefaultWhat it does
--folder <FOLDER>stringnone, meaning the whole vaultRestrict 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 flagTypeDefaultWhat it does
<QUERY>stringrequiredThe fuzzy query, matched against filenames.
--limit <LIMIT>integer20Maximum 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.

Full-text and meaning-based search across the vault.

jotura search [OPTIONS] <QUERY>
Argument or flagTypeDefaultWhat it does
<QUERY>stringrequiredThe query text. An empty or whitespace-only query returns an empty result set rather than an error.
--limit <LIMIT>integer20Maximum 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>stringnoneRestrict results to paths under this prefix, for example projects/. Keyword mode only.
--mode <MODE>keyword, semantic, or smartkeywordWhich search strategy to use.

The three modes

ModeHow it worksPrerequisite
keywordRanked full-text search, plus a fuzzy filename pass.None. Works on any vault.
semanticEmbedding similarity. One result per note, and the best-matching chunk within that note wins.The desktop app’s semantic index.
smartRuns 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 prerequisiteWhat the message says
<vault>/.jotura/vectors.db does not existno semantic index for this vault (.jotura/vectors.db missing)
The index exists but the feature is switched offsemantic search is disabled for this vault
No config directory could be resolved for the modelno config directory for the embedding model
The embedding model files are absentembedding model missing at <path>
The ONNX runtime cannot be found. macOS only, and only for a standalone CLI buildONNX Runtime dylib not found followed by which lookups failed
The runtime is present but the engine fails to loadembedding 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 flagTypeDefaultWhat it does
<PATTERN>stringrequiredA regular expression.
-i, --ignore-casebooleanfalseMatch case-insensitively.
-x, --line-regexpbooleanfalseRequire the pattern to match the entire line.
-w, --word-regexpbooleanfalseRequire the match to fall on word boundaries.
-c, --countbooleanfalsePrint one match count per file instead of the matching lines.
--max-count <N>integernoneStop after N matching lines in each file. There is no short form.
--include <GLOB>stringnoneOnly search paths matching this glob.
--exclude <GLOB>stringnoneSkip paths matching this glob.
-l, --files-with-matchesbooleanfalsePrint only the paths that contained a match.
-L, --files-without-matchbooleanfalsePrint 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.

ModeText outputJSON output
Defaultpath:line:line_textAn array of hits with path, line, lineText, and matchedText
-cpath:countAn array of { "path", "count" }
-lOne path per lineAn array of path strings
-LOne path per lineAn 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.

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 flagTypeDefaultWhat it does
<PATH>stringrequiredFor backlinks, the link target to find references to. For links, the source note to read.
--limit <LIMIT>integernone, meaning unlimitedCap the number of results.
jotura backlinks projects/roadmap.md
jotura backlinks projects/roadmap --limit 10 --json
jotura links projects/roadmap.md --json

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.

  1. Exact path match after normalization. The .md extension is optional, so projects/roadmap and projects/roadmap.md both resolve to the note at projects/roadmap.md.
  2. Filename match, but only when the link target contains no slash. A bare [[roadmap]] resolves to every note named roadmap.md, in every folder. That is why resolved is 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]
CommandArgument or flagTypeDefaultWhat it does
search-documents<QUERY>stringrequiredThe query text.
search-documents--limit <LIMIT>integer20Maximum number of results.
ls-documents--folder <FOLDER>stringnoneRestrict 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.

CodeSymbolWhen it happens on this page
0successIncluding a search or grep that matched nothing, backlinks against a nonexistent target, and a --folder or --in value that matches nothing.
1ErrorUnclassified I/O or serialization failure.
3NotFoundread or links given a path that does not exist.
6NoVaultNo vault resolved, or the resolved path is missing or is not a directory.
8PermissionDeniedA filesystem permission error, or a read or links path resolving outside the vault.
9InvalidArgsAn invalid regex or glob for grep, a path component rejected by read, or --in combined with a semantic mode.
13SemanticUnavailable--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.