A fully featured CLI used as an alternative to AppView applications (Bsky.app/mu.social)

Files
README.md

atcli #

Bluesky and the atproto network, from the command line, for people and for agents.

atcli login alice.bsky.social                 # OAuth in your browser
atcli timeline -n 10
atcli post 'Hello from the terminal! #atproto'
atcli notifications --unread
atcli search posts 'bundle' --sort latest --json

Everything the Bluesky app does, and the raw atproto layer beneath it:

  • Read: your timeline, anyone's posts, threads (or a whole self-thread unrolled into one piece), custom feeds, list feeds, notifications, trending topics, likes, reposts and quotes of a post.
  • Write: posts and threads with mentions, links, hashtags, link cards, images, video and captions, quotes, replies, content warnings, languages, reply gates and quote controls. Also likes, reposts, bookmarks, and deletes.
  • People: profiles (and editing yours), follows, blocks, mutes (accounts, threads and words), relationships, suggestions, curation and moderation lists, starter packs.
  • Messages: direct messages and group chats. Read, send, react, accept, mute and leave.
  • Raw access: resolve handles and DIDs, read and write any record in any collection, and call any XRPC method on any service.
  • Several accounts signed in at once, with a default and --account to pick another.
  • Output as text, JSON, Markdown or HTML, on every command, with a default you choose.
  • Agent-ready: --json on every command (errors included), --help on every command (as JSON too), stable exit codes, and a skill that teaches agents to use it (atcli skill install).

What writes #

Most commands only read. The ones that write:

  • On the network — they publish something other people can see, or change the account: post, reply, quote, delete, like/unlike, repost/unrepost, bookmark add/remove, gate, profile update/pin/unpin, follow/unfollow, block/unblock, mute/unmute, list create/edit/delete/add/remove and the moderation-list actions, feed save/unsave/pin/unpin, notifications seen, chat send and every chat-management command, record create/put/delete, upload, report.
  • On this machine only — sessions and settings: login, logout, account remove/default/refresh, config set/unset/forget, skill install, completion --install.
  • Either, by method: xrpc — queries (GET) read, procedures (POST) write.

The generated command reference marks every writing command and lists them all up top, and the agent skill (atcli skill show) carries the same list with what each one does. post --dry-run prints the exact record without writing anything.

Install #

atcli ships as one signed file, built with @pipobscure/bundle, and needs Node.js 26.11.1 or later.

bundle install <url of atcli.nzip>      # verified, put on your PATH, kept current by `bundle update`

Or run it from a checkout. There is no build step: Node runs the TypeScript directly.

git clone … && cd atcli && npm install
node src/main.ts --help
npm link                                # puts `atcli` on your PATH

Signing in #

atcli login alice.bsky.social                      # OAuth (the default): opens your browser
atcli login alice.bsky.social --password           # an app password, asked for at the terminal
printf %s "$APP_PW" | atcli login alice.bsky.social --password-stdin

Sessions are kept in the system credential store, never in a plain file:

Platform Store
Linux, BSD Secret Service (GNOME Keyring, KWallet, KeePassXC…) through secret-tool; KWallet directly through kwallet-query
macOS the login Keychain, through security
Windows Credential Manager

On a machine without a keyring (a container, CI), atcli config set secret-store file keeps them in a 0600 file in the config directory instead.

