Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,13 @@ Chrome cookies → GraphQL API → JSONL cache → SQLite FTS5 index
Regex classification
Search / List / Viz
Search / List / Viz / Browse (fzf)
```

### Dependencies

All pure JavaScript/WASM — no native bindings:
- `commander` — CLI framework
- `sql.js` + `sql.js-fts5` — SQLite in WebAssembly
- `zod` — schema validation
- `dotenv` — .env file loading
- `fzf` — required for `ft browse` (external binary)
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,13 @@ Requires Node.js 20+. A Chrome-family browser or Firefox is recommended for sess
# 1. Sync your bookmarks (needs a supported browser logged into X)
ft sync

# 2. Search them
# 2. Browse them interactively (needs fzf)
ft browse

# 3. Search them
ft search "distributed systems"

# 3. Explore
# 4. Explore trends
ft viz
ft categories
ft stats
Expand Down Expand Up @@ -52,9 +55,11 @@ On first run, `ft sync` extracts your X session from your browser and downloads
| Command | Description |
|---------|-------------|
| `ft search <query>` | Full-text search with BM25 ranking |
| `ft browse` | Interactive browser with live preview (needs `fzf`) |
| `ft list` | Filter by author, date, category, domain, or folder |
| `ft list --folder <name>` | Show bookmarks in an X bookmark folder |
| `ft show <id>` | Show one bookmark in detail |
| `ft show <id> --open` | Show details and open in your default browser |
| `ft sample <category>` | Random sample from a category |
| `ft stats` | Top authors, languages, date range |
| `ft viz` | Terminal dashboard with sparklines, categories, and domains |
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

73 changes: 73 additions & 0 deletions src/bookmarks-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -773,6 +773,79 @@ export async function exportBookmarksForSyncSeed(): Promise<BookmarkRecord[]> {
}
}

export async function getFzfList(): Promise<string[]> {
const dbPath = twitterBookmarksIndexPath();
const db = await openDb(dbPath);
ensureMigrations(db);

try {
const sql = `
SELECT
posted_at,
bookmarked_at,
author_handle,
text,
id,
tweet_id
FROM bookmarks
`;
const rows = db.exec(sql);
if (!rows.length) return [];

const items = rows[0].values.map((row) => {
const postedAtStr = row[0] as string | null;
const bookmarkedAtStr = row[1] as string | null;
const authorHandle = row[2] as string | null;
const text = row[3] as string | null;
const id = row[4] as string;
const tweetId = row[5] as string;

// Parse timestamp
let timestamp = 0;
if (bookmarkedAtStr && /^\d{4}-/.test(bookmarkedAtStr)) {
timestamp = new Date(bookmarkedAtStr).getTime();
} else if (postedAtStr) {
timestamp = new Date(postedAtStr).getTime();
}

// Fallback to decoding the Snowflake ID
if (isNaN(timestamp) || timestamp === 0) {
try {
timestamp = Number(BigInt(tweetId) >> 22n) + 1288834974657;
} catch {
timestamp = 0;
}
}

const cleanText = String(text ?? '')
.replace(/\s+/g, ' ') // Collapse all whitespace including newlines
.trim()
.slice(0, 300);

return {
date: new Date(timestamp),
author: String(authorHandle ?? 'unknown').padEnd(15),
text: cleanText,
id,
timestamp
};
});

// Sort newest to oldest
items.sort((a, b) => b.timestamp - a.timestamp);

return items.map((item) => {
const yyyy = item.date.getFullYear();
const mm = String(item.date.getMonth() + 1).padStart(2, '0');
const dd = String(item.date.getDate()).padStart(2, '0');
const shortDate = isNaN(yyyy) ? '????-??-??' : `${yyyy}-${mm}-${dd}`;
return `[${shortDate}] @${item.author} ${item.text} ${item.id}`;
});
} finally {
db.close();
}
}

export async function getBookmarkById(id: string): Promise<BookmarkTimelineItem | null> {
const dbPath = twitterBookmarksIndexPath();
const db = await openDb(dbPath);
Expand Down
71 changes: 66 additions & 5 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
getFolderCounts,
listBookmarks,
getBookmarkById,
getFzfList,
} from './bookmarks-db.js';
import { formatClassificationSummary } from './bookmark-classify.js';
import { classifyWithLlm, classifyDomainsWithLlm } from './bookmark-classify-llm.js';
Expand All @@ -41,11 +42,17 @@ import { getPathReport } from './field-status.js';
import fs from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
import { execSync, spawnSync } from 'node:child_process';

configureHttpProxyFromEnv();

// ── Helpers ─────────────────────────────────────────────────────────────────

/** Create a clickable terminal hyperlink (OSC 8) */
function link(url: string, text?: string): string {
return `\x1b]8;;${url}\x1b\\${text ?? url}\x1b]8;;\x1b\\`;
}

const SPINNER = ['\u280b', '\u2819', '\u2839', '\u2838', '\u283c', '\u2834', '\u2826', '\u2827', '\u2807', '\u280f'];
let spinnerIdx = 0;

Expand Down Expand Up @@ -381,7 +388,7 @@ export async function showDashboard(): Promise<void> {
console.log(`
\x1b[2mSync now:\x1b[0m ft sync
\x1b[2mSearch:\x1b[0m ft search "query"
\x1b[2mExplore:\x1b[0m ft viz
\x1b[2mExplore:\x1b[0m ft viz, ft browse
\x1b[2mAll commands:\x1b[0m ft --help
`);
} catch {
Expand Down Expand Up @@ -1036,6 +1043,7 @@ export function buildCli() {
.description('Show one bookmark in detail')
.argument('<id>', 'Bookmark id')
.option('--json', 'JSON output')
.option('--open', 'Open the bookmark URL in your default browser')
.action(safe(async (id: string, options) => {
if (!requireIndex()) return;
const item = await getBookmarkById(String(id));
Expand All @@ -1044,19 +1052,72 @@ export function buildCli() {
process.exitCode = 1;
return;
}
if (options.open) {
try {
execSync(`open "${item.url}"`);
} catch {
console.error(` Error: Could not open browser for ${item.url}`);
}
}
if (options.json) {
console.log(JSON.stringify(item, null, 2));
return;
}
console.log(`${item.id} \u00b7 ${item.authorHandle ? `@${item.authorHandle}` : '@?'}`);
console.log(item.url);
console.log(link(item.url));
console.log(item.text);
if (item.quotedTweet) {
console.log(formatQuotedTweetLines(item.quotedTweet).join('\n'));
}
if (item.links.length) console.log(`links: ${item.links.join(', ')}`);
if (item.categories) console.log(`categories: ${item.categories}`);
if (item.domains) console.log(`domains: ${item.domains}`);
if (item.links.length) console.log(`links: ${item.links.map(l => link(l)).join(', ')}`);
if (item.categories.length) console.log(`categories: ${item.categories.join(', ')}`);
if (item.domains.length) console.log(`domains: ${item.domains.join(', ')}`);
}));

// ── browse ──────────────────────────────────────────────────────────────

program
.command('browse')
.description('Interactive browser for your bookmarks using fzf')
.action(safe(async () => {
if (!requireIndex()) return;
const list = await getFzfList();
if (!list.length) {
console.log(' No bookmarks to browse. Run: ft sync');
return;
}

// We use a temporary command that fzf can call for the preview.
// The current binary is globally linked as 'ft'.
const fzf = spawnSync('fzf', [
'--layout=reverse',
'--header', 'Enter: View & Open | Ctrl-/: Toggle Preview | Ctrl-W: Resize | ESC: Quit',
'--preview', 'ft show {-1}',
'--preview-window', 'right:50%:wrap',
'--ansi',
'--bind', 'ctrl-/:change-preview-window(hidden|)',
'--bind', 'ctrl-w:change-preview-window(right,60%|right,70%|right,40%|right,50%)',
'--bind', 'enter:execute(ft show {-1} --open)+abort',
], {
input: list.join('\n'),
stdio: ['pipe', 'inherit', 'inherit'],
encoding: 'utf-8',
});

if (fzf.error) {
const err = fzf.error as NodeJS.ErrnoException;
if (err.code === 'ENOENT') {
throw new Error('ft browse requires fzf, but it was not found on PATH. Install fzf, then retry: ft browse');
}
throw new Error(`fzf failed to start: ${err.message}`);
}
if (fzf.status === 2) {
throw new Error('fzf exited with an error while browsing bookmarks.');
}

const selected = fzf.stdout?.trim();
// fzf's execute() binding will naturally print the output of `ft show`
// before exiting, so we don't need to manually re-parse and print it here.
}));

// ── stats ───────────────────────────────────────────────────────────────
Expand Down
65 changes: 65 additions & 0 deletions tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { compareVersions, runWithSpinner, buildCli, parseCookieOption } from '../src/cli.js';
import { buildIndex } from '../src/bookmarks-db.js';
import { dataDir } from '../src/paths.js';
import { skillWithFrontmatter } from '../src/skill.js';

Expand All @@ -26,6 +27,25 @@ async function captureStdout(fn: () => Promise<void>): Promise<string> {
return chunks.join('');
}

async function captureStderr(fn: () => Promise<void>): Promise<string> {
const chunks: string[] = [];
const origWrite = process.stderr.write;
process.stderr.write = ((chunk: any, encodingOrCb?: any, cb?: any) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk.toString('utf-8') : String(chunk));
if (typeof encodingOrCb === 'function') encodingOrCb();
if (typeof cb === 'function') cb();
return true;
}) as typeof process.stderr.write;

try {
await fn();
} finally {
process.stderr.write = origWrite;
}

return chunks.join('');
}

test('showDashboard: prints update notice when cache is newer than local', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ft-dashboard-'));
const origEnv = process.env.FT_DATA_DIR;
Expand Down Expand Up @@ -55,6 +75,51 @@ test('showDashboard: prints update notice when cache is newer than local', async
);
});

test('ft browse: reports missing fzf instead of silently exiting', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ft-browse-'));
const emptyPathDir = path.join(tmpDir, 'bin');
fs.mkdirSync(emptyPathDir);
fs.writeFileSync(path.join(tmpDir, 'bookmarks.jsonl'), `${JSON.stringify({
id: '1',
tweetId: '1',
url: 'https://x.com/alice/status/1',
text: 'A bookmark for browsing',
authorHandle: 'alice',
authorName: 'Alice',
syncedAt: '2026-01-01T00:00:00Z',
postedAt: '2026-01-01T12:00:00Z',
links: [],
tags: [],
ingestedVia: 'graphql',
})}\n`);

const origEnv = {
FT_DATA_DIR: process.env.FT_DATA_DIR,
PATH: process.env.PATH,
};
const origExitCode = process.exitCode;
process.env.FT_DATA_DIR = tmpDir;
process.env.PATH = emptyPathDir;
process.exitCode = undefined;

try {
await buildIndex();
const output = await captureStderr(async () => {
await buildCli().parseAsync(['node', 'ft', 'browse']);
});
assert.equal(process.exitCode, 1);
assert.match(output, /ft browse requires fzf/);
assert.match(output, /not found on PATH/);
} finally {
if (origEnv.FT_DATA_DIR === undefined) delete process.env.FT_DATA_DIR;
else process.env.FT_DATA_DIR = origEnv.FT_DATA_DIR;
if (origEnv.PATH === undefined) delete process.env.PATH;
else process.env.PATH = origEnv.PATH;
process.exitCode = origExitCode;
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});

test('ft wiki: --engine option is registered', () => {
const program = buildCli();
const wikiCmd = program.commands.find((c: any) => c.name() === 'wiki');
Expand Down