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

@msareen/tinker-note-cli

v0.5.0

Published

Headless, LLM-friendly CLI and programmatic API for a tinker-note vault. Authenticates with a per-user Ed25519 API key and reads/writes notes over the REST API.

Readme

tinkernote

Headless, LLM-friendly command-line access to a single tinker-note vault.

tinkernote authenticates with an Ed25519 API key (generated in the web app) instead of a browser session, so scripts and agents can read and write your notes over the REST API. Every request is scoped to exactly the key owner's vault.

How auth works

The server stores only your Ed25519 public key (in .api-keys.json, referenced by id from .users.json). The private key lives only in your CLI env file. On each invocation the CLI mints a short-lived (120-second) EdDSA JWT assertion signed with the private key and sends it as Authorization: Bearer <jwt>. The server verifies the signature against the stored public key and checks the key's expiry/revocation. Nothing secret is ever stored server-side, and assertions can't be replayed beyond ~2 minutes.

Install

tinkernote is a standalone, dependency-free package published as @msareen/tinker-note-cli (Node ≥ 18, or Bun). Install it globally to get the tinkernote command anywhere:

npm install -g @msareen/tinker-note-cli   # provides the `tinkernote` binary
tinkernote help

Or run it without installing — both npx (Node) and bunx (Bun) work identically:

npx @msareen/tinker-note-cli help
bunx @msareen/tinker-note-cli help

Inside this repo you can also run the source directly with bun run cli <command> — it's the same program. All examples below use tinkernote; substitute bun run cli when working from the repo.

Building from source (the cli/ folder is its own package):

cd cli
npm install
npm run build      # → dist/tinker-note-cli.js (the published bin)

