@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/src/config.ts ADDED
@@ -0,0 +1,64 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ export interface ToolDef {
6
+ name: string;
7
+ description?: string;
8
+ inputSchema: {
9
+ type: string;
10
+ properties?: Record<string, SchemaProperty>;
11
+ required?: string[];
12
+ $defs?: Record<string, SchemaProperty>;
13
+ };
14
+ }
15
+
16
+ export interface SchemaProperty {
17
+ type?: string;
18
+ format?: string;
19
+ description?: string;
20
+ enum?: string[];
21
+ items?: SchemaProperty;
22
+ default?: unknown;
23
+ anyOf?: SchemaProperty[];
24
+ $ref?: string;
25
+ properties?: Record<string, SchemaProperty>;
26
+ required?: string[];
27
+ }
28
+
29
+ export interface Config {
30
+ client_id?: string;
31
+ client_secret?: string;
32
+ access_token?: string;
33
+ refresh_token?: string;
34
+ expires_at?: number;
35
+ auth_type?: "oauth" | "token";
36
+ tools_cache?: {
37
+ tools: ToolDef[];
38
+ fetched_at: number;
39
+ };
40
+ }
41
+
42
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
43
+
44
+ export function getConfigPath(): string {
45
+ return join(homedir(), ".readwise-cli.json");
46
+ }
47
+
48
+ export async function loadConfig(): Promise<Config> {
49
+ try {
50
+ const data = await readFile(getConfigPath(), "utf-8");
51
+ return JSON.parse(data) as Config;
52
+ } catch {
53
+ return {};
54
+ }
55
+ }
56
+
57
+ export async function saveConfig(config: Config): Promise<void> {
58
+ await writeFile(getConfigPath(), JSON.stringify(config, null, 2) + "\n", "utf-8");
59
+ }
60
+
61
+ export function isCacheValid(config: Config): boolean {
62
+ if (!config.tools_cache) return false;
63
+ return Date.now() - config.tools_cache.fetched_at < CACHE_TTL_MS;
64
+ }
package/src/index.ts ADDED
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env node
2
+ import { createInterface } from "node:readline";
3
+ import { Command } from "commander";
4
+ import { login, loginWithToken, ensureValidToken } from "./auth.js";
5
+ import { getTools } from "./mcp.js";
6
+ import { registerTools } from "./commands.js";
7
+ import { loadConfig } from "./config.js";
8
+ import { VERSION } from "./version.js";
9
+
10
+ function readHiddenInput(prompt: string): Promise<string> {
11
+ return new Promise((resolve, reject) => {
12
+ if (!process.stdin.isTTY) {
13
+ // Piped input (e.g. echo $TOKEN | readwise login-with-token)
14
+ const rl = createInterface({ input: process.stdin });
15
+ rl.once("line", (line) => { resolve(line.trim()); rl.close(); });
16
+ rl.once("close", () => resolve(""));
17
+ return;
18
+ }
19
+
20
+ process.stdout.write(prompt);
21
+ process.stdin.setRawMode(true);
22
+ process.stdin.resume();
23
+ process.stdin.setEncoding("utf-8");
24
+
25
+ let input = "";
26
+ const onData = (ch: string) => {
27
+ if (ch === "\r" || ch === "\n" || ch === "\u0004") {
28
+ process.stdin.removeListener("data", onData);
29
+ process.stdin.setRawMode(false);
30
+ process.stdin.pause();
31
+ process.stdout.write("\n");
32
+ resolve(input);
33
+ } else if (ch === "\u0003") {
34
+ process.stdin.removeListener("data", onData);
35
+ process.stdin.setRawMode(false);
36
+ process.stdin.pause();
37
+ process.stdout.write("\n");
38
+ reject(new Error("Aborted"));
39
+ } else if (ch === "\u007f" || ch === "\b") {
40
+ input = input.slice(0, -1);
41
+ } else {
42
+ input += ch;
43
+ }
44
+ };
45
+ process.stdin.on("data", onData);
46
+ });
47
+ }
48
+
49
+ const program = new Command();
50
+
51
+ program
52
+ .name("readwise")
53
+ .version(VERSION)
54
+ .description("Command-line interface for Readwise and Reader")
55
+ .option("--json", "Output raw JSON (machine-readable)")
56
+ .option("--refresh", "Force-refresh the tool cache");
57
+
58
+ program
59
+ .command("login")
60
+ .description("Authenticate with Readwise via OAuth (opens browser)")
61
+ .action(async () => {
62
+ try {
63
+ await login();
64
+ } catch (err) {
65
+ process.stderr.write(`\x1b[31m${(err as Error).message}\x1b[0m\n`);
66
+ process.exitCode = 1;
67
+ }
68
+ });
69
+
70
+ program
71
+ .command("login-with-token [token]")
72
+ .description("Authenticate with a Readwise access token (for scripts/CI)")
73
+ .action(async (token?: string) => {
74
+ try {
75
+ if (!token) {
76
+ console.log("Get your token from https://readwise.io/access_token");
77
+ token = await readHiddenInput("Enter token: ");
78
+ if (!token) {
79
+ process.stderr.write("\x1b[31mNo token provided.\x1b[0m\n");
80
+ process.exitCode = 1;
81
+ return;
82
+ }
83
+ }
84
+ await loginWithToken(token);
85
+ } catch (err) {
86
+ process.stderr.write(`\x1b[31m${(err as Error).message}\x1b[0m\n`);
87
+ process.exitCode = 1;
88
+ }
89
+ });
90
+
91
+ async function main() {
92
+ const config = await loadConfig();
93
+ const forceRefresh = process.argv.includes("--refresh");
94
+ const positionalArgs = process.argv.slice(2).filter((a) => !a.startsWith("--"));
95
+ const hasSubcommand = positionalArgs.length > 0;
96
+
97
+ // If no subcommand, TTY, and authenticated → launch TUI
98
+ if (!hasSubcommand && process.stdout.isTTY && config.access_token) {
99
+ try {
100
+ const { token, authType } = await ensureValidToken();
101
+ const tools = await getTools(token, authType, forceRefresh);
102
+ const { startTui } = await import("./tui/index.js");
103
+ await startTui(tools, token, authType);
104
+ return;
105
+ } catch (err) {
106
+ process.stderr.write(`\x1b[33mWarning: Could not start TUI: ${(err as Error).message}\x1b[0m\n`);
107
+ // Fall through to Commander help
108
+ }
109
+ }
110
+
111
+ // If no subcommand and not authenticated → hint to login
112
+ if (!hasSubcommand && process.stdout.isTTY && !config.access_token) {
113
+ await program.parseAsync(process.argv);
114
+ console.log("\nRun `readwise login` or `readwise login-with-token` to authenticate.");
115
+ return;
116
+ }
117
+
118
+ // Try to load tools if we have a token (for subcommand mode)
119
+ if (config.access_token) {
120
+ try {
121
+ const { token, authType } = await ensureValidToken();
122
+ const tools = await getTools(token, authType, forceRefresh);
123
+ registerTools(program, tools);
124
+ } catch (err) {
125
+ // Don't fail — login command should still work
126
+ // Only warn if user is trying to run a non-login command
127
+ if (hasSubcommand && positionalArgs[0] !== "login" && positionalArgs[0] !== "login-with-token") {
128
+ process.stderr.write(`\x1b[33mWarning: Could not fetch tools: ${(err as Error).message}\x1b[0m\n`);
129
+ }
130
+ }
131
+ }
132
+
133
+ await program.parseAsync(process.argv);
134
+ }
135
+
136
+ main();
package/src/mcp.ts ADDED
@@ -0,0 +1,66 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
3
+ import { loadConfig, saveConfig, isCacheValid, type ToolDef } from "./config.js";
4
+ import { VERSION } from "./version.js";
5
+
6
+ const MCP_URL = "https://mcp2.readwise.io/mcp";
7
+
8
+ function createTransport(token: string, authType: "oauth" | "token"): StreamableHTTPClientTransport {
9
+ const authHeader = authType === "token" ? `Token ${token}` : `Bearer ${token}`;
10
+ return new StreamableHTTPClientTransport(new URL(MCP_URL), {
11
+ requestInit: {
12
+ headers: {
13
+ Authorization: authHeader,
14
+ },
15
+ },
16
+ });
17
+ }
18
+
19
+ export async function getTools(token: string, authType: "oauth" | "token", forceRefresh = false): Promise<ToolDef[]> {
20
+ if (!forceRefresh) {
21
+ const config = await loadConfig();
22
+ if (isCacheValid(config)) {
23
+ return config.tools_cache!.tools;
24
+ }
25
+ }
26
+
27
+ const client = new Client({ name: "readwise", version: VERSION });
28
+ const transport = createTransport(token, authType);
29
+
30
+ try {
31
+ await client.connect(transport);
32
+ const result = await client.listTools();
33
+
34
+ const tools = result.tools as ToolDef[];
35
+
36
+ // Cache
37
+ const config = await loadConfig();
38
+ config.tools_cache = {
39
+ tools,
40
+ fetched_at: Date.now(),
41
+ };
42
+ await saveConfig(config);
43
+
44
+ return tools;
45
+ } finally {
46
+ await client.close();
47
+ }
48
+ }
49
+
50
+ export async function callTool(
51
+ token: string,
52
+ authType: "oauth" | "token",
53
+ name: string,
54
+ args: Record<string, unknown>,
55
+ ): Promise<{ content: Array<{ type: string; text?: string }>; structuredContent?: Record<string, unknown>; isError?: boolean }> {
56
+ const client = new Client({ name: "readwise", version: VERSION });
57
+ const transport = createTransport(token, authType);
58
+
59
+ try {
60
+ await client.connect(transport);
61
+ const result = await client.callTool({ name, arguments: args });
62
+ return result as { content: Array<{ type: string; text?: string }>; structuredContent?: Record<string, unknown>; isError?: boolean };
63
+ } finally {
64
+ await client.close();
65
+ }
66
+ }