@readwise/cli 0.3.0

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 ADDED
@@ -0,0 +1,140 @@
1
+ # readwise
2
+
3
+ A command-line interface for [Readwise](https://readwise.io) and [Reader](https://read.readwise.io). Search your highlights, manage your reading list, tag and organize documents — all from the terminal.
4
+
5
+ Commands are auto-discovered from the Readwise API, so the CLI stays up to date as new features are added.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ git clone <repo-url> && cd readwise
11
+ npm install
12
+ npm run build
13
+ npm link
14
+ ```
15
+
16
+ ## Setup
17
+
18
+ ### Interactive login (opens browser)
19
+
20
+ ```bash
21
+ readwise login
22
+ ```
23
+
24
+ ### Access token login (for scripts/CI)
25
+
26
+ Get your token from [readwise.io/access_token](https://readwise.io/access_token), then:
27
+
28
+ ```bash
29
+ readwise login-with-token
30
+ # prompts for token (hidden input, not stored in shell history)
31
+ ```
32
+
33
+ You can also pipe the token in:
34
+
35
+ ```bash
36
+ echo "$READWISE_TOKEN" | readwise login-with-token
37
+ ```
38
+
39
+ Credentials are stored in `~/.readwise-cli.json`. OAuth tokens refresh automatically.
40
+
41
+ ## Commands
42
+
43
+ Run `readwise --help` to see all available commands, or `readwise <command> --help` for details on a specific command.
44
+
45
+ ### Search documents
46
+
47
+ ```bash
48
+ readwise reader-search-documents --query "machine learning"
49
+ readwise reader-search-documents --query "react" --category-in article
50
+ readwise reader-search-documents --query "notes" --location-in shortlist --limit 5
51
+ readwise reader-search-documents --query "physics" --published-date-gt 2024-01-01
52
+ ```
53
+
54
+ ### Search highlights
55
+
56
+ ```bash
57
+ readwise readwise-search-highlights --vector-search-term "spaced repetition"
58
+ ```
59
+
60
+ ### List and inspect documents
61
+
62
+ ```bash
63
+ readwise reader-list-documents --limit 5
64
+ readwise reader-list-documents --category article --location later
65
+ readwise reader-list-documents --tag "to-review"
66
+ readwise reader-get-document-details --document-id <document-id>
67
+ readwise reader-get-document-highlights --document-id <document-id>
68
+ ```
69
+
70
+ ### Save a document
71
+
72
+ ```bash
73
+ readwise reader-create-document --url "https://example.com/article"
74
+ readwise reader-create-document \
75
+ --url "https://example.com" \
76
+ --title "My Article" \
77
+ --tags "reading-list,research" \
78
+ --notes "Found via HN"
79
+ ```
80
+
81
+ ### Organize
82
+
83
+ ```bash
84
+ # Tags
85
+ readwise reader-list-tags
86
+ readwise reader-add-tags-to-document --document-id <id> --tag-names "important,review"
87
+ readwise reader-remove-tags-from-document --document-id <id> --tag-names "old-tag"
88
+
89
+ # Move between locations (new/later/shortlist/archive)
90
+ readwise reader-move-document --document-id <id> --location archive
91
+
92
+ # Edit metadata
93
+ readwise reader-edit-document-metadata --document-id <id> --title "Better Title"
94
+ readwise reader-set-document-notes --document-id <id> --notes "Updated notes"
95
+ ```
96
+
97
+ ### Highlight management
98
+
99
+ ```bash
100
+ readwise reader-add-tags-to-highlight --document-id <id> --highlight-document-id <id> --tag-names "key-insight"
101
+ readwise reader-remove-tags-from-highlight --document-id <id> --highlight-document-id <id> --tag-names "old-tag"
102
+ readwise reader-set-highlight-notes --document-id <id> --highlight-document-id <id> --notes "This connects to..."
103
+ ```
104
+
105
+ ### Export
106
+
107
+ ```bash
108
+ readwise reader-export-documents
109
+ readwise reader-export-documents --since-updated "2024-06-01T00:00:00Z"
110
+ ```
111
+
112
+ ## Options
113
+
114
+ | Flag | Description |
115
+ |------|-------------|
116
+ | `--json` | Output raw JSON (for piping to `jq`, scripts, etc.) |
117
+ | `--refresh` | Force-refresh the command list from the server |
118
+ | `--help` | Show all commands or command-specific options |
119
+
120
+ ## Examples
121
+
122
+ Pipe results to `jq`:
123
+
124
+ ```bash
125
+ readwise reader-list-documents --limit 3 --json | jq '.results[].title'
126
+ ```
127
+
128
+ ## Development
129
+
130
+ ```bash
131
+ # Run without building
132
+ npx tsx src/index.ts --help
133
+
134
+ # Build
135
+ npm run build
136
+ ```
137
+
138
+ ## How it works
139
+
140
+ 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/auth.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ export declare function login(): Promise<void>;
2
+ export declare function loginWithToken(token: string): Promise<void>;
3
+ export declare function ensureValidToken(): Promise<{
4
+ token: string;
5
+ authType: "oauth" | "token";
6
+ }>;
package/dist/auth.js ADDED
@@ -0,0 +1,194 @@
1
+ import { createServer } from "node:http";
2
+ import { randomBytes, createHash } from "node:crypto";
3
+ import { URL } from "node:url";
4
+ import open from "open";
5
+ import { loadConfig, saveConfig } from "./config.js";
6
+ const DISCOVERY_URL = "https://readwise.io/o/.well-known/oauth-authorization-server";
7
+ const REDIRECT_URI = "http://localhost:6274/callback";
8
+ const SCOPES = "openid read write";
9
+ async function discover() {
10
+ const res = await fetch(DISCOVERY_URL);
11
+ if (!res.ok)
12
+ throw new Error(`OAuth discovery failed: ${res.status} ${res.statusText}`);
13
+ return (await res.json());
14
+ }
15
+ async function registerClient(registrationEndpoint) {
16
+ const res = await fetch(registrationEndpoint, {
17
+ method: "POST",
18
+ headers: { "Content-Type": "application/json" },
19
+ body: JSON.stringify({
20
+ client_name: "readwise-cli",
21
+ redirect_uris: [REDIRECT_URI],
22
+ grant_types: ["authorization_code", "refresh_token"],
23
+ token_endpoint_auth_method: "client_secret_basic",
24
+ }),
25
+ });
26
+ if (!res.ok) {
27
+ const body = await res.text();
28
+ throw new Error(`Client registration failed: ${res.status} ${body}`);
29
+ }
30
+ return (await res.json());
31
+ }
32
+ function generatePKCE() {
33
+ const verifier = randomBytes(48).toString("base64url");
34
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
35
+ return { verifier, challenge };
36
+ }
37
+ function waitForCallback(state) {
38
+ return new Promise((resolve, reject) => {
39
+ let timeout;
40
+ const cleanup = () => {
41
+ clearTimeout(timeout);
42
+ server.close();
43
+ };
44
+ const server = createServer((req, res) => {
45
+ const url = new URL(req.url, `http://localhost:6274`);
46
+ if (url.pathname !== "/callback") {
47
+ res.writeHead(404);
48
+ res.end();
49
+ return;
50
+ }
51
+ const code = url.searchParams.get("code");
52
+ const returnedState = url.searchParams.get("state");
53
+ const error = url.searchParams.get("error");
54
+ if (error) {
55
+ res.writeHead(200, { "Content-Type": "text/html" });
56
+ res.end(`<html><body><h1>Login failed</h1><p>${error}</p><p>You can close this tab.</p></body></html>`);
57
+ cleanup();
58
+ reject(new Error(`OAuth error: ${error}`));
59
+ return;
60
+ }
61
+ if (!code || returnedState !== state) {
62
+ res.writeHead(400, { "Content-Type": "text/html" });
63
+ res.end(`<html><body><h1>Invalid callback</h1><p>You can close this tab.</p></body></html>`);
64
+ cleanup();
65
+ reject(new Error("Invalid callback: missing code or state mismatch"));
66
+ return;
67
+ }
68
+ res.writeHead(200, { "Content-Type": "text/html" });
69
+ res.end(`<html><body><h1>Login successful!</h1><p>You can close this tab and return to the terminal.</p></body></html>`);
70
+ cleanup();
71
+ resolve(code);
72
+ });
73
+ server.listen(6274, () => {
74
+ // Server ready
75
+ });
76
+ server.on("error", (err) => {
77
+ clearTimeout(timeout);
78
+ reject(new Error(`Failed to start callback server: ${err.message}`));
79
+ });
80
+ // Timeout after 2 minutes
81
+ timeout = setTimeout(() => {
82
+ server.close();
83
+ reject(new Error("Login timed out — no callback received within 2 minutes"));
84
+ }, 120_000);
85
+ });
86
+ }
87
+ async function exchangeToken(tokenEndpoint, code, clientId, clientSecret, codeVerifier) {
88
+ const res = await fetch(tokenEndpoint, {
89
+ method: "POST",
90
+ headers: {
91
+ "Content-Type": "application/x-www-form-urlencoded",
92
+ Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`,
93
+ },
94
+ body: new URLSearchParams({
95
+ grant_type: "authorization_code",
96
+ code,
97
+ redirect_uri: REDIRECT_URI,
98
+ code_verifier: codeVerifier,
99
+ }),
100
+ });
101
+ if (!res.ok) {
102
+ const body = await res.text();
103
+ throw new Error(`Token exchange failed: ${res.status} ${body}`);
104
+ }
105
+ return (await res.json());
106
+ }
107
+ export async function login() {
108
+ console.log("Discovering OAuth endpoints...");
109
+ const metadata = await discover();
110
+ let config = await loadConfig();
111
+ // Register client if needed
112
+ if (!config.client_id || !config.client_secret) {
113
+ console.log("Registering client...");
114
+ const { client_id, client_secret } = await registerClient(metadata.registration_endpoint);
115
+ config.client_id = client_id;
116
+ config.client_secret = client_secret;
117
+ await saveConfig(config);
118
+ }
119
+ const { verifier, challenge } = generatePKCE();
120
+ const state = randomBytes(16).toString("hex");
121
+ const authUrl = new URL(metadata.authorization_endpoint);
122
+ authUrl.searchParams.set("response_type", "code");
123
+ authUrl.searchParams.set("client_id", config.client_id);
124
+ authUrl.searchParams.set("redirect_uri", REDIRECT_URI);
125
+ authUrl.searchParams.set("scope", SCOPES);
126
+ authUrl.searchParams.set("code_challenge", challenge);
127
+ authUrl.searchParams.set("code_challenge_method", "S256");
128
+ authUrl.searchParams.set("state", state);
129
+ // Start callback server before opening browser
130
+ const codePromise = waitForCallback(state);
131
+ console.log("Opening browser for authentication...");
132
+ await open(authUrl.toString());
133
+ const code = await codePromise;
134
+ console.log("Exchanging authorization code for tokens...");
135
+ const tokens = await exchangeToken(metadata.token_endpoint, code, config.client_id, config.client_secret, verifier);
136
+ config.access_token = tokens.access_token;
137
+ config.refresh_token = tokens.refresh_token;
138
+ config.expires_at = Date.now() + tokens.expires_in * 1000;
139
+ config.auth_type = "oauth";
140
+ await saveConfig(config);
141
+ console.log("Login successful! Tokens saved to ~/.readwise-cli.json");
142
+ }
143
+ export async function loginWithToken(token) {
144
+ const config = await loadConfig();
145
+ config.access_token = token;
146
+ config.auth_type = "token";
147
+ delete config.refresh_token;
148
+ delete config.expires_at;
149
+ delete config.client_id;
150
+ delete config.client_secret;
151
+ await saveConfig(config);
152
+ console.log("Token saved to ~/.readwise-cli.json");
153
+ }
154
+ export async function ensureValidToken() {
155
+ const config = await loadConfig();
156
+ if (!config.access_token) {
157
+ throw new Error("Not logged in. Run `readwise-cli login` or `readwise-cli login-with-token <token>` first.");
158
+ }
159
+ const authType = config.auth_type ?? "oauth";
160
+ // Access tokens don't expire and don't need refresh
161
+ if (authType === "token") {
162
+ return { token: config.access_token, authType };
163
+ }
164
+ // Refresh if expired or expiring within 60s
165
+ if (config.expires_at && Date.now() > config.expires_at - 60_000) {
166
+ if (!config.refresh_token || !config.client_id || !config.client_secret) {
167
+ throw new Error("Cannot refresh token — missing credentials. Run `readwise-cli login` again.");
168
+ }
169
+ console.error("Refreshing access token...");
170
+ const metadata = await discover();
171
+ const res = await fetch(metadata.token_endpoint, {
172
+ method: "POST",
173
+ headers: {
174
+ "Content-Type": "application/x-www-form-urlencoded",
175
+ Authorization: `Basic ${Buffer.from(`${config.client_id}:${config.client_secret}`).toString("base64")}`,
176
+ },
177
+ body: new URLSearchParams({
178
+ grant_type: "refresh_token",
179
+ refresh_token: config.refresh_token,
180
+ }),
181
+ });
182
+ if (!res.ok) {
183
+ const body = await res.text();
184
+ throw new Error(`Token refresh failed: ${res.status} ${body}. Run \`readwise-cli login\` again.`);
185
+ }
186
+ const tokens = (await res.json());
187
+ config.access_token = tokens.access_token;
188
+ if (tokens.refresh_token)
189
+ config.refresh_token = tokens.refresh_token;
190
+ config.expires_at = Date.now() + tokens.expires_in * 1000;
191
+ await saveConfig(config);
192
+ }
193
+ return { token: config.access_token, authType };
194
+ }
@@ -0,0 +1,14 @@
1
+ import { Command } from "commander";
2
+ import type { ToolDef, SchemaProperty } from "./config.js";
3
+ export declare function toolNameToCommand(name: string): string;
4
+ export declare function resolveRef(prop: SchemaProperty, defs?: Record<string, SchemaProperty>): SchemaProperty;
5
+ export declare function resolveProperty(prop: SchemaProperty, defs?: Record<string, SchemaProperty>): SchemaProperty;
6
+ export declare function displayResult(result: {
7
+ content: Array<{
8
+ type: string;
9
+ text?: string;
10
+ }>;
11
+ structuredContent?: Record<string, unknown>;
12
+ isError?: boolean;
13
+ }, json: boolean): void;
14
+ export declare function registerTools(program: Command, tools: ToolDef[]): void;
@@ -0,0 +1,152 @@
1
+ import { ensureValidToken } from "./auth.js";
2
+ import { callTool } from "./mcp.js";
3
+ export function toolNameToCommand(name) {
4
+ return name.replace(/_/g, "-");
5
+ }
6
+ export function resolveRef(prop, defs) {
7
+ if (prop.$ref && defs) {
8
+ const name = prop.$ref.replace("#/$defs/", "");
9
+ const resolved = defs[name];
10
+ if (resolved)
11
+ return { ...resolved, description: prop.description || resolved.description };
12
+ }
13
+ return prop;
14
+ }
15
+ export function resolveProperty(prop, defs) {
16
+ let p = resolveRef(prop, defs);
17
+ if (p.anyOf) {
18
+ const nonNull = p.anyOf.find((v) => v.type !== "null");
19
+ if (nonNull) {
20
+ p = { ...p, ...resolveRef(nonNull, defs), anyOf: undefined };
21
+ }
22
+ }
23
+ // Resolve items.$ref for array types
24
+ if (p.type === "array" && p.items) {
25
+ p = { ...p, items: resolveRef(p.items, defs) };
26
+ // Also resolve anyOf inside items (e.g. items wrapped in anyOf)
27
+ if (p.items?.anyOf) {
28
+ const nonNull = p.items.anyOf.find((v) => v.type !== "null");
29
+ if (nonNull) {
30
+ const resolvedItem = resolveRef(nonNull, defs);
31
+ p = { ...p, items: { ...p.items, ...resolvedItem, anyOf: undefined } };
32
+ }
33
+ }
34
+ }
35
+ return p;
36
+ }
37
+ function optionFlag(name, prop) {
38
+ const flag = `--${name.replace(/_/g, "-")}`;
39
+ if (prop.type === "boolean") {
40
+ return flag;
41
+ }
42
+ return `${flag} <value>`;
43
+ }
44
+ function parseValue(value, prop) {
45
+ if (prop.type === "integer" || prop.type === "number") {
46
+ const n = Number(value);
47
+ if (isNaN(n))
48
+ throw new Error(`Expected a number for value: ${value}`);
49
+ return n;
50
+ }
51
+ if (prop.type === "array") {
52
+ // Try JSON first, then comma-separated
53
+ try {
54
+ const parsed = JSON.parse(value);
55
+ if (Array.isArray(parsed))
56
+ return parsed;
57
+ }
58
+ catch {
59
+ // fall through
60
+ }
61
+ return value.split(",").map((s) => s.trim());
62
+ }
63
+ if (prop.type === "boolean") {
64
+ return true;
65
+ }
66
+ return value;
67
+ }
68
+ export function displayResult(result, json) {
69
+ if (result.isError) {
70
+ for (const item of result.content) {
71
+ if (item.text) {
72
+ process.stderr.write(`\x1b[31mError: ${item.text}\x1b[0m\n`);
73
+ }
74
+ }
75
+ process.exitCode = 1;
76
+ return;
77
+ }
78
+ // Prefer text content; fall back to structuredContent for empty results
79
+ let printed = false;
80
+ for (const item of result.content) {
81
+ if (item.type === "text" && item.text) {
82
+ printed = true;
83
+ if (json) {
84
+ process.stdout.write(item.text + "\n");
85
+ }
86
+ else {
87
+ try {
88
+ const parsed = JSON.parse(item.text);
89
+ console.log(JSON.stringify(parsed, null, 2));
90
+ }
91
+ catch {
92
+ console.log(item.text);
93
+ }
94
+ }
95
+ }
96
+ }
97
+ if (!printed && result.structuredContent) {
98
+ const data = result.structuredContent;
99
+ if (json) {
100
+ process.stdout.write(JSON.stringify(data) + "\n");
101
+ }
102
+ else {
103
+ console.log(JSON.stringify(data, null, 2));
104
+ }
105
+ }
106
+ }
107
+ export function registerTools(program, tools) {
108
+ for (const tool of tools) {
109
+ const cmd = program
110
+ .command(toolNameToCommand(tool.name))
111
+ .description(tool.description || "");
112
+ const properties = tool.inputSchema.properties || {};
113
+ const required = new Set(tool.inputSchema.required || []);
114
+ const defs = tool.inputSchema.$defs;
115
+ for (const [propName, rawProp] of Object.entries(properties)) {
116
+ const prop = resolveProperty(rawProp, defs);
117
+ const flag = optionFlag(propName, prop);
118
+ const parts = [];
119
+ if (prop.description)
120
+ parts.push(prop.description);
121
+ if (required.has(propName))
122
+ parts.push("(required)");
123
+ const enumValues = prop.enum || prop.items?.enum;
124
+ if (enumValues)
125
+ parts.push(`[${enumValues.join(", ")}]`);
126
+ if (prop.default !== undefined)
127
+ parts.push(`(default: ${JSON.stringify(prop.default)})`);
128
+ cmd.option(flag, parts.join(" ") || undefined);
129
+ }
130
+ cmd.action(async (options) => {
131
+ try {
132
+ const { token, authType } = await ensureValidToken();
133
+ // Convert commander options back to tool arguments
134
+ const args = {};
135
+ for (const [propName, rawProp] of Object.entries(properties)) {
136
+ const prop = resolveProperty(rawProp, defs);
137
+ const camelKey = propName.replace(/_/g, "-").replace(/-([a-z])/g, (_, c) => c.toUpperCase());
138
+ const value = options[camelKey];
139
+ if (value !== undefined) {
140
+ args[propName] = parseValue(String(value), prop);
141
+ }
142
+ }
143
+ const result = await callTool(token, authType, tool.name, args);
144
+ displayResult(result, program.opts().json || false);
145
+ }
146
+ catch (err) {
147
+ process.stderr.write(`\x1b[31m${err.message}\x1b[0m\n`);
148
+ process.exitCode = 1;
149
+ }
150
+ });
151
+ }
152
+ }
@@ -0,0 +1,38 @@
1
+ export interface ToolDef {
2
+ name: string;
3
+ description?: string;
4
+ inputSchema: {
5
+ type: string;
6
+ properties?: Record<string, SchemaProperty>;
7
+ required?: string[];
8
+ $defs?: Record<string, SchemaProperty>;
9
+ };
10
+ }
11
+ export interface SchemaProperty {
12
+ type?: string;
13
+ format?: string;
14
+ description?: string;
15
+ enum?: string[];
16
+ items?: SchemaProperty;
17
+ default?: unknown;
18
+ anyOf?: SchemaProperty[];
19
+ $ref?: string;
20
+ properties?: Record<string, SchemaProperty>;
21
+ required?: string[];
22
+ }
23
+ export interface Config {
24
+ client_id?: string;
25
+ client_secret?: string;
26
+ access_token?: string;
27
+ refresh_token?: string;
28
+ expires_at?: number;
29
+ auth_type?: "oauth" | "token";
30
+ tools_cache?: {
31
+ tools: ToolDef[];
32
+ fetched_at: number;
33
+ };
34
+ }
35
+ export declare function getConfigPath(): string;
36
+ export declare function loadConfig(): Promise<Config>;
37
+ export declare function saveConfig(config: Config): Promise<void>;
38
+ export declare function isCacheValid(config: Config): boolean;
package/dist/config.js ADDED
@@ -0,0 +1,24 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
5
+ export function getConfigPath() {
6
+ return join(homedir(), ".readwise-cli.json");
7
+ }
8
+ export async function loadConfig() {
9
+ try {
10
+ const data = await readFile(getConfigPath(), "utf-8");
11
+ return JSON.parse(data);
12
+ }
13
+ catch {
14
+ return {};
15
+ }
16
+ }
17
+ export async function saveConfig(config) {
18
+ await writeFile(getConfigPath(), JSON.stringify(config, null, 2) + "\n", "utf-8");
19
+ }
20
+ export function isCacheValid(config) {
21
+ if (!config.tools_cache)
22
+ return false;
23
+ return Date.now() - config.tools_cache.fetched_at < CACHE_TTL_MS;
24
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};