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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404import { test } from 'node:test';import * as assert from 'node:assert/strict';import { parse, bindArgs } from '../src/args.ts';import { GLOBAL_OPTIONS, namesOf, optionsOf, type CommandSpec } from '../src/command.ts';import { root } from '../src/commands/index.ts';import { detect, facets, graphemeLength, split, expandLinks } from '../src/richtext.ts';import { sniff, aspectRatio } from '../src/media.ts';import { actor, postLink } from '../src/refs.ts';import { complete, respond, splitLine, script } from '../src/completion.ts';import { helpJson, helpText } from '../src/help.ts';import { parseAtUri, paginate } from '../src/api.ts';import { UsageError } from '../src/errors.ts';import { jwtExpiry } from '../src/session.ts';
process.env['NO_COLOR'] = '1';
// ------------------------------------------------------------ the tree ---
function walk(command: CommandSpec, path: CommandSpec[] = [], visit: (path: CommandSpec[]) => void = () => {}): void { const here = [...path, command]; visit(here); for (const sub of command.subcommands ?? []) walk(sub, here, visit);}
test('every command has a summary, and runs or has subcommands', () => { walk(root, [], (path) => { const command = path[path.length - 1]!; assert.ok(command.summary, `${path.map((c) => c.name).join(' ')} has a summary`); assert.ok(command.run || command.subcommands?.length, `${command.name} does something`); if (command.default) assert.ok(command.subcommands?.some((sub) => sub.name === command.default), `${command.name}'s default exists`); });});
test('names, aliases and options never collide', () => { walk(root, [], (path) => { const command = path[path.length - 1]!; const names = (command.subcommands ?? []).flatMap(namesOf); assert.equal(new Set(names).size, names.length, `subcommand names of '${command.name}' are unique: ${names.join(' ')}`); const options = optionsOf(command); const longs = options.map((option) => option.name); assert.equal(new Set(longs).size, longs.length, `long options of '${command.name}' are unique`); const shorts = options.filter((option) => option.short).map((option) => option.short); assert.equal(new Set(shorts).size, shorts.length, `short options of '${command.name}' are unique: ${shorts.join(' ')}`); for (const option of options) { assert.ok(!longs.includes(`no-${option.name}`), `--no-${option.name} is not also an option of '${command.name}'`); } const args = command.args ?? []; args.forEach((arg, at) => { if (arg.variadic) assert.equal(at, args.length - 1, `only the last argument of '${command.name}' is variadic`); if (arg.required && at > 0) assert.ok(args[at - 1]!.required, `a required argument of '${command.name}' follows only required ones`); }); });});
test('help renders for every command, as text and as JSON', () => { walk(root, [], (path) => { const text = helpText(path); assert.match(text, /Usage/); const json = helpJson(path) as { command: string; usage: string }; assert.equal(json.command, ['atcli', ...path.slice(1).map((c) => c.name)].join(' ')); JSON.stringify(json); });});
// ------------------------------------------------------------- parsing ---
test('subcommands, options anywhere, and values in every spelling', () => { const parsed = parse(root, ['search', 'posts', '--sort=latest', 'hello', '-n', '5', 'world', '--json']); assert.deepEqual(parsed.path.map((c) => c.name), ['atcli', 'search', 'posts']); assert.equal(parsed.options['sort'], 'latest'); assert.equal(parsed.options['limit'], 5); assert.equal(parsed.options['json'], true); assert.deepEqual(parsed.positionals, ['hello', 'world']); assert.deepEqual(bindArgs(parsed.command, parsed.positionals), { query: ['hello', 'world'] });});
test('short options bundle and take attached values', () => { const parsed = parse(root, ['timeline', '-vn10']); assert.equal(parsed.options['verbose'], true); assert.equal(parsed.options['limit'], 10);});
test('--help anywhere asks for help on the deepest command, whatever else is wrong', () => { for (const argv of [['post', '--help'], ['post', '--bogus', '--help'], ['post', '--reply', '--help'], ['post', '-h', 'text']]) { const parsed = parse(root, argv); assert.equal(parsed.help, true, argv.join(' ')); assert.equal(parsed.command.name, 'post'); } assert.equal(parse(root, ['--help']).command, root); assert.equal(parse(root, ['login', '--help']).command.name, 'login');});
test('-- ends options', () => { const parsed = parse(root, ['post', '--', '--not-an-option', '-x']); assert.deepEqual(parsed.positionals, ['--not-an-option', '-x']);});
test('--no- turns booleans off, lists repeat, choices are checked', () => { const parsed = parse(root, ['post', 'x', '--no-card', '--image', 'a.png', '--image', 'b.png', '--label', 'nudity']); assert.equal(parsed.options['card'], false); assert.deepEqual(parsed.options['image'], ['a.png', 'b.png']); assert.throws(() => parse(root, ['post', 'x', '--label', 'nope']), UsageError); assert.throws(() => parse(root, ['post', 'x', '--no-reply']), UsageError);});
test('mistakes are usage errors that name the command', () => { assert.throws(() => parse(root, ['post', '--bogus']), /does not take --bogus/); assert.throws(() => parse(root, ['reply']), /needs <post>/); assert.throws(() => parse(root, ['timeline', 'extra']), /no arguments/); assert.throws(() => parse(root, ['nosuchcommand']), /no command named/); assert.throws(() => parse(root, ['timeline', '-n', 'many']), /takes a number/);});
test('a group runs its default subcommand, with or without arguments', () => { assert.deepEqual(parse(root, ['notifications']).path.map((c) => c.name), ['atcli', 'notifications', 'list']); assert.deepEqual(parse(root, ['notifications', '--unread']).path.map((c) => c.name), ['atcli', 'notifications', 'list']); const profile = parse(root, ['profile', 'bsky.app']); assert.deepEqual(profile.path.map((c) => c.name), ['atcli', 'profile', 'show']); assert.deepEqual(profile.positionals, ['bsky.app']); assert.equal(parse(root, ['notifications', '--help']).command.name, 'notifications');});
test('options fall back to their settings, and a flag wins', () => { const settings = (key: string) => (key === 'lang' ? 'de' : undefined); assert.deepEqual(parse(root, ['post', 'x'], settings).options['lang'], ['de']); assert.deepEqual(parse(root, ['post', 'x', '--lang', 'en'], settings).options['lang'], ['en']); assert.equal(parse(root, ['post', 'x'], () => undefined).options['lang'], undefined);});
test('global options are offered by every command', () => { for (const option of GLOBAL_OPTIONS) assert.ok(optionsOf(root).includes(option));});
// ------------------------------------------------------------ richtext ---
test('detects mentions, links and tags, and leaves e-mail and file names alone', () => { const found = detect('Hi @alice.bsky.social, see https://example.com/a_(b) and example.org! #rust #2024 mail me@example.com or open notes.txt.'); assert.deepEqual(found.map((each) => [each.kind, each.value]), [ ['mention', 'alice.bsky.social'], ['link', 'https://example.com/a_(b)'], ['link', 'https://example.org'], ['tag', 'rust'], ]);});
test('trailing punctuation and unbalanced parentheses are not part of a link', () => { assert.equal(detect('(see https://example.com/x).')[0]!.value, 'https://example.com/x'); assert.equal(detect('go to https://example.com, then').at(0)!.value, 'https://example.com');});
test('facet offsets are UTF-8 bytes, not string indices', async () => { const text = '🦋 café — #bluesky'; const [facet] = await facets(text, async () => undefined); const bytes = Buffer.from(text); assert.equal(bytes.subarray(facet!.index.byteStart, facet!.index.byteEnd).toString(), '#bluesky');});
test('mentions that do not resolve stay plain text', async () => { assert.deepEqual(await facets('@nobody.example hi', async () => undefined), []); const [facet] = await facets('@alice.test hi', async () => 'did:plc:aaaaaaaaaaaaaaaaaaaaaaaa'); assert.equal((facet!.features[0] as { did: string }).did, 'did:plc:aaaaaaaaaaaaaaaaaaaaaaaa');});
test('graphemes are counted as people count characters', () => { assert.equal(graphemeLength('👨👩👧👦'), 1); assert.equal(graphemeLength('é'), 1);});
test('split keeps every part within the limit, at sentence breaks where it can', () => { const text = Array.from({ length: 30 }, (_, i) => `This is sentence number ${i}.`).join(' '); const parts = split(text, 100); assert.ok(parts.length > 1); for (const part of parts) { assert.ok(graphemeLength(part) <= 100); assert.match(part, /\.$/); } assert.equal(parts.join(' '), text); assert.deepEqual(split('short'), ['short']);});
test('shortened link text shows where it goes', () => { const text = 'read example.com/very…'; const facet = { index: { byteStart: 5, byteEnd: Buffer.byteLength(text) }, features: [{ $type: 'app.bsky.richtext.facet#link' as const, uri: 'https://example.com/very/long/path' }] }; assert.equal(expandLinks(text, [facet]), 'read example.com/very… <https://example.com/very/long/path>');});
// --------------------------------------------------------------- media ---
test('image types and sizes come from the bytes', () => { const png = Buffer.alloc(32); Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(png); png.writeUInt32BE(1200, 16); png.writeUInt32BE(800, 20); assert.deepEqual(sniff(png), { mimeType: 'image/png', width: 1200, height: 800 }); assert.deepEqual(aspectRatio({ mimeType: 'image/png', width: 1200, height: 800 }), { width: 3, height: 2 });
const gif = Buffer.from('GIF89a\x40\x01\xf0\x00\x00\x00', 'latin1'); assert.deepEqual(sniff(gif), { mimeType: 'image/gif', width: 320, height: 240 });
// A minimal JPEG: SOI, then a baseline SOF0 for 640x480. const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xc0, 0x00, 0x11, 0x08, 0x01, 0xe0, 0x02, 0x80, 0x03, 0x01, 0x22, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xff, 0xd9]); assert.deepEqual(sniff(jpeg), { mimeType: 'image/jpeg', width: 640, height: 480 });
assert.equal(sniff(Buffer.from('plain text, not an image')), undefined);});
test('an mp4 reports the size of its first video track', () => { const box = (type: string, ...children: Buffer[]) => { const body = Buffer.concat(children); const head = Buffer.alloc(8); head.writeUInt32BE(8 + body.length); head.write(type, 4, 'latin1'); return Buffer.concat([head, body]); }; const tkhd = Buffer.alloc(84); tkhd.writeUInt32BE(1920 * 65536, 76); tkhd.writeUInt32BE(1080 * 65536, 80); const ftyp = box('ftyp', Buffer.from('isom\0\0\0\0', 'latin1')); const mp4 = Buffer.concat([ftyp, box('moov', box('trak', box('tkhd', tkhd)))]); assert.deepEqual(sniff(mp4), { mimeType: 'video/mp4', width: 1920, height: 1080 }); assert.deepEqual(aspectRatio(sniff(mp4)!), { width: 16, height: 9 });});
// ---------------------------------------------------------- references ---
test('actors are accepted as handles, @handles, DIDs and profile links', () => { assert.equal(actor('@Alice.bsky.social'), 'alice.bsky.social'); assert.equal(actor('did:plc:z72i7hdynmk6r22z27h6tvur'), 'did:plc:z72i7hdynmk6r22z27h6tvur'); assert.equal(actor('https://bsky.app/profile/bsky.app'), 'bsky.app'); assert.equal(actor('https://bsky.app/profile/bsky.app/post/3abc'), 'bsky.app'); assert.throws(() => actor('not a handle'), UsageError);});
test('at:// URIs parse, and posts link to the web', () => { assert.deepEqual(parseAtUri('at://did:plc:abc/app.bsky.feed.post/3k'), { repo: 'did:plc:abc', collection: 'app.bsky.feed.post', rkey: '3k' }); assert.deepEqual(parseAtUri('at://alice.test'), { repo: 'alice.test', collection: undefined, rkey: undefined }); assert.equal(postLink('at://did:plc:abc/app.bsky.feed.post/3k', 'alice.test'), 'https://bsky.app/profile/alice.test/post/3k'); assert.throws(() => parseAtUri('https://example.com'), UsageError);});
test('pagination stops at the limit, and at the last page', async () => { const pages = [{ items: [1, 2, 3], cursor: 'a' }, { items: [4, 5, 6], cursor: 'b' }, { items: [7], cursor: undefined }]; let calls = 0; const fetchPage = async (_cursor: string | undefined, limit: number) => { const page = pages[calls++]!; return { items: page.items.slice(0, limit), cursor: page.cursor }; }; assert.deepEqual(await paginate({ limit: 4 }, fetchPage), { items: [1, 2, 3, 4], cursor: 'b' }); calls = 0; assert.deepEqual((await paginate({ all: true }, fetchPage)).items, [1, 2, 3, 4, 5, 6, 7]);});
test('a JWT expiry is read without checking anything else', () => { const token = `x.${Buffer.from(JSON.stringify({ exp: 1700000000 })).toString('base64url')}.y`; assert.equal(jwtExpiry(token), 1700000000); assert.equal(jwtExpiry('garbage'), undefined);});
// ---------------------------------------------------------- completion ---
test('completion offers commands, subcommands, options and their values', () => { const values = (words: string[], current: string) => complete(root, words, current).candidates.map((each) => each.value); assert.ok(values([], 'ti').includes('timeline')); assert.deepEqual(values(['search'], ''), ['posts', 'users', 'feeds', 'starter-packs']); assert.ok(values(['post'], '--im').includes('--image')); assert.deepEqual(values(['post', '--label'], ''), ['sexual', 'nudity', 'porn', 'graphic-media']); assert.deepEqual(values(['search', 'posts'], '--sort='), ['--sort=top', '--sort=latest']); assert.equal(complete(root, ['post', '--image'], '').files, true); assert.deepEqual(values(['completion'], ''), ['bash', 'zsh', 'fish', 'powershell']); assert.deepEqual(values(['notifications', '--reason'], 'm'), ['mention']); assert.ok(values(['notifications'], '--un').includes('--unread'));});
test('the shell side splits lines and trims at word breaks for bash', () => { assert.deepEqual(splitLine('atcli post "hello world" --ima'), { words: ['atcli', 'post', 'hello world'], current: '--ima' }); const lines = respond(root, ['--shell', 'bash'], { COMP_LINE: 'atcli search posts --sort=l', COMP_POINT: '27' }); assert.deepEqual(lines, ['latest']); const fish = respond(root, ['--shell', 'fish', '--', 'search', '']); assert.ok(fish.some((line) => line.startsWith('posts\t')));});
test('every shell gets a script', () => { for (const shell of ['bash', 'zsh', 'fish', 'powershell']) assert.match(script(shell), /__complete/); assert.throws(() => script('tcsh'));});
test('the skill reference is current with the command tree', async () => { const FS = await import('node:fs'); const { reference } = await import('../src/reference.ts'); const current = FS.readFileSync(new URL('../skills/atcli/reference.md', import.meta.url), 'utf-8'); assert.ok(current === reference(), "skills/atcli/reference.md is out of date: run 'npm run reference'");});
test('thread numbering is taken off, and nothing else', async () => { const { stripNumbering } = await import('../src/unroll.ts'); for (const [text, expected] of [ ['1/5 The start', 'The start'], ['(2/5) Next', 'Next'], ['🧵 1/ Thread time', 'Thread time'], ['The end 5/5', 'The end'], ['Middle part [3/n]', 'Middle part'], ['The 24/7 shop', 'The 24/7 shop'], ['Ratio of 1/2 is half', 'Ratio of 1/2 is half'], ] as const) assert.equal(stripNumbering(text), expected, text);});
test('Markdown and HTML render the same document, and post text stays text in both', async () => { const { toMarkdown, toHtml, p, link, h } = await import('../src/doc.ts'); const { richText } = await import('../src/docs.ts'); const hostile = '<script>alert(1)</script> *not bold* [not](a link)\n# not a heading'; const doc = { title: 'T <x>', blocks: [h(1, 'T'), p(...richText(hostile, undefined)), p(link('javascript:alert(1)', 'click')), p(link('https://example.com/a b', 'ok'))] }; const html = toHtml(doc); assert.doesNotMatch(html, /<script>/); assert.match(html, /<script>/); assert.doesNotMatch(html, /javascript:/); assert.match(html, /<title>T <x><\/title>/); assert.match(html, /<a href="https:\/\/example.com\/a b">ok<\/a>/); const markdown = toMarkdown(doc); assert.match(markdown, /\\<script\\>/); assert.match(markdown, /\\\*not bold\\\*/); assert.match(markdown, /\\\[not\\\]/); assert.match(markdown, /\\\n\\# not a heading/); assert.doesNotMatch(markdown, /javascript:/); assert.match(markdown, /\[ok\]\(<https:\/\/example\.com\/a b>\)/);});
test('facets become links in the document, at the right bytes', async () => { const { richText } = await import('../src/docs.ts'); const { toMarkdown, p } = await import('../src/doc.ts'); const value = '🦋 see example.com and @alice.test'; const bytes = (part: string) => Buffer.byteLength(value.slice(0, value.indexOf(part))); const facets = [ { index: { byteStart: bytes('example'), byteEnd: bytes('example') + 11 }, features: [{ $type: 'app.bsky.richtext.facet#link' as const, uri: 'https://example.com' }] }, { index: { byteStart: bytes('@alice'), byteEnd: bytes('@alice') + 11 }, features: [{ $type: 'app.bsky.richtext.facet#mention' as const, did: 'did:plc:aaaaaaaaaaaaaaaaaaaaaaaa' }] }, ]; assert.equal(toMarkdown({ title: '', blocks: [p(...richText(value, facets))] }), '🦋 see [example.com](https://example.com) and [@alice.test](https://bsky.app/profile/did:plc:aaaaaaaaaaaaaaaaaaaaaaaa)\n');});
test('the output format: flags, then the saved preference', async () => { const { resolveFormat } = await import('../src/cli.ts'); const unset = () => 'text'; assert.equal(resolveFormat(['timeline'], unset), 'text'); assert.equal(resolveFormat(['timeline', '--markdown'], unset), 'markdown'); assert.equal(resolveFormat(['timeline', '--format', 'html'], unset), 'html'); assert.equal(resolveFormat(['timeline', '--html', '--json'], unset), 'json', 'the last one wins'); assert.equal(resolveFormat(['post', '--', '--json'], unset), 'text', 'not after --'); assert.equal(resolveFormat(['timeline'], () => 'markdown'), 'markdown'); assert.equal(resolveFormat(['timeline', '--json'], () => 'markdown'), 'json');});
test('a startup file says which shell it is for', async () => { const { shellOfProfile } = await import('../src/completion.ts'); assert.equal(shellOfProfile('/home/a/.config/fish/config.fish'), 'fish'); assert.equal(shellOfProfile('/home/a/.zshrc'), 'zsh'); assert.equal(shellOfProfile('/home/a/.bashrc'), 'bash'); assert.equal(shellOfProfile('C:\\Users\\a\\Documents\\PowerShell\\Microsoft.PowerShell_profile.ps1'), 'powershell'); assert.equal(shellOfProfile('/home/a/notes.txt'), null);});
test('feed names match however they are written', async () => { const { matchScore } = await import('../src/feedref.ts'); assert.equal(matchScore('JS/TS Wien', 'JS/TS Wien'), 100); assert.equal(matchScore('js ts wien', 'JS/TS Wien'), 100); assert.equal(matchScore('jstswien', 'JS/TS Wien'), 100); assert.equal(matchScore('mutuals', 'Mutual Follows', 'mutuals'), 95); assert.equal(matchScore('wiener', 'Wiener Linien'), 80); assert.equal(matchScore('café', 'Cafe Society'), 80); assert.equal(matchScore('lin wie', 'Wiener Linien'), 70); assert.equal(matchScore('ien', 'Wiener Linien'), 60); assert.equal(matchScore('wnrln', 'Wiener Linien'), 30); assert.equal(matchScore('xyz', 'Wiener Linien'), 0); assert.equal(matchScore('', 'anything'), 0);});
test('times: ISO 8601 (UTC unless it says otherwise) and how long ago', async () => { const { parseTime } = await import('../src/time.ts'); const now = Date.parse('2026-10-15T12:00:00Z'); assert.equal(parseTime('2026-10-15', now), Date.parse('2026-10-15T00:00:00Z')); assert.equal(parseTime('2026-10-15T09:30', now), Date.parse('2026-10-15T09:30:00Z')); assert.equal(parseTime('2026-10-15 09:30:15', now), Date.parse('2026-10-15T09:30:15Z')); assert.equal(parseTime('2026-10-15T09:30:00+02:00', now), Date.parse('2026-10-15T07:30:00Z')); assert.equal(parseTime('30m', now), now - 30 * 60_000); assert.equal(parseTime('2h', now), now - 2 * 3_600_000); assert.equal(parseTime('1.5d', now), now - 36 * 3_600_000); assert.equal(parseTime('1w', now), now - 7 * 86_400_000); for (const bad of ['yesterday', '15.10.2026', '2026-13-45', '5 parsecs']) assert.throws(() => parseTime(bad, now), UsageError, bad);});
test('--since keeps what is newer, and stops paging at the first page with nothing new', async () => { const at = (minutes: number) => new Date(Date.parse('2026-10-15T12:00:00Z') - minutes * 60_000).toISOString(); const pages = [ { items: [{ at: at(1) }, { at: at(5) }], cursor: 'a' }, { items: [{ at: at(9) }, { at: at(20) }], cursor: 'b' }, { items: [{ at: at(30) }, { at: at(40) }], cursor: 'c' }, { items: [{ at: at(50) }], cursor: 'd' }, ]; let calls = 0; const fetchPage = async () => pages[calls++]!; const page = await paginate({ all: true, since: Date.parse(at(10)) }, fetchPage, 100, (item: { at: string }) => item.at); assert.deepEqual(page.items.map((item) => item.at), [at(1), at(5), at(9)]); assert.equal(calls, 3, 'the page with nothing newer ends it'); assert.equal(page.cursor, undefined);});