Something went wrong. Try again.
A fully featured CLI used as an alternative to AppView applications (Bsky.app/mu.social)
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226import { appviewProxy, chatProxy, publicAppview, Session } from './session.ts';import { resolveIdentity, toDid } from './identity.ts';import { xrpc, type CallOptions, type Params } from './xrpc.ts';import { AuthError, UsageError } from './errors.ts';
// What commands talk to. A Client routes each call to where it belongs://// appview app.bsky.* — through the signed-in account's PDS, proxied to// the Bluesky app view, so results are personalised (viewer// state, blocks, mutes); signed out, straight to the public// app view, which answers most reads for anyone// chat chat.bsky.* — through the PDS, proxied to the chat service// pds com.atproto.* on the account's own PDS// repo reads someone's records, from their own PDS, signed in or not//// A command that writes asks for the session with `session()`, which fails// with exit 3 and a pointer to `atcli login` when there is none.
export type ServiceName = 'appview' | 'chat' | 'pds';
export interface BlobRef { $type: 'blob'; ref: { $link: string }; mimeType: string; size: number;}
export interface StrongRef { uri: string; cid: string;}
export interface Page<T> { items: T[]; /** Where to continue from, when there is more. */ cursor?: string | undefined;}
export interface PageOptions { limit?: number | undefined; all?: boolean | undefined; cursor?: string | undefined; /** Only items newer than this, in milliseconds since the epoch (with `dateOf`). */ since?: number | undefined;}
export class Client { #session: Session | undefined; #selector: string | undefined; #loaded = false;
constructor(selector?: string, session?: Session) { this.#selector = selector; if (session) { this.#session = session; this.#loaded = true; } }
/** The account in use, if any is signed in. */ async maybeSession(): Promise<Session | undefined> { if (!this.#loaded) { this.#session = await Session.load(this.#selector); this.#loaded = true; } return this.#session; }
async session(what = 'this'): Promise<Session> { const session = await this.maybeSession(); if (!session) throw new AuthError(`${what} needs a signed-in account; run 'atcli login <handle>' first`); return session; }
/** A call to an app.bsky.* method; signed out, the public app view answers. */ async appview<T = unknown>(nsid: string, params?: Params, options: { input?: unknown; signedIn?: boolean } = {}): Promise<T> { const session = options.signedIn || options.input !== undefined ? await this.session(nsid) : await this.maybeSession(); if (session) return await session.call<T>(nsid, { params, input: options.input, proxy: appviewProxy() }); return await xrpc<T>(publicAppview(), nsid, { params }); }
async chat<T = unknown>(nsid: string, params?: Params, input?: unknown): Promise<T> { const session = await this.session('direct messages'); return await session.call<T>(nsid, { params, input, proxy: chatProxy() }); }
async pds<T = unknown>(nsid: string, options: CallOptions & { proxy?: string } = {}): Promise<T> { const session = await this.session(nsid); return await session.call<T>(nsid, options); }
/** Any method, on any service: what `atcli xrpc` uses. */ async call<T = unknown>(nsid: string, { service, proxy, ...options }: CallOptions & { service?: string | undefined; proxy?: string | undefined }): Promise<T> { if (service && /^https?:\/\//.test(service)) { const session = await this.maybeSession(); // A third party's server gets no tokens of ours. if (session && new URL(service).origin === new URL(session.pds).origin) return await session.call<T>(nsid, { ...options, proxy }); return await xrpc<T>(service, nsid, options); } const route = service ?? (nsid.startsWith('chat.bsky.') ? 'chat' : nsid.startsWith('app.bsky.') ? 'appview' : 'pds'); if (route === 'chat') return await this.pds<T>(nsid, { ...options, proxy: proxy ?? chatProxy() }); if (route === 'appview') { const session = await this.maybeSession(); if (session) return await session.call<T>(nsid, { ...options, proxy: proxy ?? appviewProxy() }); if (options.input !== undefined || options.method === 'POST') await this.session(nsid); return await xrpc<T>(publicAppview(), nsid, options); } if (route === 'pds') { const session = await this.maybeSession(); if (session) return await session.call<T>(nsid, { ...options, proxy }); if (options.input !== undefined || options.method === 'POST') await this.session(nsid); // Signed out, a repo read goes to the repo's own PDS. const repo = options.params?.['repo']; if (typeof repo === 'string') return await xrpc<T>((await resolveIdentity(repo)).pds, nsid, options); return await xrpc<T>(publicAppview(), nsid, options); } throw new UsageError(`unknown service '${route}': appview, chat, pds or an https:// URL`); }
// ------------------------------------------------------------- records ---
async createRecord(collection: string, record: Record<string, unknown>, rkey?: string): Promise<StrongRef> { const session = await this.session(`creating a ${collection} record`); return await session.call<StrongRef>('com.atproto.repo.createRecord', { input: { repo: session.did, collection, ...(rkey ? { rkey } : {}), record: { $type: collection, ...record } }, }); }
async putRecord(collection: string, rkey: string, record: Record<string, unknown>, swapRecord?: string | null): Promise<StrongRef> { const session = await this.session(`writing a ${collection} record`); return await session.call<StrongRef>('com.atproto.repo.putRecord', { input: { repo: session.did, collection, rkey, record: { $type: collection, ...record }, ...(swapRecord !== undefined ? { swapRecord } : {}) }, }); }
async deleteRecord(uri: string): Promise<void> { const session = await this.session('deleting a record'); const { repo, collection, rkey } = parseAtUri(uri); if (!collection || !rkey) throw new UsageError(`${uri} does not name a record`); const did = await toDid(repo); if (did !== session.did) throw new UsageError(`${uri} is in ${repo}'s repository, not ${session.handle}'s; only your own records can be deleted`); await session.call('com.atproto.repo.deleteRecord', { input: { repo: did, collection, rkey } }); }
/** A record from anyone's repository: from the session's PDS when it is ours, else from the owner's. */ async getRecord<T = Record<string, unknown>>(uri: string): Promise<{ uri: string; cid?: string; value: T }> { const { repo, collection, rkey } = parseAtUri(uri); if (!collection || !rkey) throw new UsageError(`${uri} does not name a record`); const identity = await resolveIdentity(repo); const params = { repo: identity.did, collection, rkey }; const session = await this.maybeSession(); if (session?.did === identity.did) return await session.call('com.atproto.repo.getRecord', { params }); return await xrpc(identity.pds, 'com.atproto.repo.getRecord', { params }); }
async listRecords<T = Record<string, unknown>>(repo: string, collection: string, paging: PageOptions & { reverse?: boolean }): Promise<Page<{ uri: string; cid: string; value: T }>> { const identity = await resolveIdentity(repo); return await paginate(paging, async (cursor, limit) => { const page = await xrpc<{ records: { uri: string; cid: string; value: T }[]; cursor?: string }>(identity.pds, 'com.atproto.repo.listRecords', { params: { repo: identity.did, collection, limit, cursor, ...(paging.reverse ? { reverse: true } : {}) }, }); return { items: page.records, cursor: page.cursor }; }); }
async uploadBlob(bytes: Uint8Array, mimeType: string): Promise<BlobRef> { const session = await this.session('uploading'); const { blob } = await session.call<{ blob: BlobRef }>('com.atproto.repo.uploadBlob', { input: bytes, encoding: mimeType }); return blob; }}
// --------------------------------------------------------------- at:// URIs ---
export interface AtUri { repo: string; collection?: string | undefined; rkey?: string | undefined;}
export function parseAtUri(uri: string): AtUri { const match = /^at:\/\/([^/?#]+)(?:\/([^/?#]+)(?:\/([^/?#]+))?)?\/?$/.exec(uri.trim()); if (!match) throw new UsageError(`'${uri}' is not an at:// URI`); return { repo: decodeURIComponent(match[1]!), collection: match[2], rkey: match[3] };}
export function atUri(repo: string, collection: string, rkey: string): string { return `at://${repo}/${collection}/${rkey}`;}
// -------------------------------------------------------------- pagination ---
/** * Fetch pages until `limit` items are in hand (default 25), or every page with * `all`. Returns the items and the cursor to continue from. * * With `since` and `dateOf`, items older than `since` are left out, and paging * stops at the first page with nothing newer in it: listings come newest * first, so what follows it is older still. That holds for timelines, * notifications and messages; an algorithmic feed is only roughly in order, * so a whole page older than `since` is taken as the end there too. */export async function paginate<T>(options: PageOptions, fetchPage: (cursor: string | undefined, limit: number) => Promise<Page<T>>, pageMax = 100, dateOf?: (item: T) => string | undefined): Promise<Page<T>> { const wanted = options.all ? Infinity : Math.max(1, Math.floor(options.limit ?? 25)); const since = dateOf ? options.since : undefined; const newer = (item: T) => { const time = Date.parse(dateOf!(item) ?? ''); return Number.isNaN(time) || time > since!; }; const items: T[] = []; let cursor = options.cursor; for (;;) { // With a cut-off, ask for whole pages: how many will be new is not known. const page = await fetchPage(cursor, since === undefined ? Math.min(pageMax, wanted - items.length) : pageMax); const kept = since === undefined ? page.items : page.items.filter(newer); items.push(...kept); cursor = page.items.length ? page.cursor : undefined; if (since !== undefined && page.items.length && !kept.length) { cursor = undefined; break; } if (!cursor || items.length >= wanted) break; } return { items: items.slice(0, wanted === Infinity ? undefined : wanted), cursor };}