Docs / The CLI / Editing notes
Editing notes
How to change note content from the Jotura CLI: the read-then-write hash loop, every edit operation, multi-op applies, batches, and conflict recovery.
A Jotura vault is an ordinary folder of Markdown files, so any text editor can change a note. The CLI exists because it adds four things a plain file write does not have:
- A compare-and-swap precondition that refuses to overwrite a change the desktop app or sync just made.
- Strict matching rules that refuse to guess which occurrence of a string you meant.
- Frontmatter that survives every body edit untouched.
- Machine-readable errors with stable exit codes.
This page covers the three commands that change note content. Use edit for structured changes to a single note, write for replacing content wholesale, and batch for many files in one call. Creating, renaming, moving, and deleting notes are covered in Creating, moving, and deleting. Frontmatter keys and tags have their own commands, described in Frontmatter and tags.
The safe-edit loop
Every write from the CLI should start with a read. The read --json output carries a hash field. Pass that hash back as --if-hash and the write becomes a compare-and-swap, so a file that changed since your read is left alone instead of clobbered.
HASH=$(jotura read notes/today.md --json | jq -r .hash)
jotura edit notes/today.md --replace "old" --with "new" --if-hash "$HASH"
jq is a separate third-party install and is not bundled with Jotura. An agent that parses the JSON itself does not need it, and the loop is the same either way.
Exit code 2 means the hash no longer matches, so read again, re-derive your change, and retry. Exit code 14 means the file was deleted or moved since your read, and Jotura deliberately does not recreate it. Both cases are described under Recovering from a hash conflict below.
One warning before you script that retry. The argument parser also exits 2 for its own usage errors, such as an unknown flag, and prints plain text rather than JSON. Check whether stderr parses as JSON before you treat exit 2 as a hash conflict, or a typo will loop forever.
edit performs an implicit compare-and-swap even when you omit --if-hash, because it reads the note and then writes with the hash it just read. Passing --if-hash widens that guard to cover the time you spent deciding what to change. That is the part that matters when an agent reads, thinks, and then writes.
write is different and has no implicit guard. Without --if-hash it overwrites the target unconditionally, with no hash check at all, which is why the flag matters more on write than on edit.
Hash-checked writes are atomic against every other Jotura writer. The CLI and the desktop app take a per-vault advisory lock at <vault>/.jotura/write.lock and hold it across the hash compare and the write. Two writers validating the same hash can never both succeed, and the loser exits 2.
edit: one structured change
edit applies a structured change to a note’s body. The YAML frontmatter block is split off before the change and prepended again verbatim afterward, so no edit operation can ever alter frontmatter. Use frontmatter set, frontmatter delete, tag add, and tag remove for that.
The full synopsis names the operation selectors, exactly as jotura edit --help prints it:
jotura edit [OPTIONS] <--replace <OLD>|--replace-line <N>|--replace-lines <N:M>|--insert-before <N>|--insert-after <N>|--delete-lines <N:M>|--append|--prepend|--apply <FILE>> <PATH>
The note must already exist, because edit reads before it writes. Exactly one operation selector is required. Supplying none or more than one is a usage error from the argument parser rather than a Jotura error, so you get human-readable text on stderr and exit code 2 rather than the JSON envelope.
| Flag | Type | Default | What it does |
|---|---|---|---|
<PATH> | string | required | The vault-relative note to edit. |
--replace <OLD> | string | none | Operation selector. Replace a literal substring. Requires --with. |
--with <NEW> | string | none | The replacement text. Required by --replace, --replace-line, and --replace-lines, and used by no other operation. |
--all | bool | false | Replace every occurrence. Only valid with --replace. |
--nth <NTH> | integer | none | Replace the Nth occurrence, counting from 1. Only valid with --replace. |
--replace-line <N> | integer | none | Operation selector. Replace line N, counting from 1. Requires --with. |
--replace-lines <N:M> | range | none | Operation selector. Replace the inclusive line range N to M. Requires --with. |
--insert-before <N> | integer | none | Operation selector. Insert content before line N. |
--insert-after <N> | integer | none | Operation selector. Insert content after line N. |
--delete-lines <N:M> | range | none | Operation selector. Delete the inclusive line range N to M. |
--append | bool | false | Operation selector. Append content to the end of the body. |
--prepend | bool | false | Operation selector. Prepend content to the start of the body. |
--apply <FILE> | path or - | none | Operation selector. Apply a JSON array of operations in sequence. - reads stdin. |
--content <CONTENT> | string | stdin | Content for the insert, append, and prepend operations. Omit it to read stdin to end of file. |
--if-hash <IF_HASH> | string | none | Refuse the write unless the note’s current hash matches. |
--dry-run | bool | false | Compute the change and print it without writing. |
--diff | bool | false | Include a unified diff in the output. Works with and without --dry-run. |
The --with requirement is not enforced by the argument parser. It is checked when the command is dispatched, so jotura edit x.md --replace "a" with no --with exits 9 (InvalidArgs) with the normal JSON error envelope and the message --replace requires --with. The same applies to --replace-line and --replace-lines.
--content is ignored by the replace family rather than rejected. A command such as jotura edit x.md --replace "a" --with "b" --content "c" succeeds and silently drops the --content value, so reach for --with whenever you are replacing text.
--content, --replace, and --with all accept values that begin with a hyphen, so you do not need a -- separator to pass text like --with "-1".
Every operation reports a changes count in its output. The counts mean different things per operation:
| Operation | Reported changes |
|---|---|
--replace with a single match | 1 |
--replace --nth N | 1 |
--replace --all | The number of occurrences replaced. |
--replace-line N | 1 |
--replace-lines N:M | The number of lines replaced. |
--insert-before N, --insert-after N | 1 |
--delete-lines N:M | The number of lines removed. |
--append, --prepend | 1 |
--apply | The sum of the per-operation counts. |
Replacing a substring
--replace matches a literal byte sequence, not a regular expression, and it is strict on purpose. Zero matches exits 3 (NotFound). More than one match exits 4 (Ambiguous) rather than silently picking one. An empty OLD string exits 9.
Both error envelopes carry needle, the string you searched for. The Ambiguous envelope adds occurrences and lineHits, the 1-indexed line number of every hit.
jotura edit notes/plan.md --replace "status: draft" --with "status: final"
You resolve ambiguity in one of three ways: lengthen the needle until it is unique, pass --all to replace every occurrence, or pass --nth N to pick one.
jotura edit notes/plan.md --replace "TODO" --with "DONE" --all
jotura edit notes/plan.md --replace "TODO" --with "DONE" --nth 2
--nth counts from 1. Passing 0, or a number larger than the occurrence count, exits 5 (OutOfRange). Read that error carefully, because the envelope reuses the line fields for a different concept: line holds the N you passed and lineCount holds the occurrence count, not a line count.
--nth also suppresses the ambiguity check, since you have already said which match you want. If you pass both --all and --nth, --all wins.
Replacing lines
--replace-line N replaces a single line, counting from 1, and --replace-lines N:M replaces an inclusive range. Both require --with.
jotura edit notes/plan.md --replace-line 3 --with "## Revised heading"
jotura edit notes/plan.md --replace-lines 10:14 --with "One line replaces five."
Line operations preserve the original line ending. If the line ended with a carriage return and newline, the replacement keeps that pairing. If the target was the final line and had no line ending at all, none is added. For a range, the line ending of line M is the one preserved, and the whole range collapses into a single replacement block.
A malformed range exits 9, because N:M needs the colon and needs numbers on both sides. A range where M is less than N, where N is 0, or where either end is past the end of the file, exits 5.
Use the line operations when the text you are changing is not textually unique. Pair them with jotura read <path> --numbered so the numbers you pass match what you just looked at.
jotura read notes/plan.md --numbered
Plain --numbered counts the body only, which is exactly the numbering the edit operations use. Adding --with-frontmatter changes that: the YAML block is then numbered too, and every body line is offset by the length of the block. Never derive edit line numbers from a --numbered --with-frontmatter read.
Inserting content
--insert-before N and --insert-after N place a block of content relative to line N, counting from 1. The content comes from --content or, if that is omitted, from stdin read to end of file.
jotura edit notes/plan.md --insert-after 1 --content "A new second line."
cat snippet.md | jotura edit notes/plan.md --insert-before 5
A non-empty block gets a trailing newline added if it lacks one, so inserted content never runs into the following line. An empty block is inserted as-is and no newline is added. --insert-after also forces a newline onto the end of line N if that line did not have one.
The bounds rules differ slightly between the two. --insert-before 0 exits 5, and an N past the end of the file exits 5 as well, except when the body is empty: inserting into an empty note works at any N. --insert-after exits 5 when the body is empty, when N is 0, or when N is past the end of the file.
Deleting lines
--delete-lines N:M removes an inclusive range and takes neither --with nor --content. It uses the same range parsing and bounds rules as --replace-lines.
jotura edit notes/plan.md --delete-lines 12:18
Appending and prepending
--append adds a block to the end of the body and --prepend adds one to the start. Both read content from --content or stdin.
jotura edit journal/2026-08-30.md --append --content "- Shipped the CLI docs."
--append guarantees a newline between the existing body and a non-empty new block, and a trailing newline after it. --prepend always terminates the block with a newline, even an empty one.
For appending to today’s daily note specifically, jotura today --append is the shorter route. See Daily notes, tasks, and templates for how the daily folder is resolved.
Editing a file that is not Markdown
A vault can hold text files that are not notes, such as .txt or .json. For any path whose name does not end in .md, there is no frontmatter split at all and the body is the entire file. Line numbers, --replace matches, appends, and prepends therefore all operate over the whole file, including anything that looks like a YAML block at the top.
What a successful edit prints
edit always emits JSON on success, whether or not you pass --json. The shape depends on which mode you used, and a client parsing the output has to branch on it.
| Mode | Output |
|---|---|
| Single operation | { "path", "hash", "changes", "diff"? } |
--apply | { "path", "currentHash", "newHash", "changes", "opsApplied", "diff"? } |
The key rename is real and easy to trip over. A single operation returns hash, and --apply returns currentHash plus newHash. The diff field appears only when you pass --diff.
Multiple operations in one write: edit --apply
--apply takes a JSON array of operations, applies them in sequence to one note, then performs a single atomic write at the end. Pass a file path, or - to read the array from stdin.
jotura edit notes/plan.md --apply ops.json --if-hash "$HASH"
Operations are applied to the evolving content, so operation N sees the result of operation N-1. Line numbers shift accordingly, which is the single most common source of surprise. If you delete lines 5 to 8 and then want to change what used to be line 12, refer to line 8.
Each operation is a JSON object with a kind field. There are eight kinds.
kind | Fields | Notes |
|---|---|---|
replace | old (string), new (string), all (bool, default false), nth (integer or null, default null) | Same strictness as the flag form: zero matches is NotFound, more than one without all or nth is Ambiguous. |
replace-line | line (integer), content (string) | Line numbers count from 1. |
replace-lines | start (integer), end (integer), content (string) | Inclusive range. |
insert-before | line (integer), content (string) | |
insert-after | line (integer), content (string) | |
delete-lines | start (integer), end (integer) | Inclusive range. |
append | content (string) | |
prepend | content (string) |
[ { "kind": "replace", "old": "TODO", "new": "DONE" },
{ "kind": "delete-lines", "start": 5, "end": 8 },
{ "kind": "append", "content": "trailing\n" } ]
Three names describe the same concept across this surface, and there is no way around learning all three. On the command line the replacement text is --with. In a replace operation it is new. In a replace-line or replace-lines operation it is content.
The same split applies to hashes, which are --if-hash on the command line and ifHash inside batch JSON.
--apply is genuinely all-or-nothing. If any operation fails, the command exits 11 (MultiEditOpFailed) and nothing is written. The error JSON carries opIndex, the 0-based position of the failing operation, plus underlyingCode and a nested underlying object holding the original error’s code, message, and data. Invalid JSON in the operation list exits 9.
opsApplied in the output is the number of operations in the array, and changes is the sum of the per-operation change counts.
write: replacing content wholesale
write replaces a note’s content outright. Use it when you are generating the whole body rather than transforming part of it. The path is created if it does not exist.
jotura write [OPTIONS] <PATH>
| Flag | Type | Default | What it does |
|---|---|---|---|
<PATH> | string | required | The vault-relative destination. Created if it does not exist. |
--full | bool | false | Replace the entire file including frontmatter. |
--content <CONTENT> | string | stdin | The new content. Omit it to read stdin to end of file. |
--if-hash <IF_HASH> | string | none | Refuse the write unless the current hash matches. |
--dry-run | bool | false | Compute the change and print it without writing. |
--diff | bool | false | Include a unified diff in the output. |
By default write replaces the body and preserves any existing frontmatter block verbatim. --full replaces everything, frontmatter included, which is what you want when the content you supply already contains its own YAML block.
jotura write notes/plan.md --content "# Plan
Rewritten from scratch."
pandoc -t markdown input.docx | jotura write imported/input.md --full
There is no bare positional form for content: it is --content or stdin, always.
For a path with no .md extension there is no frontmatter to split off, so both forms behave identically. Binary files are refused with exit 9, so use jotura import instead, described in Documents from the CLI.
On success write always emits JSON, with or without --json: { "path", "hash", "diff"? }.
Without --if-hash, write performs no hash check and overwrites whatever is there. With --if-hash and an existing target, a mismatch exits 2 and the error carries a currentVsIntended diff. With --if-hash and a target that does not exist, the CLI-layer check is skipped and the core write path rejects the write, so you get exit 14 (TargetMissing) rather than exit 2.
Previewing with --dry-run and --diff
--dry-run computes exactly what would be written and prints it without touching the disk. --diff adds a unified diff, and works both with --dry-run and on a real write, where it shows you what you just changed.
jotura edit notes/plan.md --replace "draft" --with "final" --dry-run --diff
edit and write emit the content envelope, which carries both hashes so you can chain the preview into a real write:
{ "dryRun": true, "path": "notes/a.md",
"currentHash": "1f3a...", "newHash": "9c02...", "changes": 1,
"preview": { "kind": "replace", "before": "draft", "after": "final" },
"diff": "--- a/notes/a.md\n+++ b/notes/a.md\n..." }
The preview object identifies the operation and varies by kind:
| Command and mode | preview.kind | Other preview fields |
|---|---|---|
edit --replace | replace | before (the needle), after (the replacement) |
edit --replace-line | replace-line | before, after, lineRange |
edit --replace-lines | replace-lines | before, after, lineRange |
edit --insert-before | insert-before | after, lineRange |
edit --insert-after | insert-after | after, lineRange |
edit --delete-lines | delete-lines | before, lineRange |
edit --append | append | after |
edit --prepend | prepend | after |
edit --apply | multi-edit | opsApplied |
write | write | none |
write --full | write-full | none |
For replace the before and after values are the needle and the replacement text themselves, not the surrounding lines. For --apply, opsApplied appears twice: once inside preview and once at the top level of the envelope. For a write dry run, changes is always the literal 1 and is not a count of anything.
In text mode the same information prints as DRY RUN: <path> followed by indented current hash, new hash, and changes lines, then each preview key, then the diff.
A dry run skips checks that a real run performs, which is the one thing that stops a preview being a promise. edit --dry-run, write --dry-run, and batch --dry-run all return before the share guard runs, and write --dry-run returns before the binary-file check.
So a dry run into a share mount you joined as a viewer previews cleanly, and the real run then exits 15. A write --dry-run at a binary path previews cleanly too, and the real run then exits 9.
create, rename, and delete use a different, smaller envelope with fixed pseudo-diffs, because there is no old-versus-new body to compare. That envelope is documented in Creating, moving, and deleting.
batch: many files in one call
batch applies a JSON list of file-level operations across the vault.
jotura batch [OPTIONS] <FILE>
| Flag | Type | Default | What it does |
|---|---|---|---|
<FILE> | path or - | required | The JSON file containing the batch, or - to read it from stdin. |
--dry-run | bool | false | Plan and preview, writing nothing. |
--diff | bool | false | Include a per-entry diff in each result. Content operations get a real unified diff, while create, rename, and delete get the fixed manifest pseudo-diff instead. |
The input is a JSON array of objects shaped { "path": "...", "op": { "kind": "...", ... } }. Invalid JSON exits 9, and a file that cannot be read exits 1.
jotura batch changes.json --dry-run --diff
cat changes.json | jotura batch -
There are nine operation kinds. Note the casing difference from the command line: inside batch JSON the precondition field is ifHash.
kind | Fields | Supports ifHash | Notes |
|---|---|---|---|
write | content (string), full (bool, default false), ifHash (optional) | Yes | Same frontmatter-preserving behavior as jotura write. |
edit | apply (array of edit operations), ifHash (optional) | Yes | Uses the eight edit operation kinds above. |
create | parent (string), name (string) | No | Same .md extension rule and (2) collision suffixing as jotura create. |
rename | to (string) | No | The source is the entry’s path. |
delete | none | No | Permanent, not a move to trash. |
frontmatter-set | key (string), value (string), ifHash (optional) | Yes | The value is parsed as JSON, falling back to a string. |
frontmatter-delete | key (string), ifHash (optional) | Yes | |
tag-add | tag (string), ifHash (optional) | Yes | |
tag-remove | tag (string), ifHash (optional) | Yes |
[ { "path": "notes/a.md", "op": { "kind": "tag-add", "tag": "reviewed" } },
{ "path": "notes/b.md",
"op": { "kind": "edit", "ifHash": "1f3a...",
"apply": [ { "kind": "replace", "old": "v1", "new": "v2" } ] } },
{ "path": "notes/old.md", "op": { "kind": "rename", "to": "archive/old" } } ]
Every content operation carries an implicit compare-and-swap against the hash read during planning, whether or not you supplied ifHash. Adding ifHash asserts a hash you chose yourself and moves the check earlier, into the batch-wide verify pass.
batch always prints pretty JSON and ignores --json entirely:
{ "opsApplied": 3,
"results": [ { "path": "notes/a.md", "kind": "tag-add",
"currentHash": "1f3a...", "newHash": "9c02...", "changes": 1 } ] }
Each result may carry path, kind, currentHash, newHash, changes, diff, and newPath. newPath appears for create and rename. The hash and changes fields are omitted for operations with no body content, and changes is always the literal 1 for the operations that do have one. A --dry-run prints the same shape from the planned results rather than from real writes.
What “atomic” actually means here
The command’s own summary calls the batch atomic. That is accurate about preconditions and misleading about the apply phase, so here is what the four phases really do.
- Plan. Jotura reads the current content for every body-modifying operation and computes the intended new content. Operation-internal failures such as
NotFound,Ambiguous, andOutOfRangesurface here as exit 11 withopIndex, before anything is written. - Verify. Every
ifHashprecondition is checked in a single pass against current state. Any mismatch aborts the whole batch with exit 2 and aconflictsarray. No disk changes have happened. - Guard. Every operation is routed through the share guards before any write. A write into a view-only share mount exits 15, and an operation that would break a mount’s structure exits 9. Either one aborts the batch as a whole.
- Apply. Each operation is an independent atomic file write.
The caveat lives in step 4. If an operation fails during the apply phase for an unexpected reason, the operations already committed in the same batch are not rolled back. Unexpected reasons include a file that vanished after the read-back and a create that collides. The three preflight phases make this uncommon, but they do not make it impossible.
So treat a failed batch as partially applied. Reconcile by reading the affected paths rather than assuming the vault is untouched.
Recovering from a hash conflict
Every CLI error is a single-line JSON object on stderr, with or without --json. A hash conflict on a single file looks like this:
{ "code": "HashConflict", "message": "hash conflict: expected abc, actual def",
"expectedHash": "abc", "actualHash": "def",
"expected": "abc", "actual": "def",
"path": "notes/x.md",
"currentVsIntended": "--- a/notes/x.md\n+++ b/notes/x.md\n..." }
currentVsIntended is a unified diff between what is on disk right now and what you were about to write. It is the fastest way to decide whether your change is still wanted. Often the other writer touched a different part of the note and you can replay your edit unchanged, and sometimes they already made your change.
The expected and actual fields duplicate expectedHash and actualHash and exist only for backwards compatibility, so read the Hash forms in new code.
The recovery loop is always the same. Read again to get the current hash, re-derive your change against the current content, and retry the write.
HASH=$(jotura read notes/x.md --json | jq -r .hash)
jotura edit notes/x.md --replace "old" --with "new" --if-hash "$HASH"
For batch, a precondition failure produces exit 2 with a conflicts array instead of a single set of fields. Each entry carries path, expectedHash, actualHash, and its own optional currentVsIntended diff, so one failed batch tells you every path that moved underneath you.
Exit 14 (TargetMissing) is a different situation and needs a different response. It means the file you read was deleted or moved before your write landed, and Jotura refuses to recreate it silently. The error carries path. Either read the vault to find out where the note went, or write without --if-hash if recreating the file is genuinely what you want.
An edit --if-hash that fails on the early check reports the conflict without a currentVsIntended diff, because the intended content has not been computed yet. A conflict detected later, inside the write path, carries the diff.
Exit codes you will see while editing
The full list lives in Exit codes and JSON output. These are the ones the editing commands produce.
| Code | Symbol | When it happens |
|---|---|---|
| 0 | Success. | |
| 1 | Error | Unclassified failure, such as an unreadable batch file. Read the message field. |
| 2 | HashConflict | An --if-hash or ifHash precondition failed, or a compare-and-swap write lost the race. |
| 3 | NotFound | The note does not exist, or a --replace needle matched zero times. |
| 4 | Ambiguous | A --replace needle matched more than once and neither --all nor --nth was given. |
| 5 | OutOfRange | A line number or range is out of bounds, or --nth N is 0 or beyond the occurrence count. |
| 6 | NoVault | No vault resolved. Pass --vault, set JOTURA_VAULT, or open a vault in the desktop app. |
| 8 | PermissionDenied | A filesystem permission error, or a path that resolves outside the vault. |
| 9 | InvalidArgs | A missing --with, a malformed range, invalid JSON, an empty --replace needle, or a binary target passed to write. |
| 11 | MultiEditOpFailed | One operation in an --apply sequence or a batch failed. Carries opIndex. |
| 14 | TargetMissing | The target was deleted or moved between your read and your write. |
| 15 | ReadOnlyShare | The target is inside a view-only share mount. |
The exit-2 collision described in the safe-edit loop applies to all of these. The argument parser uses exit code 2 for its own usage errors, such as a missing required argument, an unknown flag, or supplying zero or two operation selectors to edit. Those print human-readable text on stderr rather than the JSON envelope, so check whether stderr parses as JSON before concluding you hit a hash conflict.
Limits worth knowing before you script this
Every path argument is normalized and confined to the vault. A path containing . or .. components exits 9, an empty path exits 9, and anything under .jotura/ exits 9 as well. The templates subtree at .jotura/templates/ is the documented exception.
Symlinks and Windows junctions that resolve inside the vault are followed, and anything resolving outside exits 8 with “path resolves outside the vault”. The desktop app enforces the same rule through the same shared code.
Editing a note inside a share mount you joined as a viewer exits 15 (ReadOnlyShare), and the error carries path, shareId, and mountRel. There is no override flag, deliberately: a forced viewer write would never sync and the next remote change would overwrite it. Ask the share owner for editor access instead. Sharing is part of Jotura’s paid Sync plan, described on the pricing page and in Sync and shares.
The edit surface cannot modify nested YAML in frontmatter. Complex YAML is preserved as opaque text, but the frontmatter commands can only set and delete scalar keys.
There is no commit or flush step. A CLI edit lands on disk atomically and the desktop app notices it. If the note is open in a tab, the app does not prompt: it runs a silent three-way merge between your CLI change and any unsaved editor content, then announces “Merged changes from disk”.
Deleting a note that is open is the one case that does prompt. See The editor for both behaviors.
Related pages
- Reading and searching for
read --json, which produces the hash the safe-edit loop depends on. - Frontmatter and tags for the commands that change the YAML block that
editleaves alone. - Creating, moving, and deleting for
create,rename,delete, and the manifest dry-run envelope. - Exit codes and JSON output for the complete error contract.
- Watching and diagnostics for observing changes as they land.
- Install and setup for vault resolution and the global flags.
- The editor for how the same notes behave in the desktop app.