@recollectoften/cli
v0.8.0
Published
Two-way sync between a git repository of markdown decks and RecollectOften.
Maintainers
Readme
@recollectoften/cli
Author RecollectOften decks as markdown files in a git repository, and sync them two ways with your account.
Cards you write in the repo appear in the app. Cards you write in the app appear in the repo. When the same card changed on both sides, you get a three-way merge and, if that cannot resolve it, conflict markers in the file — the same thing git would give you.
npm install -g @recollectoften/cli
recollect login # paste your token; it is checked before it is stored
cd my-decks
recollect init
recollect status # what a sync would do; writes nothing
recollect sync # do itRequires Node 20 or later and a RecollectOften account.
recollect docs prints the complete file-format reference — every key of deck.yaml,
_folder.yaml and card frontmatter, the markdown dialect, every error slug, and the exit codes. It
needs no repository and no network, so it is also the right thing to hand a script or a coding agent
that has to author cards.
Getting a token
Sign in at recollectoften.com, then:
Profile → API Keys → Create API key
Tick Read decks & cards and Create & edit cards. The CLI never writes review scheduling and never calls AI, so it needs neither of the other two scopes. The token is shown once, at creation, along with the exact commands below.
Then store it:
recollect loginIt prompts with the input hidden, checks the token against the API, and only then writes it to a
file readable by you alone — %APPDATA%\recollect\token on Windows,
~/.config/recollect/token elsewhere. Checking first means a truncated paste or a revoked key
fails while you are still looking at it, rather than midway through a sync.
That is per user, not per repository, so it is done once no matter how many decks repos you keep.
For CI, or a one-off run against another account, pipe it in or use the environment instead:
recollect login --with-token < token.txt # or: echo "$TOKEN" | recollect login
RECOLLECT_TOKEN=rcol_live_… recollect syncrecollect auth shows which token is in use, where it came from, and whether it still works.
recollect logout removes the stored one.
There is deliberately no --token flag. A secret in a command line is visible in ps to
anyone else on the machine, is saved to your shell history, and is copied verbatim into CI logs.
Precedence is RECOLLECT_TOKEN, then the stored login, then .recollect/token. That last one is
legacy: 0.1.0 had no login, so repos that use it keep working, but recollect auth will tell you
to migrate. The token is never written into the repository by anything the CLI does now, and
.recollect/config.yaml — which is meant to be committed — holds nothing secret.
Commands
| Command | What it does |
|---|---|
| recollect docs | Prints the complete file-format reference. Needs no repository, no configuration and no network, so it works before login and before init. |
| recollect login | Stores an API token for your user, after checking it works. Reads stdin with --with-token, otherwise prompts with the input hidden. |
| recollect logout | Removes the stored token. |
| recollect auth | Shows which token is in use, where it came from, and whether the API still accepts it. |
| recollect init | Writes .recollect/config.yaml and an empty state.json, adds .recollect/token and .recollect/cache/ to .gitignore, and adds *.md text eol=lf to .gitattributes. Runs no git commands. |
| recollect status | Fetches the manifest, scans the repo, and prints the action table. Writes nowhere. This is sync --dry-run. |
| recollect pull | Applies only the database-to-repo half. Never writes to the API. |
| recollect sync | The full reconciliation: creates, pushes, pulls, merges. Deletes only with --allow-deletes. |
| recollect diff <path> | The markdown difference between one card file and the database. |
| recollect merge-state | Git's merge driver for .recollect/state.json. Git runs it; you do not. See below. |
Global flags:
| Flag | What it does |
|---|---|
| -C, --repo <dir> | Run as if started in <dir>. Otherwise commands walk up from the working directory looking for .recollect/, the way git does. |
| --no-cache | Ignore .recollect/cache/ and re-read every file. |
| --timing | Print per-phase timings to stderr. |
recollect status --json prints the same plan as JSON on stdout and nothing else — every action
including the skips the table hides, the files that would not parse, and the exit code, so a caller
does not have to re-derive it. "refused": true marks a delete waiting on --allow-deletes.
Merging state.json
Cards are one file each, so two people authoring different cards never touch the same card file.
They both touch .recollect/state.json: every entity has an entry in it, and editing a card
rewrites that card's line. Its keys are time-ordered ObjectIds, so concurrent entries land on
adjacent lines — the worst case for a line-based merge. Two people adding one card each is enough
to conflict, in a file neither of them typed a character of.
recollect init commits half the fix:
.recollect/state.json merge=recollect-stateand prints the other half, which has to be run once per clone:
git config merge.recollect-state.name "RecollectOften sync state"
git config merge.recollect-state.driver "recollect merge-state %O %A %B"Git deliberately will not let a repository name its own merge driver — a repo that could would be able to run any command on the machine of anyone who cloned it — so the second half cannot be committed for you.
The driver keeps what both branches agree on, keeps what only one of them added, and drops the
rest. Dropping is safe, which is what lets it run unattended: state.json is a cache of an
agreement, not a source of truth, so the next sync re-derives a dropped entry from the card file and
its committed base file. Nothing is lost in either direction, because no branch of that decision
writes without comparing first.
What actually gets sent
Nothing that has not changed. Every entity is compared by a content hash, three ways — the file in
the repo, the hash the database reports in the manifest, and the hash the two sides last agreed on
in state.json:
| repo vs agreed | database vs agreed | what happens |
|---|---|---|
| same | same | skip — no request is made at all |
| changed | same | push |
| same | changed | pull |
| changed | changed | three-way merge |
Images are compared by the SHA-256 of their bytes rather than by name, so a rename costs nothing and the same picture under two names uploads once. A sync of an unchanged repository sends exactly one request: the manifest.
Detecting that is what costs something, and .recollect/cache/ is what makes it cheap. It records
each file's size and modification time beside the hash the last run computed, so an unmoved file is
answered without being opened, parsed or hashed:
| cards | before 0.4.0 | first run | subsequent runs | |---:|---:|---:|---:| | 100 | 244 ms | 190 ms | 155 ms | | 1,000 | 642 ms | 357 ms | 190 ms | | 10,000 | 3,929 ms | 1,599 ms | 420 ms |
The cache is not committed — it holds this machine's timestamps, which mean nothing on anyone else's — and it is safe to delete at any moment, at the cost of one slow run. It invalidates itself when the conversion codec changes, so an upgrade never adopts hashes from the previous version.
It is also never trusted for a write: a card the run intends to send is re-read and re-hashed first.
In the rare case where a file changed without its size or timestamp moving, that check reports
stale-cache and drops the card from the run rather than acting on a hash the file no longer has.
Exit codes — these are the contract with whatever runs the CLI in CI:
0nothing went wrong1the run completed, but left something for a human: conflict markers written into a file, or a file that could not be read and was therefore skipped2the run could not complete
1 is deliberately not a crash. The run finished, every other entity was processed, and
state.json and the base store are written before the process exits with it.
It is equally deliberately not success. Do not treat 1 as 0. A file that will not parse is
skipped and its entity left alone — the right call for one bad file in a large repository — but
until 0.3.0 sync then printed done: everything is in sync. and exited 0, so a script could
not tell a complete run from one that had silently dropped a card. Now it says which files were
skipped and exits 1.
Every failure prints as path:line: message (slug). recollect docs lists every slug.
Repository layout
my-decks/
├── .recollect/
│ ├── config.yaml # committed; apiUrl only, nothing secret
│ ├── state.json # committed; what the last sync agreed on
│ ├── base/<cardId>.md # committed; merge base for each card
│ └── cache/ # NOT committed; derived, delete it any time
├── Spanish/ # a folder — any directory holding _folder.yaml
│ ├── _folder.yaml
│ └── Irregular Verbs/ # a deck — any directory holding deck.yaml
│ ├── deck.yaml
│ ├── ser-vs-estar.md
│ └── _assets/
│ └── conjugation-chart.png
└── Loose Deck/ # a deck may also sit at the repository root
├── deck.yaml
└── a-card.mdA directory with neither _folder.yaml nor deck.yaml is ignored, along with everything under
it, so the repository can hold whatever else you keep there. That also means a deck directory whose
deck.yaml you forgot is invisible rather than broken: nothing under it is scanned, so nothing is
reported about it. If cards are not appearing in status, check that every ancestor directory has
its yaml file. A .md file whose name starts with _ is not a card, so a deck can hold a
_notes.md.
Every file the CLI writes is UTF-8 with LF endings and a trailing newline.
deck.yaml and _folder.yaml
# deck.yaml — name is the only required key
id: 66b2f9c41a2b3c4d5e6f7a8b # omit it and the deck is created on the next sync
name: Irregular Verbs
description: ''
tags: []
newCardsPerDay: 20 # default 20
maxReviewsPerDay: 100 # default 100# _folder.yaml — name is the only required key
id: 66b2f9c41a2b3c4d5e6f7a8e # omit it and the folder is created on the next sync
name: SpanishAn unknown key in either is an error rather than a silent no-op, and the file is skipped —
editorFormat, isPublic and cardCount deliberately do not belong here. Neither file names its
parent: which folder a deck is in comes from the directory tree and nowhere else.
recollect docs has the types, ranges and defaults for all of it.
The card file
---
id: 66b2f9c41a2b3c4d5e6f7a8b
tags: [grammar, verbs]
---
When do you use **ser** vs **estar**?
<!-- answer -->
*Ser* for essence, *estar* for state.id is written back by the CLI when a new card is created — leave it out and the card is created
on the next sync. tags are normalised the way the server normalises them (trimmed, lowercased,
deduplicated, max 10), because otherwise a repo tag of Grammar would differ from the stored
grammar on every single sync.
The line <!-- answer -->, alone on its line, splits question from answer. Exactly one is
required. A copy inside a fenced code block does not count, so a card can be about the sentinel.
Markdown supports headings, bold, italic, code, links, lists, blockquotes, tables, fenced
code, mermaid diagrams and images. Anything the app can express but markdown cannot round-trips
through a ```prosemirror-json fence rather than being silently lost.
Structure, identity and moves
The directory tree is the structure; the ids are the identity. Which folder a folder is
in, which folder a deck is in and which deck a card belongs to are read from the tree and nowhere
else — neither deck.yaml nor a card's frontmatter repeats its parent.
Four consequences worth knowing before you rearrange anything:
- Renaming a directory is not a rename. The authoritative name is the
namefield in the yaml. The CLI never renames or relocates your directories on pull, including when a name changed in the app. - Moving a deck directory into another folder is a real move and syncs. It shows in
statusasmove. - Moving a card file between deck directories does not sync. It is reported as
unsupported-move!and nothing happens — not to the file, not to the database, not even to the card's content. The API has no operation that changes a card's deck, and the obvious workaround, deleting and recreating, destroys the review history. To really move a card: recreate it in the new deck and delete the original, accepting the loss of its history. - Two sibling folders may share a name. Identity is always the id, so that is legal and works.
Conflicts
Three-way merge needs the version both sides last agreed on, so every successful sync or pull of a
card also writes .recollect/base/<cardId>.md. It is committed, so a fresh clone can merge
without re-syncing first.
The merge runs over the card body. Tags are merged as a three-way set, which is what stops a tag you deleted in the app from coming back on the next sync.
If a card changed on both sides and the base file is missing, the CLI does not guess: it writes conflict markers around both full versions. A file still holding a conflict marker stops the whole run rather than pushing half-resolved text. Resolve it, then sync again.
Images
Three forms, all meaning the same thing:
is an accepted input — the natural thing to type after dropping a file into the deck's_assets/.[image:_assets/chart.png]is what the CLI writes whenever the deck has the bytes. It is the same document as the id form: references resolve before anything is hashed, so which one is on disk does not change the hash and does not make a card look edited.[image:<id>]is written when the deck does not have the bytes.
On pull the image is downloaded into the deck's _assets/ so you have the bytes beside the card,
and the card then names the file rather than an opaque id.
An image is uploaded only when a card refers to it. A file dropped into _assets/ and referred
to by nothing is reported as skipped and left alone — it is your file, and the CLI did not put it
there. In the other direction, an image in the database that no card uses is offered for deletion
under --allow-deletes, and appears in status as delete-db! first like every other delete.
Whether an image is in use is the server's answer rather than a guess from what this repository
holds, so a card written in the app and never pulled here still counts. Deleting one needs an API
key with the Manage images scope; without it the delete is reported and refused.
Safety
- Sync never writes scheduling. Review state, ease factor, interval, due date, lapses and review count belong to the study flow. This is enforced at the transport, so no repository edit can reach them.
- Nothing is deleted without
--allow-deletes, in either direction, and every delete appears instatusfirst, markeddelete-db!ordelete-file!. - A card delete prints its review count first. The delete is hard and the review history goes with it.
- An image display URL is never written anywhere — not to a file, not to
state.json, not to a log. Read URLs are short-lived signed links, so one in a log would be a live credential.
Releasing
@recollectoften/content is depended on as file:../packages/content, which is correct in the
workspace and fatal on npm — a published file: range points at a path nobody else has, so the
install fails at resolution. So the release is:
# 1. pin the sibling to the exact version that is on the registry
# "@recollectoften/content": "0.2.0"
npm publish # prepublishOnly refuses a file: range, so this cannot be skipped
# 2. put "file:../packages/content" backtools/check-publishable.mjs is what makes step 1 non-optional. Before it existed, forgetting the
swap did not fail the publish — it shipped a package that could not be installed at all, and the
first person to find out would have been a user.
Licence and support
Proprietary. You may install and run this against your own account; see
LICENSE.md for the terms, and THIRD-PARTY-NOTICES.md
for the open-source components it includes.
Bugs and feedback: https://recollectoften.com/?feedback=1