OAuth when the browser is elsewhere. If you are on SSH, open the printed URL wherever you have a browser, then paste the address it ends up at (http://127.0.0.1/callback?…, which will not load) into the terminal. Or do it in two steps, which is how agents do it:

atcli login alice.bsky.social --start          # prints the URL to open
atcli login --finish 'http://127.0.0.1/callback?state=…&iss=…&code=…'

Which OAuth client. Before signing in, atcli picks the first OAuth client whose metadata document would pass the authorization server's checks: status 200 without a redirect, served as application/json, naming itself, atcli's redirect and its scope. It tries, in order:

  1. the oauth-client setting, if you have put a document's URL there;
  2. this release's own document, attached to the release on tangled. Its URL is known from the version alone, from the first entry of atcli.oauthClients in package.json;
  3. the fallback document at https://ordner.fyi/bundles/atcli.json, the second entry;
  4. atproto's loopback development client, which needs nothing hosted but gets shorter sessions.

The candidates are checked at once, and the first that passes, in order, is used. atcli only says which candidates it passed over when it ends up at the loopback client, or when the one in your setting doesn't pass. atcli config set oauth-client loopback skips straight to the loopback client. To host your own document, atcli config client-metadata <url> prints it. A session keeps the client it signed in with, so upgrading atcli doesn't sign you out. atcli whoami shows which client a session uses.

Several accounts #

Sign in to as many as you like. The first becomes the default.

atcli account list                       # * marks the default
atcli account default bob.example.com    # commands now act as bob
atcli post 'hi' --account alice.bsky.social
atcli logout bob.example.com

Using it #

atcli --help lists every command, and --help after any command explains it, wherever it appears on the line: atcli post --image --help works.

Output formats. Every command prints for the terminal by default. It can also print --json (the data, for programs), --markdown, or --html (a whole page), or take --format text|json|markdown|html. Set your own default with atcli config set format markdown; a flag always wins. Markdown and HTML are made from the same document, with post text escaped so it stays text, and mentions, links, tags, images, quotes and link cards rendered properly. Shell scripts (atcli completion bash) print as they are, whatever the format.

atcli unroll <post> --html > thread.html && open thread.html
atcli timeline -n 20 --markdown | glow -
atcli thread <post> --markdown | pandoc -o thread.pdf
atcli config set format markdown        # from now on, unless told otherwise
atcli config set lang en                # the language posts are tagged with

Naming things. Accounts are handles, @handles, DIDs or profile links. Posts are at:// URIs, bsky.app links, or handle/rkey. Feeds and lists can be named the way you think of them: atcli feed show mutuals, atcli feed show "JS/TS Wien", atcli list show "js ts wien". A name (or the last part of a feed's address) is looked for among your saved feeds, then the ones you made, then by searching all of Bluesky's feeds. Case, accents and punctuation don't matter, and part of a name is enough. following is your timeline. The output says which feed it read. If two of your feeds fit equally well, atcli lists them instead of guessing. at:// URIs, bsky.app links and handle/rkey work too. A chat conversation is its id, or the handle of the person it is with.

Lists and paging. Listing commands take -n <total> (default 25), --all, and --cursor to continue where a previous listing stopped. Listings of dated things (timeline, posts, feed show, list feed, notifications, chat list/show, bookmark list, likes, quotes) also take --since <time>: an ISO 8601 time (UTC unless it says otherwise) or how long ago (30m, 2h, 1d, 1w). atcli timeline --since 2h --all is everything from the last two hours, and paging stops as soon as it reaches older posts.

Only what's new. --new on any of those listings shows only what the last --new listing of the same thing didn't: atcli feed show mutuals --new shows the newest the first time, and after that only what has come since. With -n, it takes the oldest n of the new ones, so reading in batches skips nothing. It remembers per account and per listing; atcli timeline and atcli feed show following count as the same listing. atcli config forget --new forgets.

Watching. atcli chat watch and atcli notifications watch keep running and print messages and notifications as they arrive, as text or as one JSON object per line:

atcli chat watch                                             # everything, as it happens
atcli chat watch --incoming --messages --count 1 --json      # wait for the next DM, then stop
atcli notifications watch --reason mention --reason reply
atcli chat watch --resume --once                             # what came since last time, then stop

Bluesky has no push channel for direct messages that a client like this can use: they aren't on the firehose, and push notifications only go to the official apps. So the watches poll with a cursor, the way the Bluesky app does: messages every 10s, notifications every 30s, slowing while nothing happens (--interval changes it). --count, --timeout and --once stop them. --resume carries on from where the last one stopped, per account, neither repeating nor missing anything.

Posting. Text over 300 characters is refused, unless --split turns it into a thread. --thread posts each argument as its own post. --dry-run prints the records without writing anything.

atcli post 'Look at this' --image cat.jpg --alt 'A cat asleep on a keyboard'
atcli post --video clip.mp4 --video-alt 'Waves at sunset' --caption en:clip.vtt
atcli reply https://bsky.app/profile/alice.bsky.social/post/3k… 'Agreed!'
atcli quote at://did:plc:…/app.bsky.feed.post/3k… 'This.'
atcli post 'Only for people I follow' --reply-gate following --no-quotes
cat essay.txt | atcli post --split --lang en -

Whole threads. Given any post of a thread someone wrote as replies to themselves, unroll finds its start and end and prints it as one piece, without other people's replies or the "1/7" numbering. atcli thread points it out when a post is part of one.

atcli unroll https://bsky.app/profile/alice.bsky.social/post/3k…
atcli thread <post> --unroll --links          # the same, with each post's link
atcli unroll <post> --markdown > thread.md    # links, mentions and images as Markdown
atcli unroll <post> --json | jq -r .text

Raw access, for anything not covered:

atcli resolve alice.bsky.social
atcli record list app.bsky.graph.follow --repo alice.bsky.social --all --json
atcli xrpc app.bsky.graph.getKnownFollowers -p actor=alice.bsky.social
atcli xrpc app.bsky.actor.putPreferences --data @prefs.json

For agents #

atcli skill install            # into .claude/skills/atcli in this project
atcli skill install --global   # into ~/.claude/skills for every project
atcli skill show > AGENTS.md   # the whole skill as one document, for any other agent

The skill explains the conventions, signing in without handling passwords, when to ask the user before acting, and recipes for common tasks. It includes a full command reference generated from the CLI itself. Without the skill:

  • --json prints the result as JSON on stdout. Failures print {"error": {"code", "message", "exit"}} there too.
  • atcli <command> --help --json describes a command's arguments and options; atcli help --json describes all of them.
  • Exit codes: 0 ok, 1 error, 2 usage, 3 not signed in, 4 not found, 5 rate limited, 6 needs input it had no terminal to ask for.

Tab completion #

eval "$(atcli completion bash)"                               # ~/.bashrc
eval "$(atcli completion zsh)"                                # ~/.zshrc
atcli completion fish | source                                # ~/.config/fish/config.fish
atcli completion powershell | Out-String | Invoke-Expression  # $PROFILE
atcli completion --install                                    # or let atcli add it

It completes commands, options, their choices, files, and signed-in accounts.

Settings #

Everything atcli can be told to do differently is a setting in its config file, beside the account list (never the credentials), not an environment variable:

atcli config list                      # every setting, its value, and what it does
atcli config set format markdown
atcli config unset format
Setting
format text (default), json, markdown or html
lang the language(s) posts are tagged with, e.g. en or en,de
secret-store auto (default), secret-service, kwallet, keychain, wincred or file
kwallet-wallet the KWallet wallet to use (default kdewallet)
oauth-client auto (default: this release's client, else loopback), loopback, or a client metadata URL
browser auto (default) or never: whether login opens a browser
rate-limit-wait the longest to wait out a rate limit, in seconds (default 60)
appview, public-appview, chat, video-service, plc-directory the services atcli talks to

The config file is ~/.config/atcli/config.json (~/Library/Application Support/atcli on macOS, %APPDATA%\atcli on Windows); atcli config path prints it. The one thing that cannot be a setting is where that file is: ATCLI_CONFIG_DIR moves it, for tests and for keeping separate setups apart. Beyond that, atcli honours the usual conventions of the platform: NO_COLOR, XDG_CONFIG_HOME.

Releasing #

CI (.tangled/workflows/ci.yml) typechecks and tests every push and pull request to main. Releases are made the way tangled releases itself:

npm version patch            # bumps package.json and makes an annotated tag
git push --follow-tags       # the tag starts .tangled/workflows/release.yml

tools/release.sh then checks the tag matches package.json, tests, builds the archive reproducibly (dated by the commit, in UTC), attaches it to the release as atcli.nzip, attests it as published, and points the @<you>/atcli bundle listing at it. A version that already has atcli.nzip is left as it is. Run by hand (tangled run trigger --workflow release.yml), it is a dry run, and so is RELEASE_DRY_RUN=1 ./tools/release.sh locally.

It needs two secrets (tangled secret set <NAME>): TANGLED_HANDLE and TANGLED_APP_PASSWORD, an app password for that account. It uploads with the tangled CLI, installed from its listing only if its author has attested it, and with bundle's state kept inside the run, so a local dry run leaves your own installs alone.

Each release also carries its OAuth client document, client-metadata.json. atcli only signs in with it once it is served as application/json, which tangled.org does not do yet (tangled.org/core#2264); until then atcli signs in through https://ordner.fyi/bundles/atcli.json, and switches by itself, with no new release, once tangled.org serves it right.

To build and try the archive by hand:

npm run release      # check, manifest, pack, client metadata, audit
./build/atcli.unsigned.nzip --version

Licence #

EUPL-1.2