Experimental Bluesky client for agents
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421import { afterEach, describe, expect, test } from 'bun:test';import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';import { tmpdir } from 'node:os';import { join } from 'node:path';import { runCli } from '../src/cli.ts';import type { FeedSnapshot, PostNode } from '../src/model.ts';import { ReadingStore } from '../src/store.ts';
const VIEWER_DID = 'did:plc:ewvi7nxzyoun6zhxrhs64oiz';const OTHER_VIEWER_DID = 'did:plc:js47frzswl2ezb6v235hlcz2';const AUTHOR_DID = 'did:plc:ragtjsm2j2vknwkz3zp4oxrd';const REPOSTER_DID = 'did:plc:yckmmocpnqkrdwkp2q5rdipl';const CID = 'bafyreigh2akiscaildcw453k5p2q5w2dmqnj4dnrz3llrqmk5f3x7tdpiy';const PDS = 'https://pds.example';const POST_ONE = `at://${AUTHOR_DID}/app.bsky.feed.post/one`;const POST_TWO = `at://${AUTHOR_DID}/app.bsky.feed.post/two`;const POST_THREE = `at://${AUTHOR_DID}/app.bsky.feed.post/three`;const originalFetch = globalThis.fetch;const originalLog = console.log;const temporaryDirectories: string[] = [];
type FetchResponder = (request: Request) => Response | Promise<Response>;
interface AuthFixture { path: string; identifier: string; password: string; accessValue: string; refreshValue: string;}
function temporaryDirectory(): string { const directory = mkdtempSync(join(tmpdir(), 'perch-feed-cli-')); temporaryDirectories.push(directory); return directory;}
function authFixture(): AuthFixture { const directory = temporaryDirectory(); const fixture = { path: join(directory, 'mcp.json'), identifier: `fixture-${crypto.randomUUID()}.invalid`, password: `fixture-${crypto.randomUUID()}`, accessValue: `fixture-${crypto.randomUUID()}`, refreshValue: `fixture-${crypto.randomUUID()}`, }; writeFileSync(fixture.path, JSON.stringify({ mcpServers: { atproto: { env: { BSKY_IDENTIFIER: fixture.identifier, BSKY_APP_PASSWORD: fixture.password, }, }, }, }), { mode: 0o600 }); return fixture;}
function apiPost(uri: string, text: string): Record<string, unknown> { return { $type: 'app.bsky.feed.defs#postView', uri, cid: CID, author: { did: AUTHOR_DID, handle: 'author.test', displayName: 'Author' }, record: { $type: 'app.bsky.feed.post', text, createdAt: '2026-09-05T10:00:00.000Z', }, indexedAt: '2026-09-05T10:00:01.000Z', replyCount: 0, likeCount: 0, repostCount: 0, quoteCount: 0, };}
function jsonResponse(data: unknown, status = 200): Response { return new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json' }, });}
function installFetch(responder: FetchResponder): Request[] { const requests: Request[] = []; globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { const request = input instanceof Request && init === undefined ? input : new Request(input, init); requests.push(request.clone()); return responder(request); }) as typeof fetch; return requests;}
function sessionResponse(auth: AuthFixture, viewerDid = VIEWER_DID): Response { return jsonResponse({ did: viewerDid, handle: 'viewer.test', accessJwt: auth.accessValue, refreshJwt: auth.refreshValue, active: true, });}
function authFlags(auth: AuthFixture): string[] { return ['--auth', '--auth-config', auth.path, '--pds', PDS];}
async function runJson(database: string, arguments_: string[]): Promise<any> { const lines: string[] = []; console.log = (...values: unknown[]) => lines.push(values.map(String).join(' ')); try { await runCli([...arguments_, '--db', database, '--json']); } finally { console.log = originalLog; } return JSON.parse(lines.join('\n'));}
function storedFeed(source: FeedSnapshot['source'], cursor?: string): FeedSnapshot { const node: PostNode = { uri: POST_ONE, availability: 'available', author: { did: AUTHOR_DID, handle: 'author.test', displayName: 'Author' }, text: 'stored feed', createdAt: '2026-09-05T10:00:00.000Z', indexedAt: '2026-09-05T10:00:01.000Z', replies: [], repliesFetched: false, facets: [], media: [], quotes: [], cards: [], labels: [], unsupported: [], counts: {}, }; return { kind: 'feed', feed: 'following', fetchedAt: '2026-09-05T10:01:00.000Z', source, entries: [{ id: '1', postUri: POST_ONE }], nodes: [node], limit: 20, ...(cursor !== undefined ? { cursor } : {}), maxNodes: 600, locallyTruncated: false, warnings: [], };}
afterEach(() => { globalThis.fetch = originalFetch; console.log = originalLog; for (const directory of temporaryDirectories.splice(0)) rmSync(directory, { recursive: true, force: true });});
describe('following-feed CLI navigation', () => { test('requires explicit authentication and a bounded limit before any request or session movement', async () => { const directory = temporaryDirectory(); const database = join(directory, 'reader.sqlite'); const requests = installFetch(() => jsonResponse({ error: 'UnexpectedRequest' }, 500));
await expect(runCli(['following', '--db', database, '--json'])) .rejects.toThrow('following requires explicit --auth.'); await expect(runCli(['following', '--auth', '--limit', '0', '--db', database, '--json'])) .rejects.toThrow('--limit must be an integer from 1 to 100.'); await expect(runCli(['following', '--auth', '--limit', '101', '--db', database, '--json'])) .rejects.toThrow('--limit must be an integer from 1 to 100.'); expect(requests).toHaveLength(0);
const store = new ReadingStore(database); expect(store.sessions()).toEqual([]); store.close(); });
test('stores separate pages, opens an occurrence encounter, and restores exact local state', async () => { const directory = temporaryDirectory(); const database = join(directory, 'reader.sqlite'); const auth = authFixture(); let newestReads = 0; const requests = installFetch((request) => { const url = new URL(request.url); if (url.pathname.endsWith('/com.atproto.server.createSession')) return sessionResponse(auth); if (url.pathname.endsWith('/app.bsky.feed.getTimeline')) { const cursor = url.searchParams.get('cursor'); if (cursor === 'cursor-one') { return jsonResponse({ cursor: 'cursor-two', feed: [{ post: apiPost(POST_TWO, 'second page'), reason: { $type: 'app.bsky.feed.defs#reasonRepost', by: { did: REPOSTER_DID, handle: 'reposter.test', displayName: 'Reposter' }, indexedAt: '2026-09-05T10:05:00.000Z', }, }], }); } newestReads += 1; return newestReads === 1 ? jsonResponse({ cursor: 'cursor-one', feed: [{ post: apiPost(POST_ONE, 'first page') }] }) : jsonResponse({ cursor: 'cursor-refresh', feed: [{ post: apiPost(POST_THREE, 'refreshed page') }] }); } if (url.pathname.endsWith('/app.bsky.feed.getPostThread')) { const uri = url.searchParams.get('uri') ?? POST_TWO; return jsonResponse({ thread: { $type: 'app.bsky.feed.defs#threadViewPost', post: apiPost(uri, 'opened thread'), replies: [], }, }); } return jsonResponse({ error: 'UnexpectedRequest', message: url.pathname }, 500); });
const first = await runJson(database, ['following', ...authFlags(auth), '--limit', '2']); expect(first.id).toBe('v1'); expect(first.snapshot).toMatchObject({ kind: 'feed', limit: 2, cursor: 'cursor-one', source: { service: PDS, mode: 'authenticated', viewerDid: VIEWER_DID }, }); expect(JSON.stringify(first)).not.toContain(auth.password); expect(JSON.stringify(first)).not.toContain(auth.accessValue);
const firstOccurrenceRef = Object.keys(first.refs).find((ref) => ref.endsWith('-e1'))!; const beforePeekRequests = requests.length; expect(await runJson(database, ['peek', firstOccurrenceRef])).toEqual(first.refs[firstOccurrenceRef]); expect(requests).toHaveLength(beforePeekRequests);
const second = await runJson(database, ['more', ...authFlags(auth), '--limit', '1']); expect(second.id).toBe('v2'); expect(second.snapshot).toMatchObject({ kind: 'feed', limit: 1, requestCursor: 'cursor-one', cursor: 'cursor-two', }); const secondOccurrenceRef = Object.keys(second.refs).find((ref) => ref.endsWith('-e1'))!; const opened = await runJson(database, [ 'open', secondOccurrenceRef, ...authFlags(auth), '--depth', '0', '--parents', '0', '--max-nodes', '10', ]); expect(opened.snapshot).toMatchObject({ kind: 'thread', focusUri: POST_TWO, encounter: { feedViewId: 'v2', occurrenceId: '1', postUri: POST_TWO, reason: { kind: 'repost', by: { did: REPOSTER_DID, handle: 'reposter.test', displayName: 'Reposter' }, indexedAt: '2026-09-05T10:05:00.000Z', }, }, });
const beforeBackRequests = requests.length; expect(await runJson(database, ['back'])).toEqual(second); expect(await runJson(database, ['peek', firstOccurrenceRef])).toEqual(first.refs[firstOccurrenceRef]); expect(requests).toHaveLength(beforeBackRequests);
const refreshed = await runJson(database, ['refresh', ...authFlags(auth), '--limit', '1']); expect(refreshed.id).toBe('v4'); expect(refreshed.snapshot).toMatchObject({ kind: 'feed', limit: 1, cursor: 'cursor-refresh' }); expect(refreshed.snapshot.requestCursor).toBeUndefined(); const timelineUrls = requests .map((request) => new URL(request.url)) .filter((url) => url.pathname.endsWith('/app.bsky.feed.getTimeline')); expect(timelineUrls.map((url) => ({ limit: url.searchParams.get('limit'), cursor: url.searchParams.get('cursor') }))).toEqual([ { limit: '2', cursor: null }, { limit: '1', cursor: 'cursor-one' }, { limit: '1', cursor: null }, ]);
const store = new ReadingStore(database); expect(store.position('main').session).toEqual({ name: 'main', viewId: 'v4', history: ['v1', 'v2'] }); expect(store.getView('v1')).toEqual(first); expect(store.getView('v2')).toEqual(second); store.close(); });
test('reports end, cursor-loop, and network failures without moving the original feed', async () => { const directory = temporaryDirectory(); const noCursorDatabase = join(directory, 'no-cursor.sqlite'); const auth = authFixture(); const noCursorRequests = installFetch((request) => { const url = new URL(request.url); if (url.pathname.endsWith('/com.atproto.server.createSession')) return sessionResponse(auth); if (url.pathname.endsWith('/app.bsky.feed.getTimeline')) { return jsonResponse({ feed: [{ post: apiPost(POST_ONE, 'only page') }] }); } return jsonResponse({ error: 'UnexpectedRequest' }, 500); }); const endPage = await runJson(noCursorDatabase, ['following', ...authFlags(auth)]); const requestsAtEnd = noCursorRequests.length; await expect(runCli(['more', ...authFlags(auth), '--db', noCursorDatabase, '--json'])) .rejects.toThrow('Current feed page has no next cursor; continuation is unavailable.'); expect(noCursorRequests).toHaveLength(requestsAtEnd); const endStore = new ReadingStore(noCursorDatabase); expect(endStore.current('main')).toEqual(endPage); expect(endStore.getView('v2')).toBeNull(); endStore.close();
globalThis.fetch = originalFetch; const loopDatabase = join(directory, 'loop.sqlite'); let continuationReads = 0; installFetch((request) => { const url = new URL(request.url); if (url.pathname.endsWith('/com.atproto.server.createSession')) return sessionResponse(auth); if (url.pathname.endsWith('/app.bsky.feed.getTimeline')) { if (!url.searchParams.has('cursor')) { return jsonResponse({ cursor: 'loop-cursor', feed: [{ post: apiPost(POST_ONE, 'loop page') }] }); } continuationReads += 1; return continuationReads === 1 ? jsonResponse({ cursor: 'loop-cursor', feed: [{ post: apiPost(POST_TWO, 'looped page') }] }) : jsonResponse({ error: 'UpstreamUnavailable', message: 'temporary failure' }, 503); } return jsonResponse({ error: 'UnexpectedRequest' }, 500); }); const loopPage = await runJson(loopDatabase, ['following', ...authFlags(auth)]); await expect(runCli(['more', ...authFlags(auth), '--db', loopDatabase, '--json'])) .rejects.toThrow('refusing a silent loop'); await expect(runCli(['more', ...authFlags(auth), '--db', loopDatabase, '--json'])) .rejects.toThrow('Fetching following feed failed'); const loopStore = new ReadingStore(loopDatabase); expect(loopStore.current('main')).toEqual(loopPage); expect(loopStore.position('main').session.history).toEqual([]); expect(loopStore.getView('v2')).toBeNull(); loopStore.close(); });
test('rejects a changed service or viewer after login but before cursor timeline retrieval', async () => { const directory = temporaryDirectory(); const auth = authFixture(); const requests = installFetch((request) => { const url = new URL(request.url); if (url.pathname.endsWith('/com.atproto.server.createSession')) return sessionResponse(auth, OTHER_VIEWER_DID); return jsonResponse({ error: 'TimelineMustNotRun', message: url.pathname }, 500); });
const serviceDatabase = join(directory, 'service.sqlite'); const serviceStore = new ReadingStore(serviceDatabase); const serviceView = serviceStore.commit('main', storedFeed({ service: 'https://stored.example', mode: 'authenticated', viewerDid: OTHER_VIEWER_DID, }, 'stored-cursor'), serviceStore.position('main').revision); serviceStore.close(); await expect(runCli(['more', ...authFlags(auth), '--db', serviceDatabase, '--json'])) .rejects.toThrow('Feed source service changed');
const viewerDatabase = join(directory, 'viewer.sqlite'); const viewerStore = new ReadingStore(viewerDatabase); const viewerView = viewerStore.commit('main', storedFeed({ service: PDS, mode: 'authenticated', viewerDid: VIEWER_DID, }, 'stored-cursor'), viewerStore.position('main').revision); viewerStore.close(); await expect(runCli(['more', ...authFlags(auth), '--db', viewerDatabase, '--json'])) .rejects.toThrow('Feed viewer changed');
expect(requests.filter((request) => new URL(request.url).pathname.endsWith('/app.bsky.feed.getTimeline'))).toHaveLength(0); const reopenedService = new ReadingStore(serviceDatabase); const reopenedViewer = new ReadingStore(viewerDatabase); expect(reopenedService.current('main')).toEqual(serviceView); expect(reopenedViewer.current('main')).toEqual(viewerView); expect(reopenedService.getView('v2')).toBeNull(); expect(reopenedViewer.getView('v2')).toBeNull(); reopenedService.close(); reopenedViewer.close(); });
test('refuses author-feed reference DID substitution before fetching or moving the session', async () => { const directory = temporaryDirectory(); const database = join(directory, 'reader.sqlite'); const store = new ReadingStore(database); const profileView = store.commit('main', { kind: 'profile', requested: AUTHOR_DID, fetchedAt: '2026-09-05T10:00:00.000Z', source: { service: 'https://public.api.bsky.app', mode: 'public' }, author: { did: AUTHOR_DID, handle: 'author.test', displayName: 'Author' }, counts: {}, labels: [], enrichments: [], warnings: [], }, store.position('main').revision); const authorFeedReference = Object.entries(profileView.refs).find(([, target]) => ( target.kind === 'author-feed' ))?.[0]; expect(authorFeedReference).toBeDefined(); store.close();
const requests = installFetch((request) => { const url = new URL(request.url); if (url.pathname.endsWith('/app.bsky.actor.getProfile')) { return jsonResponse({ did: OTHER_VIEWER_DID, handle: 'substituted.test' }); } return jsonResponse({ error: 'AuthorFeedMustNotRun', message: url.pathname }, 500); }); await expect(runCli(['open', authorFeedReference!, '--db', database, '--json'])) .rejects.toThrow('refusing target substitution'); expect(requests.filter((request) => ( new URL(request.url).pathname.endsWith('/app.bsky.feed.getAuthorFeed') ))).toHaveLength(0);
const reopened = new ReadingStore(database); expect(reopened.current('main')).toEqual(profileView); expect(reopened.getView('v2')).toBeNull(); reopened.close(); });});