From e39b430bebe3dc543977749fce770f39787d996d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 11 Jan 2026 11:09:44 +0000 Subject: [PATCH 1/4] test: add users command pagination cases --- tests/commands.users.test.ts | 101 +++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/tests/commands.users.test.ts b/tests/commands.users.test.ts index 6d36c72..03a5c82 100644 --- a/tests/commands.users.test.ts +++ b/tests/commands.users.test.ts @@ -37,6 +37,25 @@ describe('users commands', () => { } }); + it('rejects --max-pages when only --cursor is provided', async () => { + const program = new Command(); + registerUserCommands(program, baseCtx); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`exit ${code}`); + }) as never); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + try { + await expect( + program.parseAsync(['node', 'bird', 'following', '--cursor', 'prev', '--max-pages', '2']), + ).rejects.toThrow('exit 1'); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--max-pages requires --all.')); + } finally { + exitSpy.mockRestore(); + errorSpy.mockRestore(); + } + }); + it('emits pagination JSON when --cursor is provided', async () => { const program = new Command(); registerUserCommands(program, baseCtx); @@ -58,6 +77,27 @@ describe('users commands', () => { expect(payload.nextCursor).toBe('next-1'); }); + it('prints next cursor in non-JSON single-page mode', async () => { + const program = new Command(); + registerUserCommands(program, baseCtx); + vi.spyOn(TwitterClient.prototype, 'getCurrentUser').mockResolvedValue({ + success: true, + user: { id: '123', username: 'tester', name: 'Tester' }, + }); + vi.spyOn(TwitterClient.prototype, 'getFollowing').mockResolvedValue({ + success: true, + users: [{ id: '1', username: 'alpha', name: 'Alpha' }], + nextCursor: 'next-1', + }); + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + await program.parseAsync(['node', 'bird', 'following', '--cursor', 'prev']); + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Next cursor: next-1')); + expect(logSpy).toHaveBeenCalled(); + }); + it('emits pagination JSON for --all results', async () => { const program = new Command(); registerUserCommands(program, baseCtx); @@ -79,6 +119,67 @@ describe('users commands', () => { expect(payload.nextCursor).toBe('next-1'); }); + it('prints next cursor when stopping at max pages in non-JSON mode', async () => { + vi.useFakeTimers(); + const program = new Command(); + registerUserCommands(program, baseCtx); + vi.spyOn(TwitterClient.prototype, 'getCurrentUser').mockResolvedValue({ + success: true, + user: { id: '123', username: 'tester', name: 'Tester' }, + }); + vi.spyOn(TwitterClient.prototype, 'getFollowing').mockResolvedValue({ + success: true, + users: [{ id: '1', username: 'alpha', name: 'Alpha' }], + nextCursor: 'next-1', + }); + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const run = program.parseAsync(['node', 'bird', 'following', '--all', '--max-pages', '1']); + await vi.advanceTimersByTimeAsync(1000); + await run; + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Next cursor: next-1')); + expect(logSpy).toHaveBeenCalled(); + }); + + it('deduplicates users across pages in --all mode', async () => { + vi.useFakeTimers(); + const program = new Command(); + registerUserCommands(program, baseCtx); + vi.spyOn(TwitterClient.prototype, 'getCurrentUser').mockResolvedValue({ + success: true, + user: { id: '123', username: 'tester', name: 'Tester' }, + }); + const followingSpy = vi.spyOn(TwitterClient.prototype, 'getFollowing'); + followingSpy + .mockResolvedValueOnce({ + success: true, + users: [ + { id: '1', username: 'alpha', name: 'Alpha' }, + { id: '2', username: 'beta', name: 'Beta' }, + ], + nextCursor: 'cursor-1', + }) + .mockResolvedValueOnce({ + success: true, + users: [ + { id: '2', username: 'beta', name: 'Beta' }, + { id: '3', username: 'gamma', name: 'Gamma' }, + ], + nextCursor: undefined, + }); + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const run = program.parseAsync(['node', 'bird', 'following', '--all', '--json']); + await vi.advanceTimersByTimeAsync(1000); + await run; + + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])); + expect(payload.users).toHaveLength(3); + expect(payload.users.map((user: { id: string }) => user.id)).toEqual(['1', '2', '3']); + }); + it('stops pagination when cursor repeats or no new users are added', async () => { vi.useFakeTimers(); const program = new Command(); From bbc90dbe08dfb36c53cdc00100991b2619aa2a43 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 11 Jan 2026 11:09:48 +0000 Subject: [PATCH 2/4] docs: add manual live follow test --- docs/testing.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/testing.md b/docs/testing.md index 82efad9..982487a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -15,6 +15,7 @@ Requirements: Run: - `pnpm test:live` +- `pnpm bird following --all --max-pages 2 --json --cookie-source chrome --chrome-profile Default` Notes: - Live tests are skipped unless `BIRD_LIVE=1` (set by `pnpm test:live`). From 837b09fcad46564d15dca2bf6f1f20ff891f1e5b Mon Sep 17 00:00:00 2001 From: Martin Funk Date: Mon, 5 Jan 2026 14:08:11 +0100 Subject: [PATCH 3/4] feat: add clickable hyperlinks for URLs in terminal output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use OSC 8 escape sequences to make tweet and list URLs clickable in supported terminals (iTerm2, Ghostty, Kitty, WezTerm, VS Code, etc.). - Add hyperlink() helper function in output.ts - Apply hyperlinks in printTweets() for tweet URLs - Apply hyperlinks in printLists() for list URLs - Add hyperlinks flag to OutputConfig (auto-disabled for non-TTY) - Automatically falls back to plain text in --plain mode or when piped 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/cli/shared.ts | 4 ++- src/commands/lists.ts | 4 ++- src/lib/output.ts | 24 ++++++++++++++-- tests/output.test.ts | 65 ++++++++++++++++++++++++++++++++++--------- 4 files changed, 79 insertions(+), 18 deletions(-) diff --git a/src/cli/shared.ts b/src/cli/shared.ts index 8619f8d..806de34 100644 --- a/src/cli/shared.ts +++ b/src/cli/shared.ts @@ -7,6 +7,7 @@ import kleur from 'kleur'; import { type CookieSource, resolveCredentials } from '../lib/cookies.js'; import { extractTweetId } from '../lib/extract-tweet-id.js'; import { + hyperlink, labelPrefix, type OutputConfig, resolveOutputConfigFromArgv, @@ -323,7 +324,8 @@ export function createCliContext(normalizedArgs: string[], env: NodeJS.ProcessEn if (tweet.createdAt) { console.log(`${l('date')}${tweet.createdAt}`); } - console.log(`${l('url')}https://x.com/${tweet.author.username}/status/${tweet.id}`); + const tweetUrl = `https://x.com/${tweet.author.username}/status/${tweet.id}`; + console.log(`${l('url')}${hyperlink(tweetUrl, tweetUrl, output)}`); if (opts.showSeparator ?? true) { console.log('─'.repeat(50)); } diff --git a/src/commands/lists.ts b/src/commands/lists.ts index 7ec4cbe..ac2eb34 100644 --- a/src/commands/lists.ts +++ b/src/commands/lists.ts @@ -4,6 +4,7 @@ import type { Command } from 'commander'; import type { CliContext } from '../cli/shared.js'; import { extractListId } from '../lib/extract-list-id.js'; +import { hyperlink } from '../lib/output.js'; import type { TwitterList } from '../lib/twitter-client.js'; import { TwitterClient } from '../lib/twitter-client.js'; @@ -23,7 +24,8 @@ function printLists(lists: TwitterList[], ctx: CliContext): void { if (list.owner) { console.log(` ${ctx.colors.muted(`Owner: @${list.owner.username}`)}`); } - console.log(` ${ctx.colors.accent(`https://x.com/i/lists/${list.id}`)}`); + const listUrl = `https://x.com/i/lists/${list.id}`; + console.log(` ${ctx.colors.accent(hyperlink(listUrl, listUrl, ctx.getOutput()))}`); console.log('──────────────────────────────────────────────────'); } } diff --git a/src/lib/output.ts b/src/lib/output.ts index 8ed1bff..d492f7d 100644 --- a/src/lib/output.ts +++ b/src/lib/output.ts @@ -2,6 +2,7 @@ export type OutputConfig = { plain: boolean; emoji: boolean; color: boolean; + hyperlinks: boolean; }; export type StatusKind = 'ok' | 'warn' | 'err' | 'info' | 'hint'; @@ -33,8 +34,9 @@ export function resolveOutputConfigFromArgv(argv: string[], env: NodeJS.ProcessE const plain = argv.includes('--plain'); const emoji = !plain && !argv.includes('--no-emoji'); const color = !plain && !argv.includes('--no-color') && defaultColor; + const hyperlinks = !plain && isTty; - return { plain, emoji, color }; + return { plain, emoji, color, hyperlinks }; } export function resolveOutputConfigFromCommander( @@ -48,8 +50,9 @@ export function resolveOutputConfigFromCommander( const plain = Boolean(opts.plain); const emoji = !plain && (opts.emoji ?? true); const color = !plain && (opts.color ?? true) && defaultColor; + const hyperlinks = !plain && isTty; - return { plain, emoji, color }; + return { plain, emoji, color, hyperlinks }; } export function statusPrefix(kind: StatusKind, cfg: OutputConfig): string { @@ -93,6 +96,21 @@ export function formatTweetUrl(tweetId: string): string { return `https://x.com/i/status/${tweetId}`; } +/** + * Wraps a URL in OSC 8 escape sequences to make it clickable in supported terminals. + * Falls back to plain text when not in a TTY or when hyperlinks are disabled. + */ +export function hyperlink(url: string, text?: string, cfg?: OutputConfig): string { + const displayText = text ?? url; + // Only use hyperlinks when explicitly enabled (requires TTY and not plain mode) + if (!cfg?.hyperlinks) { + return displayText; + } + // OSC 8 hyperlink: \x1b]8;;URL\x07TEXT\x1b]8;;\x07 + return `\x1b]8;;${url}\x07${displayText}\x1b]8;;\x07`; +} + export function formatTweetUrlLine(tweetId: string, cfg: OutputConfig): string { - return `${labelPrefix('url', cfg)}${formatTweetUrl(tweetId)}`; + const url = formatTweetUrl(tweetId); + return `${labelPrefix('url', cfg)}${hyperlink(url, url, cfg)}`; } diff --git a/tests/output.test.ts b/tests/output.test.ts index e8e935f..3a43b81 100644 --- a/tests/output.test.ts +++ b/tests/output.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { formatStatsLine, formatTweetUrlLine, + hyperlink, labelPrefix, resolveOutputConfigFromArgv, resolveOutputConfigFromCommander, @@ -9,14 +10,19 @@ import { } from '../src/lib/output.js'; describe('output', () => { - it('defaults to emoji + color on TTY', () => { + it('defaults to emoji + color + hyperlinks on TTY', () => { const cfg = resolveOutputConfigFromArgv([], {}, true); - expect(cfg).toEqual({ plain: false, emoji: true, color: true }); + expect(cfg).toEqual({ plain: false, emoji: true, color: true, hyperlinks: true }); }); - it('plain disables emoji + color', () => { + it('disables hyperlinks on non-TTY', () => { + const cfg = resolveOutputConfigFromArgv([], {}, false); + expect(cfg.hyperlinks).toBe(false); + }); + + it('plain disables emoji + color + hyperlinks', () => { const cfg = resolveOutputConfigFromArgv(['--plain'], {}, true); - expect(cfg).toEqual({ plain: true, emoji: false, color: false }); + expect(cfg).toEqual({ plain: true, emoji: false, color: false, hyperlinks: false }); expect(statusPrefix('ok', cfg)).toBe('[ok] '); expect(labelPrefix('url', cfg)).toBe('url: '); }); @@ -33,7 +39,7 @@ describe('output', () => { it('--no-color disables colors', () => { const cfg = resolveOutputConfigFromArgv(['--no-color'], {}, true); - expect(cfg).toEqual({ plain: false, emoji: true, color: false }); + expect(cfg).toEqual({ plain: false, emoji: true, color: false, hyperlinks: true }); }); it('--no-emoji switches to text prefixes', () => { @@ -44,32 +50,65 @@ describe('output', () => { it('commander opts override defaults', () => { const cfg = resolveOutputConfigFromCommander({ emoji: false, color: false }, {}, true); - expect(cfg).toEqual({ plain: false, emoji: false, color: false }); + expect(cfg).toEqual({ plain: false, emoji: false, color: false, hyperlinks: true }); expect(statusPrefix('info', cfg)).toBe('Info: '); expect(labelPrefix('date', cfg)).toBe('Date: '); }); it('commander plain wins over emoji/color', () => { const cfg = resolveOutputConfigFromCommander({ plain: true, emoji: true, color: true }, {}, true); - expect(cfg).toEqual({ plain: true, emoji: false, color: false }); + expect(cfg).toEqual({ plain: true, emoji: false, color: false, hyperlinks: false }); + }); + + it('commander disables hyperlinks on non-TTY', () => { + const cfg = resolveOutputConfigFromCommander({}, {}, false); + expect(cfg.hyperlinks).toBe(false); }); it('formats stats line for all modes', () => { const stats = { likeCount: null, retweetCount: undefined, replyCount: 2 }; - expect(formatStatsLine(stats, { plain: true, emoji: false, color: false })).toBe( + expect(formatStatsLine(stats, { plain: true, emoji: false, color: false, hyperlinks: false })).toBe( 'likes: 0 retweets: 0 replies: 2', ); - expect(formatStatsLine(stats, { plain: false, emoji: false, color: false })).toBe('Likes 0 Retweets 0 Replies 2'); - expect(formatStatsLine(stats, { plain: false, emoji: true, color: false })).toBe('❤️ 0 🔁 0 💬 2'); + expect(formatStatsLine(stats, { plain: false, emoji: false, color: false, hyperlinks: false })).toBe( + 'Likes 0 Retweets 0 Replies 2', + ); + expect(formatStatsLine(stats, { plain: false, emoji: true, color: false, hyperlinks: false })).toBe( + '❤️ 0 🔁 0 💬 2', + ); }); it('always includes tweet URL in all modes', () => { const id = '1234567890'; const url = `https://x.com/i/status/${id}`; - expect(formatTweetUrlLine(id, { plain: true, emoji: false, color: false })).toContain(url); - expect(formatTweetUrlLine(id, { plain: false, emoji: false, color: false })).toContain(url); - expect(formatTweetUrlLine(id, { plain: false, emoji: true, color: false })).toContain(url); + expect(formatTweetUrlLine(id, { plain: true, emoji: false, color: false, hyperlinks: false })).toContain(url); + expect(formatTweetUrlLine(id, { plain: false, emoji: false, color: false, hyperlinks: false })).toContain(url); + expect(formatTweetUrlLine(id, { plain: false, emoji: true, color: false, hyperlinks: true })).toContain(url); + }); + + it('hyperlink returns plain text when hyperlinks disabled', () => { + const cfg = { plain: true, emoji: false, color: false, hyperlinks: false }; + expect(hyperlink('https://x.com/test', undefined, cfg)).toBe('https://x.com/test'); + }); + + it('hyperlink returns plain text on non-TTY (hyperlinks: false)', () => { + const cfg = { plain: false, emoji: true, color: false, hyperlinks: false }; + expect(hyperlink('https://x.com/test', undefined, cfg)).toBe('https://x.com/test'); + }); + + it('hyperlink wraps URL with OSC 8 escapes when hyperlinks enabled', () => { + const cfg = { plain: false, emoji: true, color: true, hyperlinks: true }; + const result = hyperlink('https://x.com/test', undefined, cfg); + expect(result).toContain('\x1b]8;;'); + expect(result).toContain('\x07'); + }); + + it('hyperlink uses custom display text', () => { + const cfg = { plain: false, emoji: true, color: true, hyperlinks: true }; + const result = hyperlink('https://x.com/test', 'Click here', cfg); + expect(result).toContain('Click here'); + expect(result).toContain('\x1b]8;;https://x.com/test\x07'); }); }); From 0a4699711f69751d15a826bb12852eb26e14e1cb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 11 Jan 2026 11:11:21 +0000 Subject: [PATCH 4/4] fix: sanitize hyperlink OSC output (#29) (thanks @mafulafunk) --- CHANGELOG.md | 1 + src/lib/output.ts | 4 +++- tests/output.test.ts | 7 +++++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df4963b..a52db92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed - macOS cookie extraction now supports Brave keychain storage (#40) — thanks @gakonst. +- Terminal hyperlinks now sanitize control characters before emitting OSC 8 sequences (#29) — thanks @mafulafunk. - Following/followers pagination now guards repeat cursors and standardizes JSON output (#28) — thanks @malpern. - Lists GraphQL feature flags updated to prevent 400s (#27) — thanks @zheli. - Likes pagination now follows cursors and avoids stalling on duplicate pages (#12) — thanks @titouv. diff --git a/src/lib/output.ts b/src/lib/output.ts index d492f7d..7d6779f 100644 --- a/src/lib/output.ts +++ b/src/lib/output.ts @@ -106,8 +106,10 @@ export function hyperlink(url: string, text?: string, cfg?: OutputConfig): strin if (!cfg?.hyperlinks) { return displayText; } + const safeUrl = url.replaceAll('\x1b', '').replaceAll('\x07', ''); + const safeText = displayText.replaceAll('\x1b', '').replaceAll('\x07', ''); // OSC 8 hyperlink: \x1b]8;;URL\x07TEXT\x1b]8;;\x07 - return `\x1b]8;;${url}\x07${displayText}\x1b]8;;\x07`; + return `\x1b]8;;${safeUrl}\x07${safeText}\x1b]8;;\x07`; } export function formatTweetUrlLine(tweetId: string, cfg: OutputConfig): string { diff --git a/tests/output.test.ts b/tests/output.test.ts index 3a43b81..2f33882 100644 --- a/tests/output.test.ts +++ b/tests/output.test.ts @@ -111,4 +111,11 @@ describe('output', () => { expect(result).toContain('Click here'); expect(result).toContain('\x1b]8;;https://x.com/test\x07'); }); + + it('hyperlink strips OSC control characters from url and text', () => { + const cfg = { plain: false, emoji: true, color: true, hyperlinks: true }; + const result = hyperlink('https://x.com/\u001btest\u0007', 'Hi\u001b\u0007', cfg); + expect(result).not.toContain('\u001btest\u0007'); + expect(result).not.toContain('Hi\u001b\u0007'); + }); });