Newest at the top.
The Final Blitz release: the 2026-09-13 final audit's worklist executed. One crash guard, a transport-core rebuild, the --json contract honored at every boundary, four new commands, and a round of promise-vs-reality repairs. Suite: 209 passed, 1 skipped (was 170 passed, 1 skipped).
- Fixed: one junk bookmark no longer crashes
rd sync.normalize_urlaskedurlsplit(url).port, which raises on a malformed or out-of-range port, so a singlehttps://example.com:99999/bookmark in either library took the whole sync down with a raw traceback. The key now falls back to the raw URL when parsing fails; it still dedups against itself. - The transport core is shared and the retry family is complete. The
retry loop, the transient-error family, the Retry-After logic, and the
typed error mapping moved into
rd_cli._transport, used verbatim by both clients. The family broadened fromURLError/TimeoutErrortoOSError+http.client.HTTPException, so connection resets and truncated responses now retry (and exit as typed errors when exhausted) instead of escaping as tracebacks. Pinboard honorsRetry-Aftertoo, which the spec always promised. - Writes are never retried on a network failure. After a timeout, whether the server applied a write is unknowable, and re-sending a create could double it silently. Creating raindrops (POST) and Pinboard mutations now fail loudly instead; 429/5xx responses still retry, since the server answered.
- The
--jsonsingle-document contract is honored at the failure boundaries. Pre-flight guards and confirmation refusals (including the non-interactive refusal) emit{"error": ...}on stdout in JSON mode instead of human stderr with an empty stdout; a missing object exits non-zero in JSON mode like the human mode;rd rm --jsonemits the spec's{"result": bool}instead of a per-id map. Three commands silently ignoring--jsonwere settled:export --jsonrequires-oand emits a{"path", "bytes", "format"}document for the written file;completion --jsonrefuses with an error document (a completion script is shell code by definition);rd config path --jsonemits{"path": ...}. A missing input file or a non-JSON 200 body is now a clean error, never a traceback. rd config check [--ping]. Reports which config file is in effect and which tier each token resolves from (environment and variable name, config.toml, or.env) without ever printing a value;--pingproves each token against the service's cheapest read. Three of this repo's worst shipped bugs were silently-wrong token resolution; this turns the class into a one-command diagnosis.- The highlights lane.
rd highlights list -c <collection>wires the long-idle collection-highlights endpoint; highlight lines show the source title the API already provides; andrd highlights export [-o file]renders every highlight as Markdown grouped by source, the surface no competing CLI covers. rd dupes. A read-only report of URLs saved more than once within either service (the groups the sync planner collapses), with ids and titles, so near-duplicates can be merged by hand.rd pinboard list --from/--topasses Pinboard's date filters through on--allreads (and refuses without--all, since posts/recent has no date parameters rather than silently doing nothing).- Fixed: batch add honors and rejects flags honestly.
rd add --file/--stdinsilently ignored the five per-item value flags while--no-parsedid the opposite of its name (batch items always requested parsing). Batch mode now rejects--title/--tags/--excerpt/--note/ --importantwith a clear error and honors--no-parseas the batch-wide knob. - Fixed:
--allpaginates correctly at any--perpage. The iterators compared page size against the unclamped value while the fetch clamped to the API's 50, soperpage > 50truncated the walk after page 1.--perpageis also no longer silently ignored with--all. - Sync semantics hardened. An in-scope duplicate of an
out-of-scope-first URL is now the one that gets pushed; raindrop
createdstamps are normalized to Pinboard's documenteddtshape so a rejected timestamp cannot silently re-date a push;--from 0is rejected locally (the batch endpoints do not support it); andrd sync --dry-runbuilds both clients with the dry-run flag, so the writes are blocked twice. - Output truth.
rd viewhighlight markers route through the palette map (raw API color names rendered them colorless);NO_COLOR=""counts as unset per the standard; East Asian wide characters count two columns in table alignment; multipart filenames escape quotes and line breaks; a closed| headshuts down silently; and id-moderd tag --clearnow asks before destroying tags, like scope mode always did. - Release/publish automation. pytest and ruff are pinned in CI and on
the publish path (they floated before); publish.yml now cuts the GitHub
Release from the tag by construction; the
pypienvironment is bound tov*tags; and the missing v0.5.2 and v0.6.0 Releases were created from their verbatim tag messages (nothing older was backfilled). - PyPI metadata surfaces the differentiators. The description and keywords now name Pinboard and sync; classifiers and a Changelog URL were added.
- Docs, comments, and config-writer truth. The config writer skips
non-scalar keys with a warning instead of corrupting them, and creates
its temp file
0600from the first byte; the dispatch-chokepoint comment enumerates every bespoke--jsonbranch; several docstrings no longer overclaim (the pacer,last_update's caller,apply_plan's dry-run); the em-dash sweep finished across spec, README, and CLAUDE.md; the README gained the legacy.envrow, thePYTHONPATH=srcform, and the Behavior-notes TOC entry; and the roadmap's wrongly-ticked delete-propagation box is unticked.
The token fix that 0.6.0 never shipped, the config path the rename broke, and the sync contract repairs. 0.6.0's token-precedence fix (commit 339baa9) never reached PyPI; this release carries it, repairs a real hole it still had, and restores the author's own installed copy to working order.
- Fixed: the token-precedence fix now holds in dual-token processes.
rd syncresolves the Raindrop and Pinboard tokens in one process, andload_env_filesonly reported the keys it injected on the current call, so the second resolver took the first call's.envinjections for real environment variables. A stale./.envPinboard token silently beatrd config set-pinboard-token(the exact bug 339baa9 claimed to fix). The injected-keys registry is now module-level and matches by exact value, so.envsourcing survives across resolutions in any order while a genuinely re-exported env var still wins. - Fixed: the September 2026 rename stopped reading your config. The
package moved its config directory from
~/.config/rd-cli/to~/.config/raindrop-cli/without a migration, so every install that predates the rename (including the author's) lost its token source. The old path is now a read fallback when the new one has no config; the firstrd config set-*writes the new path and carries every legacy key across, andrd config pathprints the file actually in effect. - Fixed:
rd sync --jsonagain prints exactly one JSON document. The human plan summary lines printed before the document on a real run (only--json --dry-runwas clean). The plan lines are now guarded off in JSON mode:--dry-runemits the plan document, a real run emits the applied counts. - Fixed: sync merges keep the Pinboard post's date. A merge re-adds the
post without
dt, re-dating it to now and contradicting the documented timestamp behavior. The original save time rides along, as edits and pushes already did. A push also no longer hardcodesshared: true(which made every pushed bookmark public): the flag is omitted so your Pinboard account default applies, and merges keep the post's own value. - Fixed: socket timeouts join the retry core. On Python 3.10+ a read
that times out raises bare
TimeoutErrorinstead of aURLError, so it escaped the retry loop in both clients as a traceback. It now retries with the same backoff and surfaces as a typedAPIErrorwhen exhausted. - Fixed: explicit ids combined with
--fromare rejected. The batch endpoints scope to the path collection and ignore an id list, sord rm 5 --from 111trashed all of collection 111 and never touched id 5.rd rm,rd mv, andrd tagnow refuse the combination. rd backups createsupports--json(emits{"requested": true}), andrd config set-token/set-pinboard-tokenemit{"path": ...}under--jsoninstead of human text.- Exit codes unified. A false result now exits 1 in human mode too
(
rd tags renameand a few others used to exit 0 on failure), matching the spec's exit-code table. - Docs re-synced with reality.
spec.mdwas still the 0.3.0 contract: its non-goals banned two shipped features (confirmation prompts and the permanent-copy endpoint),openwas missing from the verb list, and--yes/RD_ASSUME_YESwere not in the contract. The OAuth2 "planned" wording is replaced by the retirement decision in spec, README, and CLAUDE.md; the README'sconfig show --json"raw tokens" claim is corrected (tokens are masked in both modes). CLAUDE.md's module tree now listspinboard.py/sync.py/completion.py. - Internals: the
--jsondispatch debt is paid. The ~57 scatteredif args.json:branches collapsed into two chokepoints incommands.py(_outfor write-shaped commands,_renderedfor list-shaped ones); genuinely bespoke renders keep an explicit branch. CI now tests the declared 3.11 floor alongside 3.14.
Two audit findings investigated and closed without a change: the
back-compat aliases are already absent from rd --help (unhelped subparsers
are unlisted on 3.11 and 3.14, and help=argparse.SUPPRESS would backfire
on 3.14 by rendering the sentinel literally); a regression test now pins
that behavior.
Suite: 170 passed, 1 skipped (was 144 passed, 1 skipped).
Phase 7 robustness sweep: the seven verified bugs, plus safe config writes. Every fix regression-tested against the FakeOpener transport:
rd filtersno longer crashes on bare-integer counts. The API documents counts as{"count": N}objects but some responses carry the bare integer; both print now.- Pinboard timestamps survive edits and syncs.
edit_postpreserves the original save time (dt=current.get("time")), and raindrops pushed to Pinboard carry theircreatedtimestamp asdt, so re-syncs never re-date anything to now. - Highlight markers are colored. Raindrop's highlight color names
(yellow, blue, green, ...) now map onto the CLI palette via
_HL_CODES; unknown names fall back to muted instead of printing the raw name as a lookup key and losing the color entirely. toreadround-trips through sync.pinboard_to_raindropalready preserved unread state as a "toread" tag;raindrop_to_pinboardnow honors that tag instead of hardcodingtoread: False.- Malformed records are skipped, not fatal. A raindrop without a link
or a Pinboard post without an href no longer raises KeyError in
plan_sync; the record is skipped and the rest of the sync proceeds. - Cover searches encode slashes.
search_coverspassessafe=""soa/b cbecomesa%2Fb%20cinstead of breaking the URL path. rd open --jsonno longer launches a browser. JSON is the agent/script surface; a browser launch is a side effect those callers never asked for. Human mode and--printbehave as before.- Config writes are atomic and escaped.
config.toml(which holds the API tokens) is now written via temp-file +os.replace, chmod 0600 before the swap, and TOML values escape quotes and backslashes properly.
Two Phase 7 boxes closed as already-shipped after code verification:
human-mode exit codes (failure paths return 1 throughout commands.py) and
multi-level completion (completion.py recurses into nested subcommands,
test-pinned). The .env docstring-vs-roadmap conflict is resolved as
documented behavior: load_env_files reads the first existing file on
purpose.
Suite: 144 passed, 1 skipped (was 127).
- Build: Replaced unittest with pytest in the CI workflow, restoring test coverage execution.
- Build: added the GitHub Actions CI workflow.
-
rd completion bash|zsh|fishprints a shell completion script. It is generated by walkingbuild_parser(), so the parser stays the single source of truth and the completion cannot drift from the commands a build actually has. Completes commands, nested subcommands, flags, and fixed positional choices (rd completion <TAB>offers the three shells).Install:
rd completion bash > ~/.local/share/bash-completion/completions/rd rd completion zsh > "${fpath[1]}/_rd" rd completion fish > ~/.config/fish/completions/rd.fish
Still zero dependencies: argcomplete would have been one, so the emitters are hand-rolled. The cost is reading argparse's private structures, which is contained by a guard test that names exactly what is relied on and fails with a pointed message if a Python release moves any of it.
The generated bash and zsh scripts are checked in CI by the shells' own parsers, and the bash one is additionally sourced and driven to confirm it really completes. The fish check skips where fish is not installed rather than pretending to pass.
rd open <id>...opens raindrops in your browser. Takes any number of ids.--cache(alias--permanent) opens the archived permanent copy instead of the original link: that endpoint answers307with the storage URL in a header, so the client asks for it with redirects suppressed and readsLocationrather than following it and downloading the copy. The archive is a PRO feature and only some links are stored, so a missing copy reports that plainly instead of opening the wrong thing.--printemits the URL and launches nothing, which is what you want over SSH or in a pipe.- Confirmation prompts on destructive operations. The guard used to be
--dry-runand nothing else, which only helps if you remember to type it first. Prompts are gated on blast radius rather than on every write, so the common path stays quiet:- Unbounded: scope mode on
rd rm,rd mv, andrd tag --clear, where--fromcan match any number of raindrops. The prompt counts them first and names the number. - Irreversible:
rd rm --permanent,rd collections empty-trash, andrd tags rm.rd collections rmalso asks, because deleting a collection takes its contents along with it. - Not prompted: removing by id to Trash (recoverable), and appending tags in scope mode (additive).
- Unbounded: scope mode on
-y/--yesskips the prompts, andRD_ASSUME_YES=1does the same for cron and scripts that cannot answer one.--dry-runbypasses confirmation entirely, since it performs no writes and its whole purpose is to show you what would happen.
- A non-interactive stdin refuses rather than prompting. Blocking on a read no one can answer would hang a script forever, and assuming yes would delete things nobody agreed to.
- The prompt is written to stderr, so confirming does not contaminate a redirected stdout.
- The affected-item count is read opportunistically. The list endpoint is not documented to return a total, so a missing count produces "every raindrop in collection X" rather than a confidently wrong number.
rd sync: two-way additive sync between Raindrop and Pinboard. Matches bookmarks across the two services by a normalized URL (scheme/www/fragment folded, tracking params likeutm_*/fbclidstripped, meaningful query kept), which doubles as the cross-service dedup key. It only ever adds and merges, never deletes, so the two libraries converge to their union with no data loss.- The model gap is bridged reversibly in tags: a Raindrop collection becomes a
slugged Pinboard tag, Pinboard's
toreadand Raindrop'simportantride along as tags, and a Pinboard tag that matches a collection routes the item back into that collection. Highlights stay Raindrop-only. On a URL that exists on both sides, tags are unioned and notes are merged idempotently (no duplication on repeat runs). - Scoping so you never have to union everything at once:
--direction both|to-pinboard|to-raindrop, and--collection/--rd-tag/--pb-tagto restrict which items are pushed. Scope narrows what is written, but matching always uses the full sets, so an out-of-scope item that already exists on the other side is never re-imported as a duplicate. --dry-runprints the plan (counts per direction, near-dupes collapsed) and writes nothing. The planning half (sync.plan_syncand the mapping helpers) is pure and covered by unit tests independent of the network.
- Pinboard as a second bookmarking backend, alongside Raindrop. A new
rd pinboard(aliaspb) command group speaks Pinboard's flat model natively (bookmarks keyed by URL, tags, notes, and thetoread/sharedflags) instead of pretending it has Raindrop's collections:pinboard list|get|add|rm|edit| tag|suggest,pinboard tags list|rename|rm, andpinboard notes list|view. PinboardClient, a stdlib sibling ofRaindropClient: auth through theauth_tokenquery param,format=jsonon every call, a minimum inter-request pacer for Pinboard's strict rate limit (about one call every three seconds) on top of the usual429backoff, and the shared typed-error family plus--dry-runand--jsonbehavior. Pinboard writes are all GETs, so they are flagged explicitly rather than inferred from the HTTP method.- Pinboard token resolution mirrors Raindrop:
PINBOARD_TOKEN(orPINBOARD_API_TOKEN) env var,pinboard_tokeninconfig.toml, or a.envfile;rd config set-pinboard-token <token>writes it (0600). Both service tokens coexist in the one config file without clobbering each other. - Pinboard has no update endpoint, so
editandtagare a read-modify-write: fetch the bookmark, merge the change, and re-save withreplace=yes, leaving untouched fields intact.
--dry-runno longer mislabels a bodyless request as<multipart>. A plain DELETE or PUT with no body now previews as<no body>, JSON writes preview as their JSON (unchanged), and multipart uploads preview as<multipart ... files=[...]>without dumping the raw file bytes. Extracted the logic into_dry_run_previewwith direct unit coverage.
The framework rebuild. The barebones prototype became a dependency-free, tested, fully documented CLI.
- Complete API coverage: raindrops (single, batch, suggest, file/cover upload,
export), collections (list, tree, view, add, edit, remove, merge, clean,
empty-trash, reorder, cover, cover search), tags (list, rename, merge,
remove), highlights (list, add, edit, remove), plus
user(show +set),stats,filters,suggest,exists(import dedup), HTML-file import, andbackups(list, create, download). - Bulk/reorganization commands:
rd mv, multi-id and scoperd rm(--permanent),rd tag(add/remove/clear), andrd add --file/--stdinfor batch create. Explicit ids loop the single-item endpoints;--from <collection>uses the batch endpoints for whole-collection scope. (Grounded in an empirically verified quirk: the batch endpoints only touch raindrops actually in the path collection, so a naive id-based batch move silently no-ops. Two other CLIs surveyed carry exactly that latent bug.) --dry-run: previews every write (logs method + payload to stderr) without calling the API; reads still run so a plan can be built first.- Grouped command surface (
rd collections tree,rd tags rename,rd highlights add, ...) withc/t/hshort aliases, keeping the original flat commands (c-list,t-list,h-list, ...) working as hidden aliases. --allto auto-paginate list and highlight reads.rd config path|show|set-token; token also resolvable from~/.config/raindrop-cli/config.tomland.env, withRAINDROP_TOKENas the primary env var (RAINDROP_TEST_TOKENstill honored).- TTY-aware ANSI output (Kanagawa-ish palette): colour on a terminal, plain when
piped,
NO_COLORand--no-colorrespected. Nested collection tree view and aligned tag/collection columns. --version, and--jsonnow works before or after the subcommand.- pytest suite exercising the client, config, output, and CLI against a fake urllib transport (no network); ruff lint/format configured.
- Framework docs: comprehensive
CLAUDE.md(Raindrop API + codebase),spec.md,roadmap.md,logo.svg, single-sourceVERSION.
- Ported the whole client from
requeststo stdliburllib; removed therequestsandpython-dotenvdependencies (zero runtime deps now). - Split the two-file prototype into a package:
errors,config,client,output,commands,cli. addnow auto-parses page metadata by default (title, cover, type) unless--no-parse; default target collection is Unsorted.
- Requests now use a timeout (previously could hang forever).
- Boolean query params are sent lowercase (
nested=true); the API rejected the previousTrue/False. - API error messages surface to the user (the
errorMessagefrom the response) instead of a bare HTTP status. - Rate-limit (
429) and transient5xxresponses retry with backoff instead of failing immediately (Retry-Afterparsed as seconds or an HTTP-date). rm --permanentuses the documented two-step delete; the undocumented?permanent=truequery param was tested and does not one-shot a live raindrop.- Removed a hard-coded personal
.envpath that leaked into the repo.