@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.
Maintainers
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 helpOr run it without installing — both npx (Node) and bunx (Bun) work identically:
npx @msareen/tinker-note-cli help
bunx @msareen/tinker-note-cli helpInside this repo you can also run the source directly with
bun run cli <command>— it's the same program. All examples below usetinkernote; substitutebun run cliwhen 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
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.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.envon Windows), so it's available from any directory:bun run cli auth # aliases: login, initRe-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.examplethere and fill it in):TINKER_TOKEN="tnk_..."A config file is optional. The CLI reads
process.envfirst and only falls back to files, soTINKER_TOKENcan equally live in your shell environment (export TINKER_TOKEN=...) or in any file Bun auto-loads —.env/.env.localin the project root — when you runbun run cli. Lookup order is:process.env→~/.tinker-cli/config.env→ a legacy./.tinker-cli.envin 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_TOKENis 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_KEYaccepts either inline PEM (quoted, one line,\n-escaped — a raw multi-line PEM won't parse) or a filesystem path to a.pemfile, e.g.TINKER_PRIVATE_KEY=./tinker-key.pem.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.envand*.pemare 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:55Each 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 workGlobal 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 helpto obtain the full command list at runtime, then drive the CLI with--jsonso responses parse cleanly. whoamiis 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 predatingGET /api/versionreportserver 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 BunStart 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.mdRevoking 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.
