Experimental Bluesky client for agents
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646#!/usr/bin/env bun
import { homedir } from 'node:os';import { resolve } from 'node:path';import { createReader, createSocialClient } from './atproto.ts';import type { FeedOccurrence, FeedSnapshot, ReaderOptions, ReadingView, Snapshot, Target, TargetSnapshot, ThreadOptions,} from './model.ts';import { loadDraftFile } from './social.ts';import type { LoadedDraft, PublicationReceipt, PublicationTargetInput, SocialMutationReceipt,} from './social.ts';import { captureView, renderText } from './render.ts';import { ReadingStore } from './store.ts';
const HELP = `Perch — a persistent Bluesky reader and explicit social publisher
Usage: perch following --auth [--limit <1..100>] [options] perch profile <actor | profile-ref> [--auth] [options] perch author-feed <actor | profile-ref> [--auth] [--limit <1..100>] [options] perch more [--auth] [--limit <1..100>] [options] perch open <post-url | at-uri | ref> [options] perch peek <ref> [options] perch show [options] perch back [options] perch refresh [options] perch fork <name> [options] perch sessions [options] perch render --out <directory> [options] perch prepare <draft.json> --auth [options] perch publish <publication-id> --auth [options] perch receipt <publication-id> [options] perch reconcile <publication-id> --auth [--retry-absent] [options] perch follow <actor | profile-ref> --auth [options] perch unfollow <actor | profile-ref> --auth [options] perch block <actor | profile-ref> --auth [options]
Common options: --session <name> Session to use (default: main) --db <path> SQLite store (default: PERCH_DB or ~/.local/share/perch/reader.sqlite) --json Emit exact JSON instead of readable text --auth Explicitly authenticate this network operation --auth-config <path> Auth config (default with --auth: ~/liv/.omp/mcp.json) --pds <url> PDS for --auth (default: https://pds.mlf.one) --depth <0..100> Reply depth for a thread read (default: 3) --parents <0..100> Parent height for a thread read (default: 40) --max-nodes <1..200> Local thread node limit (default: 60) --limit <1..100> Feed page size (default: 20) --out <directory> Output directory for render --retry-absent With reconcile only, reset an exact absent rkey for a later explicit publish --help Show this help
Draft JSON is {posts:[{text,quote?,images?:[{path,alt}],external?:{uri,title,description,thumb?:{path,alt}},langs?:string[]}],replyTo?}.Relative attachment paths resolve from the draft file; absolute paths are accepted.Attachment bytes are copied into the SQLite plan. References are globallyview-qualified (for example v12-p1).peek, show, sessions, back, fork, receipt, and render are local-only.`;
type ValueFlag = 'session' | 'db' | 'auth-config' | 'pds' | 'depth' | 'parents' | 'max-nodes' | 'limit' | 'out';
interface ParsedArguments { command?: string; positionals: string[]; help: boolean; json: boolean; auth: boolean; retryAbsent: boolean; values: Partial<Record<ValueFlag, string>>;}
function expandHome(path: string): string { if (path === '~') return homedir(); if (path.startsWith('~/')) return resolve(homedir(), path.slice(2)); return resolve(path);}
function parseArguments(argv: string[]): ParsedArguments { const parsed: ParsedArguments = { positionals: [], help: false, json: false, auth: false, retryAbsent: false, values: {}, }; const valueFlags: Record<ValueFlag, true> = { session: true, db: true, 'auth-config': true, pds: true, depth: true, parents: true, 'max-nodes': true, limit: true, out: true, };
for (let index = 0; index < argv.length; index += 1) { const argument = argv[index]; if (argument === '--help' || argument === '-h') { parsed.help = true; } else if (argument === '--json') { parsed.json = true; } else if (argument === '--auth') { parsed.auth = true; } else if (argument === '--retry-absent') { parsed.retryAbsent = true; } else if (argument.startsWith('--')) { const flag = argument.slice(2) as ValueFlag; if (valueFlags[flag] !== true) throw new Error(`Unknown option: ${argument}`); const value = argv[index + 1]; if (!value || value.startsWith('--')) throw new Error(`Option ${argument} requires a value.`); parsed.values[flag] = value; index += 1; } else if (!parsed.command) { parsed.command = argument; } else { parsed.positionals.push(argument); } } return parsed;}
function parseBoundedInteger(value: string | undefined, flag: string, minimum: number, maximum: number): number | undefined { if (value === undefined) return undefined; if (!/^\d+$/u.test(value)) throw new Error(`${flag} must be an integer from ${minimum} to ${maximum}.`); const parsed = Number(value); if (parsed < minimum || parsed > maximum) { throw new Error(`${flag} must be an integer from ${minimum} to ${maximum}.`); } return parsed;}
function threadOptions(arguments_: ParsedArguments): ThreadOptions { return { depth: parseBoundedInteger(arguments_.values.depth, '--depth', 0, 100), parentHeight: parseBoundedInteger(arguments_.values.parents, '--parents', 0, 100), maxNodes: parseBoundedInteger(arguments_.values['max-nodes'], '--max-nodes', 1, 200), };}
function feedLimit(arguments_: ParsedArguments): number | undefined { return parseBoundedInteger(arguments_.values.limit, '--limit', 1, 100);}
function requireFeedAuthentication(arguments_: ParsedArguments, operation: string): void { if (!arguments_.auth) throw new Error(`${operation} requires explicit --auth.`);}
function occurrenceFrom(view: ReadingView, target: Target): FeedOccurrence { if (target.kind !== 'occurrence' || view.snapshot.kind !== 'feed') { throw new Error(`Reference ${target.label} is not a feed occurrence.`); } const occurrence = view.snapshot.entries.find((entry) => entry.id === target.value); if (!occurrence) throw new Error(`Feed occurrence ${target.value} is missing from stored view ${view.id}.`); return occurrence;}
function requireMatchingFeedSource(feed: FeedSnapshot, source: Snapshot['source']): void { if (feed.source.mode !== source.mode) { throw new Error(`Feed source mode changed from ${feed.source.mode} to ${source.mode}; refusing network navigation.`); } if (feed.source.service !== source.service) { throw new Error(`Feed source service changed from ${feed.source.service} to ${source.service}; refusing network navigation.`); } if (feed.source.mode === 'authenticated') { if (source.mode !== 'authenticated' || !feed.source.viewerDid || !source.viewerDid) { throw new Error('Authenticated feed continuation requires both stored and current viewer DIDs.'); } if (feed.source.viewerDid !== source.viewerDid) { throw new Error('Feed viewer changed; refusing network navigation.'); } } if (feed.feed === 'author' && !feed.actor?.did) { throw new Error('Stored author feed omitted its canonical actor DID.'); }}
function readerOptions(arguments_: ParsedArguments): ReaderOptions { if (!arguments_.auth) { if (arguments_.values['auth-config'] || arguments_.values.pds) { throw new Error('--auth-config and --pds require explicit --auth.'); } return {}; } return { authConfig: expandHome(arguments_.values['auth-config'] ?? '~/liv/.omp/mcp.json'), pds: arguments_.values.pds ?? 'https://pds.mlf.one', };}
function emitView(view: ReadingView, json: boolean): void { if (json) console.log(JSON.stringify(view, null, 2)); else console.log(renderText(view));}
function requireArity(arguments_: ParsedArguments, count: number, syntax: string): void { if (arguments_.positionals.length !== count) throw new Error(`Usage: perch ${syntax}`);}
function targetInspection(target: Target, source: Snapshot['source']): TargetSnapshot { const noteByKind: Record<'link' | 'tag' | 'media', string> = { link: 'External link target only; no article content was fetched.', tag: 'Tag target only; no feed or search was fetched.', media: 'Media target only; no playback or external content was fetched.', }; if ( target.kind === 'post' || target.kind === 'profile' || target.kind === 'occurrence' || target.kind === 'author-feed' ) { throw new Error(`Target ${target.label} requires a network read.`); } return { kind: 'target', fetchedAt: new Date().toISOString(), source, target, note: noteByKind[target.kind], };}
function requireCurrent(view: ReadingView | null, session: string): ReadingView { if (!view) throw new Error(`Session "${session}" has no current view. Use "perch open <post>" first.`); return view;}
function publicationTarget(store: ReadingStore, input: string): PublicationTargetInput { if (!/^v\d+-/u.test(input)) return { input }; const resolvedReference = store.resolveRef(input); if (resolvedReference.target.kind !== 'post') { throw new Error(`Stored reference ${input} is not a post target.`); } const snapshot = resolvedReference.view.snapshot; const node = snapshot.kind === 'thread' || snapshot.kind === 'feed' ? snapshot.nodes.find((candidate) => candidate.uri === resolvedReference.target.value) : undefined; if (!node || node.availability !== 'available' || !node.cid) { throw new Error(`Stored post reference ${input} is missing, blocked, malformed, or lacks a strong CID.`); } return { input, stored: { ref: input, uri: node.uri, cid: node.cid, }, };}
function resolveDraftReferences(store: ReadingStore, draft: LoadedDraft): LoadedDraft { return { ...draft, posts: draft.posts.map((post) => ({ ...post, ...(post.quote !== undefined ? { quote: publicationTarget(store, post.quote.input) } : {}), })), ...(draft.replyTo !== undefined ? { replyTo: publicationTarget(store, draft.replyTo.input) } : {}), };}
function socialTarget(store: ReadingStore, input: string): string { if (!/^v\d+-/u.test(input)) return input; const resolvedReference = store.resolveRef(input); if (resolvedReference.target.kind !== 'profile') { throw new Error(`Stored reference ${input} is not a profile target.`); } return resolvedReference.target.value;}
function emitPublicationReceipt(receipt: PublicationReceipt, json: boolean): void { if (json) { console.log(JSON.stringify(receipt, null, 2)); return; } console.log(`Publication ${receipt.id}: ${receipt.status}`); console.log(`Owner: ${receipt.plan.ownerDid}`); console.log(`Service: ${receipt.plan.service}`); for (const post of receipt.posts) { const confirmed = post.uri && post.cid ? ` ${post.uri} (${post.cid})` : ''; console.log(` ${post.index}. ${post.state} rkey=${post.rkey}${confirmed}`); if (post.lastError) console.log(` ${post.lastError}`); }}
function emitSocialReceipt(receipt: SocialMutationReceipt, json: boolean): void { if (json) console.log(JSON.stringify(receipt, null, 2)); else { console.log(`${receipt.operation}: ${receipt.state}`); console.log(`Target: ${receipt.targetDid}${receipt.targetHandle ? ` (@${receipt.targetHandle})` : ''}`); if (receipt.recordUri) console.log(`Record: ${receipt.recordUri}`); }}
function sanitizedMessage(error: unknown): string { const message = error instanceof Error ? error.message : String(error); return message .replace(/(https?:\/\/)[^\s/@:]+:[^\s/@]+@/giu, '$1[redacted]@') .replace(/((?:password|token|secret|authorization)\s*[:=]\s*)[^\s,;]+/giu, '$1[redacted]') .replace(/(BSKY_APP_PASSWORD\s*[:=]\s*)[^\s,;]+/giu, '$1[redacted]');}
export async function runCli(argv: string[] = Bun.argv.slice(2)): Promise<void> { const arguments_ = parseArguments(argv); if (arguments_.help || !arguments_.command) { console.log(HELP); return; }
const knownCommands: Record<string, true> = { following: true, profile: true, 'author-feed': true, more: true, open: true, peek: true, show: true, back: true, refresh: true, fork: true, sessions: true, render: true, prepare: true, publish: true, receipt: true, reconcile: true, follow: true, unfollow: true, block: true, }; if (knownCommands[arguments_.command] !== true) throw new Error(`Unknown command: ${arguments_.command}\n\n${HELP}`); if (arguments_.retryAbsent && arguments_.command !== 'reconcile') { throw new Error('--retry-absent is only valid with reconcile.'); }
const sessionName = arguments_.values.session ?? 'main'; const databasePath = expandHome(arguments_.values.db ?? Bun.env.PERCH_DB ?? '~/.local/share/perch/reader.sqlite'); const store = new ReadingStore(databasePath); try { switch (arguments_.command) { case 'following': { requireArity(arguments_, 0, 'following --auth [--limit <1..100>] [options]'); requireFeedAuthentication(arguments_, 'following'); const limit = feedLimit(arguments_); const baseline = store.position(sessionName); const reader = await createReader(readerOptions(arguments_)); const snapshot = await reader.following({ limit }); emitView(store.commit(sessionName, snapshot, baseline.revision), arguments_.json); break; } case 'profile': { requireArity(arguments_, 1, 'profile <actor | profile-ref> [--auth] [options]'); const target = socialTarget(store, arguments_.positionals[0]); const baseline = store.position(sessionName); const reader = await createReader(readerOptions(arguments_)); const snapshot = await reader.profile(target); emitView(store.commit(sessionName, snapshot, baseline.revision), arguments_.json); break; } case 'author-feed': { requireArity(arguments_, 1, 'author-feed <actor | profile-ref> [--auth] [--limit <1..100>] [options]'); const target = socialTarget(store, arguments_.positionals[0]); const limit = feedLimit(arguments_); const baseline = store.position(sessionName); const reader = await createReader(readerOptions(arguments_)); const snapshot = await reader.authorFeed(target, { limit }); emitView(store.commit(sessionName, snapshot, baseline.revision), arguments_.json); break; } case 'more': { requireArity(arguments_, 0, 'more [--auth] [--limit <1..100>] [options]'); const requestedLimit = feedLimit(arguments_); const baseline = store.position(sessionName); const current = requireCurrent(baseline.view, sessionName); if (current.snapshot.kind !== 'feed') throw new Error('more is only available from a feed view.'); if (current.snapshot.feed === 'following') requireFeedAuthentication(arguments_, 'more on a following feed'); const cursor = current.snapshot.cursor; if (cursor === undefined || cursor.length === 0) { throw new Error('Current feed page has no next cursor; continuation is unavailable.'); } const reader = await createReader(readerOptions(arguments_)); requireMatchingFeedSource(current.snapshot, reader.source); const options = { limit: requestedLimit ?? current.snapshot.limit, cursor }; const snapshot = current.snapshot.feed === 'following' ? await reader.following(options) : await reader.authorFeed(current.snapshot.actor!.did, options); if (snapshot.feed !== current.snapshot.feed) throw new Error('Feed continuation changed feed kind.'); if ( current.snapshot.feed === 'author' && snapshot.actor?.did !== current.snapshot.actor?.did ) throw new Error('Author-feed continuation changed its canonical actor DID.'); emitView(store.commit(sessionName, snapshot, baseline.revision), arguments_.json); break; } case 'open': { requireArity(arguments_, 1, 'open <post-url | at-uri | ref> [options]'); const input = arguments_.positionals[0]; const baseline = store.position(sessionName); let snapshot: Snapshot; if (/^v\d+-/u.test(input)) { const resolvedReference = store.resolveRef(input); if (resolvedReference.target.kind === 'post') { const reader = await createReader(readerOptions(arguments_)); snapshot = await reader.thread(resolvedReference.target.value, threadOptions(arguments_)); } else if (resolvedReference.target.kind === 'profile') { const reader = await createReader(readerOptions(arguments_)); snapshot = await reader.profile(resolvedReference.target.value); } else if (resolvedReference.target.kind === 'author-feed') { const reader = await createReader(readerOptions(arguments_)); snapshot = await reader.authorFeed(resolvedReference.target.value, { limit: feedLimit(arguments_) }); if (snapshot.feed !== 'author' || snapshot.actor?.did !== resolvedReference.target.value) { throw new Error('Author-feed reference changed its canonical actor DID.'); } } else if (resolvedReference.target.kind === 'occurrence') { const occurrence = occurrenceFrom(resolvedReference.view, resolvedReference.target); const reader = await createReader(readerOptions(arguments_)); const thread = await reader.thread(occurrence.postUri, threadOptions(arguments_)); snapshot = { ...thread, encounter: { feedViewId: resolvedReference.view.id, occurrenceId: occurrence.id, postUri: occurrence.postUri, ...(occurrence.reason !== undefined ? { reason: occurrence.reason } : {}), }, }; } else { snapshot = targetInspection(resolvedReference.target, resolvedReference.view.snapshot.source); } } else { const reader = await createReader(readerOptions(arguments_)); snapshot = await reader.thread(input, threadOptions(arguments_)); } emitView(store.commit(sessionName, snapshot, baseline.revision), arguments_.json); break; } case 'peek': { requireArity(arguments_, 1, 'peek <ref> [options]'); const resolvedReference = store.resolveRef(arguments_.positionals[0]); console.log(JSON.stringify(resolvedReference.target, null, 2)); break; } case 'show': { requireArity(arguments_, 0, 'show [options]'); emitView(requireCurrent(store.current(sessionName), sessionName), arguments_.json); break; } case 'back': { requireArity(arguments_, 0, 'back [options]'); const baseline = store.position(sessionName); requireCurrent(baseline.view, sessionName); emitView(store.back(sessionName, baseline.revision), arguments_.json); break; } case 'refresh': { requireArity(arguments_, 0, 'refresh [options]'); const baseline = store.position(sessionName); const current = requireCurrent(baseline.view, sessionName); let snapshot: Snapshot; if (current.snapshot.kind === 'thread') { const requestedOptions = threadOptions(arguments_); const reader = await createReader(readerOptions(arguments_)); const refreshed = await reader.thread(current.snapshot.focusUri, { depth: requestedOptions.depth ?? current.snapshot.limits.depth, parentHeight: requestedOptions.parentHeight ?? current.snapshot.limits.parentHeight, maxNodes: requestedOptions.maxNodes ?? current.snapshot.limits.maxNodes, }); snapshot = current.snapshot.encounter ? { ...refreshed, encounter: current.snapshot.encounter } : refreshed; } else if (current.snapshot.kind === 'profile') { const reader = await createReader(readerOptions(arguments_)); snapshot = await reader.profile(current.snapshot.author.did); } else if (current.snapshot.kind === 'feed') { if (current.snapshot.feed === 'following') { requireFeedAuthentication(arguments_, 'refreshing a following feed'); } const requestedLimit = feedLimit(arguments_); const reader = await createReader(readerOptions(arguments_)); requireMatchingFeedSource(current.snapshot, reader.source); snapshot = current.snapshot.feed === 'following' ? await reader.following({ limit: requestedLimit ?? current.snapshot.limit }) : await reader.authorFeed(current.snapshot.actor!.did, { limit: requestedLimit ?? current.snapshot.limit, }); if ( current.snapshot.feed === 'author' && snapshot.actor?.did !== current.snapshot.actor?.did ) throw new Error('Author-feed refresh changed its canonical actor DID.'); } else { snapshot = targetInspection(current.snapshot.target, current.snapshot.source); } emitView(store.commit(sessionName, snapshot, baseline.revision), arguments_.json); break; } case 'fork': { requireArity(arguments_, 1, 'fork <name> [options]'); const forked = store.fork(sessionName, arguments_.positionals[0]); emitView(requireCurrent(forked.view, arguments_.positionals[0]), arguments_.json); break; } case 'sessions': { requireArity(arguments_, 0, 'sessions [options]'); const sessions = store.sessions(); if (arguments_.json) console.log(JSON.stringify(sessions, null, 2)); else if (sessions.length === 0) console.log('No reading sessions.'); else { for (const session of sessions) { console.log(`${session.name}\t${session.viewId ?? '(empty)'}\t${session.history.length} back`); } } break; } case 'prepare': { requireArity(arguments_, 1, 'prepare <draft.json> --auth [options]'); requireFeedAuthentication(arguments_, 'prepare'); const loaded = await loadDraftFile(expandHome(arguments_.positionals[0])); const draft = resolveDraftReferences(store, loaded); const client = await createSocialClient(readerOptions(arguments_)); const prepared = await client.preparePublication(draft); emitPublicationReceipt(store.createPublication(prepared), arguments_.json); break; } case 'publish': { requireArity(arguments_, 1, 'publish <publication-id> --auth [options]'); requireFeedAuthentication(arguments_, 'publish'); const id = arguments_.positionals[0]; const client = await createSocialClient(readerOptions(arguments_)); let receipt = store.assertPublicationBinding(id, client.source); let failureReceiptEmitted = false; try { while (receipt.status !== 'complete') { const claim = store.claimPublicationPost(id, client.source); if (!claim) break; let dispatchRecorded = false; try { const result = await client.publishPost(claim, (record) => { store.beginPublicationDispatch(id, claim.post.index, claim.token, record); dispatchRecorded = true; }); receipt = store.confirmPublicationPost(id, claim.post.index, claim.token, result); } catch (error) { const message = sanitizedMessage(error); receipt = dispatchRecorded ? store.markPublicationUncertain(id, claim.post.index, claim.token, message) : store.releasePublicationAssembly(id, claim.post.index, claim.token, message); emitPublicationReceipt(receipt, arguments_.json); failureReceiptEmitted = true; throw new Error(`${message} Durable publication ${id} remains ${receipt.status}.`); } } } catch (error) { if (!failureReceiptEmitted) emitPublicationReceipt(store.publication(id), arguments_.json); throw error; } emitPublicationReceipt(store.publication(id), arguments_.json); break; } case 'receipt': { requireArity(arguments_, 1, 'receipt <publication-id> [options]'); emitPublicationReceipt(store.publication(arguments_.positionals[0]), arguments_.json); break; } case 'reconcile': { requireArity(arguments_, 1, 'reconcile <publication-id> --auth [--retry-absent] [options]'); requireFeedAuthentication(arguments_, 'reconcile'); const id = arguments_.positionals[0]; const client = await createSocialClient(readerOptions(arguments_)); let receipt = store.assertPublicationBinding(id, client.source); const uncertain = receipt.posts.filter((post) => (post.state === 'dispatching' || post.state === 'uncertain') && post.record !== undefined ); for (const post of uncertain) { try { const inspection = await client.inspectPublicationRecord( post.rkey, post.plannedUri, post.record!, ); receipt = store.reconcilePublicationPost(id, post.index, inspection, arguments_.retryAbsent); } catch (error) { emitPublicationReceipt(store.publication(id), arguments_.json); throw error; } } emitPublicationReceipt(receipt, arguments_.json); break; } case 'follow': case 'unfollow': case 'block': { requireArity(arguments_, 1, `${arguments_.command} <actor | profile-ref> --auth [options]`); requireFeedAuthentication(arguments_, arguments_.command); const target = socialTarget(store, arguments_.positionals[0]); const client = await createSocialClient(readerOptions(arguments_)); const receipt = arguments_.command === 'follow' ? await client.follow(target) : arguments_.command === 'unfollow' ? await client.unfollow(target) : await client.block(target); emitSocialReceipt(receipt, arguments_.json); break; } case 'render': { requireArity(arguments_, 0, 'render --out <directory> [options]'); if (!arguments_.values.out) throw new Error('render requires --out <directory>.'); const view = requireCurrent(store.current(sessionName), sessionName); const files = await captureView(view, { outputDir: expandHome(arguments_.values.out), browserExecutable: Bun.env.PERCH_BROWSER || undefined, }); if (arguments_.json) console.log(JSON.stringify(files, null, 2)); else { console.log(`HTML: ${files.html}`); console.log(`Text: ${files.text}`); console.log(`JSON: ${files.json}`); for (const image of files.images) console.log(`Image: ${image}`); } break; } } } finally { store.close(); }}
if (import.meta.main) { runCli().catch((error) => { console.error(`perch: ${sanitizedMessage(error)}`); process.exitCode = 1; });}