Setup

  1. Generate a key in the web app: Settings → Manage API Keys → Generate key (pick a label and an expiry). The dialog shows a single tnk_… string once — copy it immediately; it is never shown again. That one string encodes everything the CLI needs: the server URL, your account email, the key id, and the Ed25519 signing key.

  2. Save it. The quickest way is the interactive setup, which prompts for the key and writes it (mode 600) for you — by default to ~/.tinker-cli/config.env (%USERPROFILE%\.tinker-cli\config.env on Windows), so it's available from any directory:

    bun run cli auth          # aliases: login, init

    Re-running it shows the saved key masked (press Enter to keep it), and --file <path> writes to a different location instead of the default.

    Or do it by hand — one line in ~/.tinker-cli/config.env (or copy .tinker-cli.env.example there and fill it in):

    TINKER_TOKEN="tnk_..."

    A config file is optional. The CLI reads process.env first and only falls back to files, so TINKER_TOKEN can equally live in your shell environment (export TINKER_TOKEN=...) or in any file Bun auto-loads — .env / .env.local in the project root — when you run bun run cli. Lookup order is: process.env → ~/.tinker-cli/config.env → a legacy ./.tinker-cli.env in the current directory (kept as a fallback for setups from before the per-user default existed; still handy for scoping different creds to a specific project directory).

    Before the packed token, credentials were four separate values. They still work, and are read whenever TINKER_TOKEN is unset:

    TINKER_API_URL=http://localhost:3000
    [email protected]
    TINKER_KEY_ID=00000000-0000-0000-0000-000000000000
    TINKER_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"

    TINKER_PRIVATE_KEY accepts either inline PEM (quoted, one line, \n-escaped — a raw multi-line PEM won't parse) or a filesystem path to a .pem file, e.g. TINKER_PRIVATE_KEY=./tinker-key.pem.

  3. Verify:

    bun run cli whoami

Every request carries a 30-second timeout, so an unresponsive server fails with a clear error instead of hanging a script forever. Library users can override it with timeoutMs on the Config.

~/.tinker-cli/ lives outside any repo, so it's never at risk of being committed. The legacy ./.tinker-cli.env and *.pem are gitignored for the same reason. Treat the API key like a password either way — it contains the signing key in full.

Usage

bun run cli <command> [args] [--json]

| Command | Description | | --- | --- | | auth  (aliases login, init) | Interactively create ~/.tinker-cli/config.env (--file <path> for another location) | | ls [path] | List the vault tree (optionally filtered under <path>) | | tree | List the full vault tree | | cat <path>  (alias read) | Print a note's contents | | write <path> [opts] | Create or update a note (see options below) | | rm <path> | Delete a note (moves it to the recycle bin) | | mv <old> <new> | Rename / move a note or folder | | mkdir <path> | Create a folder | | rmdir <path> | Delete an empty folder | | search <query> | Full-text + filename search | | sync <local-dir> [remote-dir] | Two-way sync with a local directory (--dry-run to preview, --yes to skip the prompt, --prefer local\|server to settle conflicts); also -l/--local, -r/--remote | | whoami | Verify auth; prints the account plus the CLI and server versions | | help  (aliases -h, --help) | Show usage |

write content sources (pick one):

  • --content "<text>" — inline string
  • --from-file <file> — read content from a local file
  • piped stdin — echo "..." | bun run cli write <path>

By default write creates the note (POST); if it already exists it transparently falls back to an update (PUT). Pass --update to force an update.

write refuses to write empty content read from stdin, because a closed stdin (cron, systemd, CI, < /dev/null) is indistinguishable from an empty pipe and would silently truncate the note. To blank a note deliberately, pass --content "".

sync <local-dir> [remote-dir] — two-way sync between the vault and a local folder of .md files.

A note that exists on only one side is copied to the other. For a note on both sides, the question is which side moved, and the answer comes from lastSyncAt in .sync-track — the moment the last sync finished. A sync stamps both copies with the same mtime, so anything later than that has been touched since:

| local mtime | server mtime | result | |---|---|---| | ≤ lastSyncAt | ≤ lastSyncAt | unchanged | | > lastSyncAt | ≤ lastSyncAt | upload — you edited it locally | | ≤ lastSyncAt | > lastSyncAt | download — it was edited in the app | | > lastSyncAt | > lastSyncAt | conflict — both sides moved |

Only that last row is ambiguous, and even then the timestamps aren't trusted on their own: mtime moves without content moving (cp -r, git checkout, a restored backup, a no-op save). So a suspected conflict is settled on the bytes. If they match, only the local timestamp is corrected — nothing is written on either side. If they differ, it is a real conflict and nothing is applied to it.

Sizes are compared first as a free filter (different sizes cannot be identical content), so the remote file is fetched for comparison only when the sizes match.

Equal mtimes normally mean a previous sync stamped both sides, but mtime has one-second granularity, so two edits landing in the same second also compare equal. When that happens and both sides moved since the last sync, the content is checked rather than assumed — silently hiding a divergence is the one outcome sync must never produce.

On the very first sync there is no lastSyncAt, so every note present on both sides is treated as possibly-diverged and settled on content. .sync-track is rewritten after every run; changing the scope with -r discards the baseline, since the files being compared are no longer the ones the previous sync left behind.

sync never deletes, on either side. A note present on one side and absent on the other is always treated as new and copied across. So a note deleted locally with rm reappears on the next sync — deliberately: absence is ambiguous (deleted? never here? not cloned yet?) and resolving it as "restore" costs one rm, while resolving it as "delete" can empty a vault. Use bun run cli rm <path> to actually remove a note; it goes to the recycle bin.

Conflicts are never resolved automatically. mtime is not evidence of an edit — it moves for reasons unrelated to content, and it's compared across two machines whose clocks need not agree. So the older edit is never silently overwritten; you're shown both sides and asked:

2 file(s) changed on both sides — these are NOT synced automatically:

PATH              LOCAL SIZE       SERVER SIZE      LOCAL MODIFIED       SERVER MODIFIED
----------------  ---------------  ---------------  -------------------  -------------------
Work/standup.md   1.4 KB (1432 B)  1.6 KB (1640 B)  2026-08-27 09:14:02  2026-08-28 07:41:55

Each is then prompted individually — [l]ocal, [s]erver, or Enter to skip. Skipping is the default: leaving both copies intact is always recoverable, overwriting one is not.

Pass --prefer local or --prefer server to settle every conflict the same way without prompting. This discards the losing version with no backup, so it's for when you already know which side is authoritative — not a way to silence the prompt.

Before touching anything, sync prints a table of every planned change (action, path, each side's mtime) and asks for confirmation. Pass --dry-run to see the table and stop there, or --yes to skip the prompt (required if stdin isn't a terminal — e.g. cron/CI). --yes covers the unambiguous transfers only; conflicts are still left untouched. --json also skips the prompt, since JSON output implies scripted/agent use, and reports conflicts in a conflicts array (with localSizeKb, serverSizeKb, localMtime, serverMtime) plus an unresolvedConflicts count, applying nothing to them unless --prefer was given.

[remote-dir] scopes the sync to one vault subfolder instead of the whole vault — useful when a local folder should mirror just one project. The first time you pass it, it's saved to <local-dir>/.sync-track (a small JSON file, local-only — the server never sees it; it also holds lastSyncAt), so later runs of bun run cli sync <local-dir> remember the scope without repeating it. Passing a [remote-dir] again always overwrites the tracked value.

If .sync-track is missing, the scope defaults to the whole vault. If it exists but can't be read, sync stops and asks you to restate the scope rather than falling back to the whole vault — silently widening the scope would pull the entire vault into a folder meant to mirror one subfolder, and push that folder's files to the vault root. Passing -r <remote-dir> (or -r "" for the whole vault) repairs the file.

A file that fails to transfer doesn't abort the run: the rest are still attempted, the failures are listed on stderr, and sync exits 1. The printed counts are what actually transferred, not what was planned.

Both positionals also have explicit flag forms — -l/--local <dir> and -r/--remote <dir> — if you'd rather not rely on argument order:

bun run cli sync -l ~/work-notes -r work

Global flags:

  • --json — emit raw server JSON instead of human-readable output (use this for scripting and LLM tool calls).

Exit codes: 0 success · 1 HTTP/auth error (server error printed to stderr) · 2 usage/config error.

Examples

# List everything
bun run cli ls

# Read a note as JSON (for an agent)
bun run cli cat notes/idea.md --json

# Create a note from a string
bun run cli write notes/idea.md --content "# Idea\n\nfirst thoughts..."

# Append-style write from a pipe
echo "# Daily log" | bun run cli write journal/2026-06-20.md

# Update an existing note from a file
bun run cli write notes/idea.md --from-file ./draft.md --update

# Move / rename
bun run cli mv notes/idea.md archive/idea.md

# Search
bun run cli search "roadmap" --json

# Folders
bun run cli mkdir projects
bun run cli rmdir projects   # must be empty

# Two-way sync with a local mirror
bun run cli sync ~/notes --dry-run   # preview, no confirmation needed
bun run cli sync ~/notes             # shows the change table, then asks to confirm
bun run cli sync ~/notes --yes       # non-interactive (cron, scripts)
bun run cli sync ~/notes --prefer server   # settle every conflict toward the vault

# Scope a local folder to just one vault subfolder — remembered in .sync-track
bun run cli sync ~/work-notes work
bun run cli sync ~/work-notes        # scope remembered, no need to repeat "work"

Using it from an LLM / agent

  • Run bun run cli help to obtain the full command list at runtime, then drive the CLI with --json so responses parse cleanly.
  • whoami is a cheap auth/health check before a batch of operations. It also reports the server's version — the CLI and the app are versioned separately, so this is where the two numbers meet. Servers predating GET /api/version report server unknown.
  • All paths are relative to the vault root; the CLI cannot escape it (server-side path traversal guards apply), and a key only ever sees its owner's notes.

Installing the tinkernote skill

The package bundles a SKILL.md — a self-contained brief that teaches an agent this command set (install, auth, the read/write/reorganize commands, --json) so it doesn't have to rediscover it from help output each session. Install it into your agent's skill directory with one command — npx and bunx both work, use whichever runtime you have:

npx @msareen/tinker-note-cli skill install                    # Claude Code, user-level (~/.claude/skills)
npx @msareen/tinker-note-cli skill install --agent codex       # Codex CLI (~/.codex/skills, or $CODEX_HOME)
npx @msareen/tinker-note-cli skill install --agent all         # both
npx @msareen/tinker-note-cli skill install --project           # this repo only (./.claude/skills), Claude Code
npx @msareen/tinker-note-cli skill install --force              # overwrite an existing install

bunx @msareen/tinker-note-cli skill install                    # same, via Bun

Start a new agent session afterward to pick it up. For any other agent that reads a SKILL.md-shaped file from its own directory, tinkernote skill print writes the raw content to stdout so you can redirect it yourself:

npx @msareen/tinker-note-cli skill print > /path/to/that/agent/skills/tinkernote/SKILL.md
bunx @msareen/tinker-note-cli skill print > /path/to/that/agent/skills/tinkernote/SKILL.md

Revoking access

Revoke a key any time from Settings → Manage API Keys. Revocation (and account de-approval) takes effect on the very next request — no need to wait for the key's expiry.

Programmatic usage

The same request/auth/sync logic that powers the CLI is also importable directly, under a separate subpath so it doesn't pull in argument parsing or interactive prompts:

import { createClient, loadConfigFromEnv } from '@msareen/tinker-note-cli/api'

const client = createClient(loadConfigFromEnv()) // reads TINKER_TOKEN / ~/.tinker-cli/config.env
// or: createClient({ apiUrl, email, keyId, privateKey }) with explicit values

const { tree } = await client.listTree()
const note = await client.read('notes/idea.md')
await client.write('notes/idea.md', '# updated content')
await client.remove('notes/idea.md')

Errors are thrown, never printed/process.exited: TinkerApiError (has status/body) for HTTP failures, TinkerConfigError for missing/invalid config. The vault sync algorithm is exposed the same way — planSync(client, { localDir, remoteDir? }) computes what would change, applySync(client, plan.actions) performs it — with no TTY prompts or table rendering baked in, so a caller can drive it non-interactively.

See api-doc.md for the full exported surface (TreeNode, FileTreeResult, SearchResult, SyncAction, SyncPlan, etc.) with signatures and usage examples, generated from the JSDoc comments in tinker-api.ts and tinker-note-cli.ts via npm run docs.