@readwise/cli 0.3.1 → 0.5.1

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 CHANGED
@@ -1,8 +1,8 @@
1
- # readwise
1
+ # The Official Readwise CLI
2
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.
3
+ A command-line interface for [Readwise](https://readwise.io) and [Reader](https://read.readwise.io). Search your documents & highlights, manage your reading list, tag and organize documents — all from the terminal.
4
4
 
5
- Commands are auto-discovered from the Readwise API, so the CLI stays up to date as new features are added.
5
+ Anything you can do in Readwise/Reader, your agent can now do for you.
6
6
 
7
7
  ## Install
8
8
 
@@ -18,7 +18,7 @@ npm install -g @readwise/cli
18
18
  readwise login
19
19
  ```
20
20
 
21
- ### Access token login (for scripts/CI)
21
+ ### Access token login (for separate hosts like OpenClaw, or scripts)
22
22
 
23
23
  Get your token from [readwise.io/access_token](https://readwise.io/access_token), then:
24
24
 
@@ -64,6 +64,8 @@ readwise reader-get-document-details --document-id <document-id>
64
64
  readwise reader-get-document-highlights --document-id <document-id>
65
65
  ```
66
66
 
67
+ > **Tip: seen vs unseen documents.** In the response, `firstOpenedAt: null` means the document is **unseen** (never opened). A non-null `firstOpenedAt` means it has been opened/seen. Use `reader-bulk-edit-document-metadata --documents '[{"document_id":"<id>","seen":true}]'` to mark a document as seen.
68
+
67
69
  ### Save a document
68
70
 
69
71
  ```bash
@@ -84,11 +86,12 @@ readwise reader-add-tags-to-document --document-id <id> --tag-names "important,r
84
86
  readwise reader-remove-tags-from-document --document-id <id> --tag-names "old-tag"
85
87
 
86
88
  # Move between locations (new/later/shortlist/archive)
87
- readwise reader-move-document --document-id <id> --location archive
89
+ readwise reader-move-documents --document-ids <id> --location archive
88
90
 
89
91
  # Edit metadata
90
- readwise reader-edit-document-metadata --document-id <id> --title "Better Title"
91
- readwise reader-set-document-notes --document-id <id> --notes "Updated notes"
92
+ readwise reader-bulk-edit-document-metadata --documents '[{"document_id":"<id>","title":"Better Title"}]'
93
+ readwise reader-bulk-edit-document-metadata --documents '[{"document_id":"<id>","seen":true}]'
94
+ readwise reader-bulk-edit-document-metadata --documents '[{"document_id":"<id>","notes":"Updated notes"}]'
92
95
  ```
93
96
 
94
97
  ### Highlight management
package/dist/config.d.ts CHANGED
@@ -15,6 +15,7 @@ export interface SchemaProperty {
15
15
  enum?: string[];
16
16
  items?: SchemaProperty;
17
17
  default?: unknown;
18
+ examples?: unknown[];
18
19
  anyOf?: SchemaProperty[];
19
20
  $ref?: string;
20
21
  properties?: Record<string, SchemaProperty>;
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import { getTools } from "./mcp.js";
6
6
  import { registerTools } from "./commands.js";
7
7
  import { loadConfig } from "./config.js";
8
8
  import { VERSION } from "./version.js";
9
+ import { registerSkillsCommands } from "./skills.js";
9
10
  function readHiddenInput(prompt) {
10
11
  return new Promise((resolve, reject) => {
11
12
  if (!process.stdin.isTTY) {
@@ -90,8 +91,9 @@ async function main() {
90
91
  const forceRefresh = process.argv.includes("--refresh");
91
92
  const positionalArgs = process.argv.slice(2).filter((a) => !a.startsWith("--"));
92
93
  const hasSubcommand = positionalArgs.length > 0;
93
- // If no subcommand, TTY, and authenticated → launch TUI
94
- if (!hasSubcommand && process.stdout.isTTY && config.access_token) {
94
+ const wantsHelp = process.argv.includes("--help") || process.argv.includes("-h");
95
+ // If no subcommand, TTY, and authenticated → launch TUI (unless --help)
96
+ if (!hasSubcommand && !wantsHelp && process.stdout.isTTY && config.access_token) {
95
97
  try {
96
98
  const { token, authType } = await ensureValidToken();
97
99
  const tools = await getTools(token, authType, forceRefresh);
@@ -110,6 +112,14 @@ async function main() {
110
112
  console.log("\nRun `readwise login` or `readwise login-with-token` to authenticate.");
111
113
  return;
112
114
  }
115
+ // Register skills commands (works without auth)
116
+ registerSkillsCommands(program);
117
+ // If not authenticated and trying a non-login command, tell user to log in
118
+ if (!config.access_token && hasSubcommand && positionalArgs[0] !== "login" && positionalArgs[0] !== "login-with-token" && positionalArgs[0] !== "skills") {
119
+ process.stderr.write("\x1b[31mNot logged in.\x1b[0m Run `readwise login` or `readwise login-with-token` to authenticate.\n");
120
+ process.exitCode = 1;
121
+ return;
122
+ }
113
123
  // Try to load tools if we have a token (for subcommand mode)
114
124
  if (config.access_token) {
115
125
  try {
@@ -119,7 +129,6 @@ async function main() {
119
129
  }
120
130
  catch (err) {
121
131
  // Don't fail — login command should still work
122
- // Only warn if user is trying to run a non-login command
123
132
  if (hasSubcommand && positionalArgs[0] !== "login" && positionalArgs[0] !== "login-with-token") {
124
133
  process.stderr.write(`\x1b[33mWarning: Could not fetch tools: ${err.message}\x1b[0m\n`);
125
134
  }
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function registerSkillsCommands(program: Command): void;
package/dist/skills.js ADDED
@@ -0,0 +1,246 @@
1
+ import { readdir, readFile, writeFile, mkdir, rm } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { join, dirname } from "node:path";
4
+ import { homedir } from "node:os";
5
+ const REPO_OWNER = "readwiseio";
6
+ const REPO_NAME = "readwise-skills";
7
+ const SKILLS_SUBDIR = "skills";
8
+ const GITHUB_API = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}`;
9
+ const GITHUB_RAW = `https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/master`;
10
+ /** Local cache directory for fetched skills */
11
+ function cacheDir() {
12
+ return join(homedir(), ".readwise", "skills-cache");
13
+ }
14
+ /** Staleness threshold — refetch if cache is older than this */
15
+ const CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
16
+ function cacheMetaPath() {
17
+ return join(cacheDir(), ".fetched_at");
18
+ }
19
+ async function isCacheFresh() {
20
+ const metaPath = cacheMetaPath();
21
+ if (!existsSync(metaPath))
22
+ return false;
23
+ try {
24
+ const ts = Number(await readFile(metaPath, "utf-8"));
25
+ return Date.now() - ts < CACHE_MAX_AGE_MS;
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
31
+ async function touchCache() {
32
+ await writeFile(cacheMetaPath(), String(Date.now()));
33
+ }
34
+ /** Fetch the list of skill directory names from GitHub API */
35
+ async function fetchSkillNames() {
36
+ const res = await fetch(`${GITHUB_API}/contents/${SKILLS_SUBDIR}`, {
37
+ headers: { Accept: "application/vnd.github.v3+json", "User-Agent": "readwise-cli" },
38
+ });
39
+ if (!res.ok)
40
+ throw new Error(`GitHub API error: ${res.status} ${res.statusText}`);
41
+ const entries = (await res.json());
42
+ return entries.filter((e) => e.type === "dir").map((e) => e.name);
43
+ }
44
+ /** Fetch a single SKILL.md from GitHub */
45
+ async function fetchSkillFile(skillName) {
46
+ const url = `${GITHUB_RAW}/${SKILLS_SUBDIR}/${skillName}/SKILL.md`;
47
+ const res = await fetch(url, { headers: { "User-Agent": "readwise-cli" } });
48
+ if (!res.ok)
49
+ throw new Error(`Failed to fetch ${skillName}: ${res.status}`);
50
+ return res.text();
51
+ }
52
+ /** Fetch all skills from GitHub and write to cache */
53
+ async function refreshCache() {
54
+ const names = await fetchSkillNames();
55
+ const cache = cacheDir();
56
+ // Clear old cache
57
+ if (existsSync(cache))
58
+ await rm(cache, { recursive: true });
59
+ await mkdir(cache, { recursive: true });
60
+ // Fetch all in parallel
61
+ const results = await Promise.allSettled(names.map(async (name) => {
62
+ const content = await fetchSkillFile(name);
63
+ const dir = join(cache, name);
64
+ await mkdir(dir, { recursive: true });
65
+ await writeFile(join(dir, "SKILL.md"), content);
66
+ return name;
67
+ }));
68
+ const fetched = results
69
+ .filter((r) => r.status === "fulfilled")
70
+ .map((r) => r.value);
71
+ await touchCache();
72
+ return fetched;
73
+ }
74
+ /** Get cached skill names, refreshing if stale */
75
+ async function getSkillNames(forceRefresh) {
76
+ if (!forceRefresh && (await isCacheFresh())) {
77
+ return listCachedSkills();
78
+ }
79
+ try {
80
+ return await refreshCache();
81
+ }
82
+ catch (err) {
83
+ // Fall back to cache if network fails
84
+ const cached = await listCachedSkills();
85
+ if (cached.length > 0) {
86
+ console.error(`\x1b[33mWarning: Could not fetch from GitHub (${err.message}), using cached skills.\x1b[0m`);
87
+ return cached;
88
+ }
89
+ throw err;
90
+ }
91
+ }
92
+ async function listCachedSkills() {
93
+ const cache = cacheDir();
94
+ if (!existsSync(cache))
95
+ return [];
96
+ const entries = await readdir(cache, { withFileTypes: true });
97
+ return entries
98
+ .filter((e) => e.isDirectory() && existsSync(join(cache, e.name, "SKILL.md")))
99
+ .map((e) => e.name);
100
+ }
101
+ async function readSkillFrontmatter(skillName) {
102
+ const content = await readFile(join(cacheDir(), skillName, "SKILL.md"), "utf-8");
103
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
104
+ if (!match)
105
+ return {};
106
+ const fm = {};
107
+ for (const line of match[1].split("\n")) {
108
+ const [key, ...rest] = line.split(":");
109
+ if (key && rest.length)
110
+ fm[key.trim()] = rest.join(":").trim();
111
+ }
112
+ return fm;
113
+ }
114
+ /** Known agent platforms and their skills directories */
115
+ const PLATFORMS = {
116
+ claude: { name: "Claude Code", path: join(homedir(), ".claude", "skills") },
117
+ codex: { name: "Codex CLI", path: join(homedir(), ".codex", "skills") },
118
+ opencode: { name: "OpenCode", path: join(homedir(), ".opencode", "skills") },
119
+ };
120
+ export function registerSkillsCommands(program) {
121
+ const skills = program.command("skills").description("Manage Readwise skills for AI agents");
122
+ skills
123
+ .command("list")
124
+ .description("List available skills (fetched from github.com/readwiseio/readwise-skills)")
125
+ .option("--refresh", "Force refresh from GitHub")
126
+ .action(async (opts) => {
127
+ try {
128
+ const names = await getSkillNames(!!opts.refresh);
129
+ if (names.length === 0) {
130
+ console.log("No skills found.");
131
+ return;
132
+ }
133
+ console.log("Available skills:\n");
134
+ for (const name of names) {
135
+ const fm = await readSkillFrontmatter(name);
136
+ const desc = fm.description || "";
137
+ console.log(` ${name.padEnd(22)} ${desc}`);
138
+ }
139
+ console.log(`\nRun \`readwise skills install <platform>\` to install. Platforms: ${Object.keys(PLATFORMS).join(", ")}`);
140
+ }
141
+ catch (err) {
142
+ console.error(`\x1b[31mFailed to list skills: ${err.message}\x1b[0m`);
143
+ process.exitCode = 1;
144
+ }
145
+ });
146
+ skills
147
+ .command("install [platform]")
148
+ .description("Install skills to an agent platform (claude, codex, opencode)")
149
+ .option("--all", "Detect installed agents and install to all")
150
+ .option("--refresh", "Force refresh from GitHub before installing")
151
+ .action(async (platform, opts) => {
152
+ try {
153
+ const names = await getSkillNames(!!opts?.refresh);
154
+ if (names.length === 0) {
155
+ console.error("No skills found.");
156
+ process.exitCode = 1;
157
+ return;
158
+ }
159
+ let targets = [];
160
+ if (opts?.all) {
161
+ for (const [key, info] of Object.entries(PLATFORMS)) {
162
+ const parentDir = dirname(info.path);
163
+ if (existsSync(parentDir)) {
164
+ targets.push({ key, ...info });
165
+ }
166
+ }
167
+ if (targets.length === 0) {
168
+ console.log("No supported agents detected. Supported platforms: " + Object.keys(PLATFORMS).join(", "));
169
+ return;
170
+ }
171
+ }
172
+ else if (platform) {
173
+ const info = PLATFORMS[platform.toLowerCase()];
174
+ if (!info) {
175
+ console.error(`Unknown platform: ${platform}`);
176
+ console.error(`Supported platforms: ${Object.keys(PLATFORMS).join(", ")}`);
177
+ process.exitCode = 1;
178
+ return;
179
+ }
180
+ targets = [{ key: platform.toLowerCase(), ...info }];
181
+ }
182
+ else {
183
+ console.error("Specify a platform or use --all.");
184
+ console.error(`Supported platforms: ${Object.keys(PLATFORMS).join(", ")}`);
185
+ process.exitCode = 1;
186
+ return;
187
+ }
188
+ const cache = cacheDir();
189
+ for (const target of targets) {
190
+ console.log(`\nInstalling to ${target.name} (${target.path})...`);
191
+ await mkdir(target.path, { recursive: true });
192
+ for (const skillName of names) {
193
+ const srcFile = join(cache, skillName, "SKILL.md");
194
+ const destDir = join(target.path, skillName);
195
+ await mkdir(destDir, { recursive: true });
196
+ const content = await readFile(srcFile, "utf-8");
197
+ await writeFile(join(destDir, "SKILL.md"), content);
198
+ console.log(` \u2713 ${skillName}`);
199
+ }
200
+ }
201
+ console.log("\nDone!");
202
+ }
203
+ catch (err) {
204
+ console.error(`\x1b[31mFailed to install skills: ${err.message}\x1b[0m`);
205
+ process.exitCode = 1;
206
+ }
207
+ });
208
+ skills
209
+ .command("show <name>")
210
+ .description("Print the raw SKILL.md for a skill")
211
+ .option("--refresh", "Force refresh from GitHub")
212
+ .action(async (name, opts) => {
213
+ try {
214
+ await getSkillNames(!!opts.refresh);
215
+ const skillPath = join(cacheDir(), name, "SKILL.md");
216
+ if (!existsSync(skillPath)) {
217
+ console.error(`Skill not found: ${name}`);
218
+ const available = await listCachedSkills();
219
+ if (available.length)
220
+ console.error(`Available: ${available.join(", ")}`);
221
+ process.exitCode = 1;
222
+ return;
223
+ }
224
+ const content = await readFile(skillPath, "utf-8");
225
+ process.stdout.write(content);
226
+ }
227
+ catch (err) {
228
+ console.error(`\x1b[31mFailed: ${err.message}\x1b[0m`);
229
+ process.exitCode = 1;
230
+ }
231
+ });
232
+ skills
233
+ .command("update")
234
+ .description("Force refresh skills from GitHub")
235
+ .action(async () => {
236
+ try {
237
+ console.log("Fetching skills from github.com/readwiseio/readwise-skills...");
238
+ const names = await refreshCache();
239
+ console.log(`Updated ${names.length} skills: ${names.join(", ")}`);
240
+ }
241
+ catch (err) {
242
+ console.error(`\x1b[31mFailed to update: ${err.message}\x1b[0m`);
243
+ process.exitCode = 1;
244
+ }
245
+ });
246
+ }