npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@recollectoften/cli

v0.8.0

Published

Two-way sync between a git repository of markdown decks and RecollectOften.

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 it

Requires 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 login

It 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 sync

recollect 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-state

and 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:

  • 0 nothing went wrong
  • 1 the 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 skipped
  • 2 the 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.md

A 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: Spanish

An 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 name field 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 status as move.
  • 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:

  • ![alt](_assets/chart.png) 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 in status first, marked delete-db! or delete-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" back

tools/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