@readwise/cli 0.5.1 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/dist/tui/app.js +63 -5
- package/package.json +1 -1
- package/src/tui/app.ts +62 -5
package/README.md
CHANGED
|
@@ -125,6 +125,21 @@ Pipe results to `jq`:
|
|
|
125
125
|
readwise reader-list-documents --limit 3 --json | jq '.results[].title'
|
|
126
126
|
```
|
|
127
127
|
|
|
128
|
+
## Skills
|
|
129
|
+
|
|
130
|
+
Pre-built workflows your AI agent can run. Install them with one command:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
readwise skills install claude # or codex, opencode
|
|
134
|
+
readwise skills list # see all available skills
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Browse and contribute skills at [github.com/readwiseio/readwise-skills](https://github.com/readwiseio/readwise-skills).
|
|
138
|
+
|
|
139
|
+
## Looking for MCP?
|
|
140
|
+
|
|
141
|
+
Using Claude Desktop, ChatGPT, or another AI app? Connect Readwise via MCP — no terminal needed. [Set up Readwise MCP →](https://readwise.io/mcp)
|
|
142
|
+
|
|
128
143
|
## How it works
|
|
129
144
|
|
|
130
145
|
The CLI connects to the [Readwise MCP server](https://mcp2.readwise.io) internally, auto-discovers available tools, and exposes each one as a CLI command. The tool list is cached locally for 24 hours.
|
package/dist/tui/app.js
CHANGED
|
@@ -387,6 +387,12 @@ function extractDocTitle(obj) {
|
|
|
387
387
|
return "Untitled";
|
|
388
388
|
}
|
|
389
389
|
function extractDocSummary(obj) {
|
|
390
|
+
// Prefer first matched chunk text (from search results)
|
|
391
|
+
if (Array.isArray(obj.matches) && obj.matches.length > 0) {
|
|
392
|
+
const first = obj.matches[0];
|
|
393
|
+
if (first?.plaintext && typeof first.plaintext === "string")
|
|
394
|
+
return first.plaintext;
|
|
395
|
+
}
|
|
390
396
|
for (const key of ["summary", "description", "note", "notes", "content"]) {
|
|
391
397
|
const val = obj[key];
|
|
392
398
|
if (val && typeof val === "string")
|
|
@@ -1333,7 +1339,45 @@ const SUCCESS_ICON = [
|
|
|
1333
1339
|
function cardLine(text, innerW, borderFn) {
|
|
1334
1340
|
return " " + borderFn("│") + " " + fitWidth(text, innerW) + " " + borderFn("│");
|
|
1335
1341
|
}
|
|
1336
|
-
|
|
1342
|
+
/** Build a regex that matches any individual word from the query (case-insensitive) */
|
|
1343
|
+
function searchTermRegex(query) {
|
|
1344
|
+
const words = query.trim().split(/\s+/).filter((w) => w.length >= 3);
|
|
1345
|
+
if (words.length === 0)
|
|
1346
|
+
return null;
|
|
1347
|
+
const escaped = words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
1348
|
+
return new RegExp(`(${escaped.join("|")})`, "gi");
|
|
1349
|
+
}
|
|
1350
|
+
/** Shift text so the first match is visible, returning a window of ~maxChars */
|
|
1351
|
+
function snippetAroundMatch(text, query, maxChars) {
|
|
1352
|
+
const re = searchTermRegex(query);
|
|
1353
|
+
if (!re)
|
|
1354
|
+
return text.slice(0, maxChars);
|
|
1355
|
+
const m = re.exec(text);
|
|
1356
|
+
if (!m)
|
|
1357
|
+
return text.slice(0, maxChars);
|
|
1358
|
+
const matchStart = m.index;
|
|
1359
|
+
// Start the window a bit before the match
|
|
1360
|
+
let start = Math.max(0, matchStart - Math.floor(maxChars / 4));
|
|
1361
|
+
// Snap forward to a word boundary so we don't cut mid-word
|
|
1362
|
+
if (start > 0) {
|
|
1363
|
+
const nextSpace = text.indexOf(" ", start);
|
|
1364
|
+
if (nextSpace !== -1 && nextSpace < matchStart) {
|
|
1365
|
+
start = nextSpace + 1;
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
const snippet = text.slice(start, start + maxChars);
|
|
1369
|
+
// Only add leading ellipsis; trailing truncation is handled by the card renderer
|
|
1370
|
+
return start > 0 ? "…" + snippet : snippet;
|
|
1371
|
+
}
|
|
1372
|
+
/** Bold all occurrences of search terms in a line */
|
|
1373
|
+
function highlightTerms(line, query) {
|
|
1374
|
+
const re = searchTermRegex(query);
|
|
1375
|
+
if (!re)
|
|
1376
|
+
return line;
|
|
1377
|
+
// \x1b[22;1;3;37m = undim+bold+italic+white, \x1b[22;23;2;39m = unbold+unitalic+dim+default color
|
|
1378
|
+
return line.replace(re, (match) => `\x1b[22;1;3;37m${match}\x1b[22;23;2;39m`);
|
|
1379
|
+
}
|
|
1380
|
+
function buildCardLines(card, ci, selected, cardWidth, searchQuery) {
|
|
1337
1381
|
const borderColor = selected ? style.cyan : style.dim;
|
|
1338
1382
|
const innerW = Math.max(1, cardWidth - 4);
|
|
1339
1383
|
const lines = [];
|
|
@@ -1383,8 +1427,19 @@ function buildCardLines(card, ci, selected, cardWidth) {
|
|
|
1383
1427
|
const titleStyled = selected ? style.bold(style.cyan(titleText)) : style.bold(titleText);
|
|
1384
1428
|
lines.push(cardLine(titleStyled, innerW, borderColor));
|
|
1385
1429
|
if (card.summary) {
|
|
1386
|
-
const
|
|
1387
|
-
|
|
1430
|
+
const snippet = searchQuery
|
|
1431
|
+
? snippetAroundMatch(card.summary, searchQuery, innerW * 3)
|
|
1432
|
+
: card.summary;
|
|
1433
|
+
const wrapped = wrapText(snippet, innerW);
|
|
1434
|
+
const showLines = wrapped.slice(0, 3);
|
|
1435
|
+
if (wrapped.length > 3) {
|
|
1436
|
+
const last = showLines[2];
|
|
1437
|
+
showLines[2] = truncateVisible(last, innerW - 1) + "…";
|
|
1438
|
+
}
|
|
1439
|
+
for (const sl of showLines) {
|
|
1440
|
+
const highlighted = searchQuery ? highlightTerms(sl, searchQuery) : sl;
|
|
1441
|
+
lines.push(cardLine(style.dim(highlighted), innerW, borderColor));
|
|
1442
|
+
}
|
|
1388
1443
|
}
|
|
1389
1444
|
if (card.meta) {
|
|
1390
1445
|
lines.push(cardLine(style.dim(truncateVisible(card.meta, innerW)), innerW, borderColor));
|
|
@@ -1404,10 +1459,12 @@ function renderCardView(state) {
|
|
|
1404
1459
|
const countInfo = style.dim(` (${state.resultCursor + 1} of ${cards.length})`);
|
|
1405
1460
|
content.push(" " + style.bold("Results") + countInfo);
|
|
1406
1461
|
content.push("");
|
|
1462
|
+
// Extract search query from form values (try common field names)
|
|
1463
|
+
const searchQuery = state.formValues["query"] || state.formValues["vector_search_term"] || state.formValues["search"] || "";
|
|
1407
1464
|
// Build all card lines
|
|
1408
1465
|
const allLines = [];
|
|
1409
1466
|
for (let ci = 0; ci < cards.length; ci++) {
|
|
1410
|
-
const cardContentLines = buildCardLines(cards[ci], ci, ci === state.resultCursor, cardWidth);
|
|
1467
|
+
const cardContentLines = buildCardLines(cards[ci], ci, ci === state.resultCursor, cardWidth, searchQuery || undefined);
|
|
1411
1468
|
for (const line of cardContentLines) {
|
|
1412
1469
|
allLines.push({ line, cardIdx: ci });
|
|
1413
1470
|
}
|
|
@@ -2408,7 +2465,8 @@ function cardLineCount(card, cardWidth) {
|
|
|
2408
2465
|
const textLines = Math.min(wrapped.length, 6);
|
|
2409
2466
|
return 2 + textLines + (card.note ? 1 : 0) + (card.meta ? 1 : 0); // top + text + note? + meta? + bottom
|
|
2410
2467
|
}
|
|
2411
|
-
|
|
2468
|
+
const summaryLines = card.summary ? Math.min(wrapText(card.summary, 60).length, 3) : 0;
|
|
2469
|
+
return 2 + 1 + summaryLines + (card.meta ? 1 : 0); // top + title + summary + meta? + bottom
|
|
2412
2470
|
}
|
|
2413
2471
|
function computeCardScroll(cards, cursor, currentScroll, availableHeight) {
|
|
2414
2472
|
const { innerWidth } = getBoxDimensions();
|
package/package.json
CHANGED
package/src/tui/app.ts
CHANGED
|
@@ -445,6 +445,11 @@ function extractDocTitle(obj: Record<string, unknown>): string {
|
|
|
445
445
|
}
|
|
446
446
|
|
|
447
447
|
function extractDocSummary(obj: Record<string, unknown>): string {
|
|
448
|
+
// Prefer first matched chunk text (from search results)
|
|
449
|
+
if (Array.isArray(obj.matches) && obj.matches.length > 0) {
|
|
450
|
+
const first = obj.matches[0] as Record<string, unknown> | undefined;
|
|
451
|
+
if (first?.plaintext && typeof first.plaintext === "string") return first.plaintext;
|
|
452
|
+
}
|
|
448
453
|
for (const key of ["summary", "description", "note", "notes", "content"]) {
|
|
449
454
|
const val = obj[key];
|
|
450
455
|
if (val && typeof val === "string") return val;
|
|
@@ -1420,7 +1425,44 @@ function cardLine(text: string, innerW: number, borderFn: (s: string) => string)
|
|
|
1420
1425
|
return " " + borderFn("│") + " " + fitWidth(text, innerW) + " " + borderFn("│");
|
|
1421
1426
|
}
|
|
1422
1427
|
|
|
1423
|
-
|
|
1428
|
+
/** Build a regex that matches any individual word from the query (case-insensitive) */
|
|
1429
|
+
function searchTermRegex(query: string): RegExp | null {
|
|
1430
|
+
const words = query.trim().split(/\s+/).filter((w) => w.length >= 3);
|
|
1431
|
+
if (words.length === 0) return null;
|
|
1432
|
+
const escaped = words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
1433
|
+
return new RegExp(`(${escaped.join("|")})`, "gi");
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
/** Shift text so the first match is visible, returning a window of ~maxChars */
|
|
1437
|
+
function snippetAroundMatch(text: string, query: string, maxChars: number): string {
|
|
1438
|
+
const re = searchTermRegex(query);
|
|
1439
|
+
if (!re) return text.slice(0, maxChars);
|
|
1440
|
+
const m = re.exec(text);
|
|
1441
|
+
if (!m) return text.slice(0, maxChars);
|
|
1442
|
+
const matchStart = m.index;
|
|
1443
|
+
// Start the window a bit before the match
|
|
1444
|
+
let start = Math.max(0, matchStart - Math.floor(maxChars / 4));
|
|
1445
|
+
// Snap forward to a word boundary so we don't cut mid-word
|
|
1446
|
+
if (start > 0) {
|
|
1447
|
+
const nextSpace = text.indexOf(" ", start);
|
|
1448
|
+
if (nextSpace !== -1 && nextSpace < matchStart) {
|
|
1449
|
+
start = nextSpace + 1;
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
const snippet = text.slice(start, start + maxChars);
|
|
1453
|
+
// Only add leading ellipsis; trailing truncation is handled by the card renderer
|
|
1454
|
+
return start > 0 ? "…" + snippet : snippet;
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
/** Bold all occurrences of search terms in a line */
|
|
1458
|
+
function highlightTerms(line: string, query: string): string {
|
|
1459
|
+
const re = searchTermRegex(query);
|
|
1460
|
+
if (!re) return line;
|
|
1461
|
+
// \x1b[22;1;3;37m = undim+bold+italic+white, \x1b[22;23;2;39m = unbold+unitalic+dim+default color
|
|
1462
|
+
return line.replace(re, (match) => `\x1b[22;1;3;37m${match}\x1b[22;23;2;39m`);
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
function buildCardLines(card: CardItem, ci: number, selected: boolean, cardWidth: number, searchQuery?: string): string[] {
|
|
1424
1466
|
const borderColor = selected ? style.cyan : style.dim;
|
|
1425
1467
|
const innerW = Math.max(1, cardWidth - 4);
|
|
1426
1468
|
const lines: string[] = [];
|
|
@@ -1471,8 +1513,19 @@ function buildCardLines(card: CardItem, ci: number, selected: boolean, cardWidth
|
|
|
1471
1513
|
lines.push(cardLine(titleStyled, innerW, borderColor));
|
|
1472
1514
|
|
|
1473
1515
|
if (card.summary) {
|
|
1474
|
-
const
|
|
1475
|
-
|
|
1516
|
+
const snippet = searchQuery
|
|
1517
|
+
? snippetAroundMatch(card.summary, searchQuery, innerW * 3)
|
|
1518
|
+
: card.summary;
|
|
1519
|
+
const wrapped = wrapText(snippet, innerW);
|
|
1520
|
+
const showLines = wrapped.slice(0, 3);
|
|
1521
|
+
if (wrapped.length > 3) {
|
|
1522
|
+
const last = showLines[2]!;
|
|
1523
|
+
showLines[2] = truncateVisible(last, innerW - 1) + "…";
|
|
1524
|
+
}
|
|
1525
|
+
for (const sl of showLines) {
|
|
1526
|
+
const highlighted = searchQuery ? highlightTerms(sl, searchQuery) : sl;
|
|
1527
|
+
lines.push(cardLine(style.dim(highlighted), innerW, borderColor));
|
|
1528
|
+
}
|
|
1476
1529
|
}
|
|
1477
1530
|
|
|
1478
1531
|
if (card.meta) {
|
|
@@ -1498,10 +1551,13 @@ function renderCardView(state: AppState): string[] {
|
|
|
1498
1551
|
content.push(" " + style.bold("Results") + countInfo);
|
|
1499
1552
|
content.push("");
|
|
1500
1553
|
|
|
1554
|
+
// Extract search query from form values (try common field names)
|
|
1555
|
+
const searchQuery = state.formValues["query"] || state.formValues["vector_search_term"] || state.formValues["search"] || "";
|
|
1556
|
+
|
|
1501
1557
|
// Build all card lines
|
|
1502
1558
|
const allLines: { line: string; cardIdx: number }[] = [];
|
|
1503
1559
|
for (let ci = 0; ci < cards.length; ci++) {
|
|
1504
|
-
const cardContentLines = buildCardLines(cards[ci]!, ci, ci === state.resultCursor, cardWidth);
|
|
1560
|
+
const cardContentLines = buildCardLines(cards[ci]!, ci, ci === state.resultCursor, cardWidth, searchQuery || undefined);
|
|
1505
1561
|
for (const line of cardContentLines) {
|
|
1506
1562
|
allLines.push({ line, cardIdx: ci });
|
|
1507
1563
|
}
|
|
@@ -2526,7 +2582,8 @@ function cardLineCount(card: CardItem, cardWidth: number): number {
|
|
|
2526
2582
|
const textLines = Math.min(wrapped.length, 6);
|
|
2527
2583
|
return 2 + textLines + (card.note ? 1 : 0) + (card.meta ? 1 : 0); // top + text + note? + meta? + bottom
|
|
2528
2584
|
}
|
|
2529
|
-
|
|
2585
|
+
const summaryLines = card.summary ? Math.min(wrapText(card.summary, 60).length, 3) : 0;
|
|
2586
|
+
return 2 + 1 + summaryLines + (card.meta ? 1 : 0); // top + title + summary + meta? + bottom
|
|
2530
2587
|
}
|
|
2531
2588
|
|
|
2532
2589
|
function computeCardScroll(cards: CardItem[], cursor: number, currentScroll: number, availableHeight: number): number {
|