readable-md 1.0.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,88 @@
1
+ # readable-md
2
+
3
+ [Readable](https://readable.md) on the command line. De-slop a draft,
4
+ check text for AI slop, simplify it for a reader, shorten it or explain its
5
+ jargon. Every number, date and fact is checked: a passage whose rewrite
6
+ changes the meaning stays as written.
7
+
8
+ ```bash
9
+ npm i -g readable-md # installs the `readable` command
10
+ npx readable-md deslop draft.md # or run it once without installing
11
+ ```
12
+
13
+ Node 20 or newer. No dependencies.
14
+
15
+ ## Sign in
16
+
17
+ Create an API key at <https://readable.md/account>, then:
18
+
19
+ ```bash
20
+ readable login # paste the key (hidden), it is checked and saved
21
+ readable usage # plan and pages left this billing cycle
22
+ ```
23
+
24
+ The key is saved to `~/.config/readable/config.json` (mode 0600, or under
25
+ `$XDG_CONFIG_HOME`). `readable logout` removes it.
26
+
27
+ ## Tools
28
+
29
+ ```bash
30
+ readable deslop draft.md > clean.md
31
+ readable deslop draft.md --voice my-old-post.md # edit toward your voice
32
+ pbpaste | readable check # free, never rewrites
33
+ readable simplify letter.pdf --audience patients
34
+ readable simplify --url https://example.com/terms --level 1
35
+ readable shorten report.md --length 0.4 --out short.md
36
+ readable explain contract.md --audience customers --language Spanish
37
+ ```
38
+
39
+ Input is a file (`.pdf` is uploaded), `-` or a pipe for stdin, or
40
+ `--url <url>`. The Markdown result goes to stdout; the summary line and the
41
+ share link go to stderr, so pipes get only the text.
42
+
43
+ | Option | Tools | |
44
+ | --- | --- | --- |
45
+ | `--audience <id>` | simplify, explain | `general`, `customers`, `patients`, `second-language`, `young`, `newcomer`, `decision-maker`, `specialist` |
46
+ | `--level <0-5>` | simplify, explain | 0 very simple, 2 plain (default), 5 expert |
47
+ | `--reader <text>` | simplify, explain | describe the reader in your own words |
48
+ | `--length <0.2-0.9>` | shorten | share of the original to keep (default 0.5) |
49
+ | `--voice <file>` | deslop | a sample of your own writing |
50
+ | `--language <name>` | all but check | write the result in this language |
51
+ | `--json` | all | print the full JSON result |
52
+ | `-o, --out <file>` | all | write the Markdown to a file |
53
+ | `-q, --quiet` | all | no summary line or share link |
54
+ | `--key <key>` | all | API key for this call |
55
+
56
+ Plans count pages: a page is 500 words, and the CLI, the API, the MCP server
57
+ and the app share them. Slop checks are free.
58
+
59
+ ## Use it from an AI assistant (MCP)
60
+
61
+ ```bash
62
+ readable mcp
63
+ ```
64
+
65
+ prints the MCP server URL (`https://readable.md/mcp`) and the config for
66
+ Claude Code, Cursor and other clients, for example:
67
+
68
+ ```bash
69
+ claude mcp add --transport http readable https://readable.md/mcp \
70
+ --header "Authorization: Bearer <key>"
71
+ ```
72
+
73
+ ## Environment
74
+
75
+ | Variable | |
76
+ | --- | --- |
77
+ | `READABLE_API_KEY` | API key (used when `--key` is not given; overrides the saved key) |
78
+ | `READABLE_API_URL` | API base URL, default `https://readable.md` |
79
+ | `XDG_CONFIG_HOME` | where the config file lives, default `~/.config` |
80
+
81
+ ## Exit codes
82
+
83
+ | Code | |
84
+ | --- | --- |
85
+ | 0 | done |
86
+ | 1 | usage error (bad option, missing input, unreadable file) |
87
+ | 2 | API or auth error (no key, invalid key, plan, bad source) |
88
+ | 3 | quota or rate limit reached |
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import { run } from "../src/commands.js";
4
+
5
+ const { version } = JSON.parse(
6
+ readFileSync(new URL("../package.json", import.meta.url), "utf8"),
7
+ );
8
+
9
+ process.exitCode = await run(process.argv.slice(2), {
10
+ env: process.env,
11
+ fetch: globalThis.fetch,
12
+ stdout: process.stdout,
13
+ stderr: process.stderr,
14
+ stdin: process.stdin,
15
+ version,
16
+ });
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "readable-md",
3
+ "version": "1.0.0",
4
+ "description": "Readable on the command line: de-slop, slop-check, simplify, shorten and explain text.",
5
+ "type": "module",
6
+ "bin": {
7
+ "readable": "./bin/readable.mjs"
8
+ },
9
+ "files": ["bin", "src", "README.md"],
10
+ "engines": {
11
+ "node": ">=20"
12
+ },
13
+ "scripts": {
14
+ "test": "vitest run --root ../.. packages/cli"
15
+ },
16
+ "keywords": ["readable", "ai", "slop", "writing", "cli", "mcp"],
17
+ "homepage": "https://readable.md",
18
+ "license": "MIT"
19
+ }
package/src/api.js ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * HTTP calls to the Readable API, through an injectable fetch.
3
+ */
4
+
5
+ export const EXIT = /** @type {const} */ ({
6
+ ok: 0,
7
+ usage: 1,
8
+ api: 2,
9
+ quota: 3,
10
+ });
11
+
12
+ export class ApiError extends Error {
13
+ /**
14
+ * @param {string} code
15
+ * @param {string} message
16
+ * @param {number} [status]
17
+ */
18
+ constructor(code, message, status) {
19
+ super(message);
20
+ this.name = "ApiError";
21
+ this.code = code;
22
+ this.status = status;
23
+ }
24
+
25
+ get exitCode() {
26
+ return this.code === "quota" || this.code === "rate"
27
+ ? EXIT.quota
28
+ : EXIT.api;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * @typedef {object} Client
34
+ * @property {string} baseUrl
35
+ * @property {string} key
36
+ * @property {typeof fetch} fetch
37
+ * @property {string} version
38
+ */
39
+
40
+ /**
41
+ * @param {Client} client
42
+ * @param {string} path
43
+ * @param {RequestInit} [init]
44
+ */
45
+ export async function request(client, path, init = {}) {
46
+ /** @type {Response} */
47
+ let response;
48
+ try {
49
+ response = await client.fetch(`${client.baseUrl}${path}`, {
50
+ ...init,
51
+ headers: {
52
+ Authorization: `Bearer ${client.key}`,
53
+ Accept: "application/json",
54
+ "User-Agent": `readable-md/${client.version}`,
55
+ ...(init.headers ?? {}),
56
+ },
57
+ });
58
+ } catch (caught) {
59
+ throw new ApiError(
60
+ "network",
61
+ `Could not reach ${client.baseUrl}: ${/** @type {Error} */ (caught).message}`,
62
+ );
63
+ }
64
+ const text = await response.text();
65
+ /** @type {any} */
66
+ let body = null;
67
+ try {
68
+ body = text ? JSON.parse(text) : null;
69
+ } catch {
70
+ // Not JSON: handled below.
71
+ }
72
+ if (!response.ok) {
73
+ const error = body?.error;
74
+ if (error && typeof error.message === "string")
75
+ throw new ApiError(
76
+ String(error.code ?? "error"),
77
+ error.message,
78
+ response.status,
79
+ );
80
+ throw new ApiError(
81
+ response.status === 401 ? "unauthorized" : "http",
82
+ `${response.status} ${response.statusText || "error"} from ${path}`.trim(),
83
+ response.status,
84
+ );
85
+ }
86
+ if (body === null)
87
+ throw new ApiError("invalid_response", `No JSON from ${path}.`);
88
+ return body;
89
+ }
90
+
91
+ /** @param {Client} client */
92
+ export function getUsage(client) {
93
+ return request(client, "/api/v1/usage");
94
+ }
95
+
96
+ /**
97
+ * @param {Client} client
98
+ * @param {Record<string, string | number>} fields
99
+ * @param {{ text?: string, pdf?: { name: string, bytes: Uint8Array } }} input
100
+ */
101
+ export function transform(client, fields, input) {
102
+ if (input.pdf) {
103
+ const form = new FormData();
104
+ for (const [name, value] of Object.entries(fields))
105
+ form.set(name, String(value));
106
+ form.set(
107
+ "file",
108
+ new Blob([input.pdf.bytes], { type: "application/pdf" }),
109
+ input.pdf.name,
110
+ );
111
+ return request(client, "/api/v1/transform", { method: "POST", body: form });
112
+ }
113
+ return request(client, "/api/v1/transform", {
114
+ method: "POST",
115
+ headers: { "Content-Type": "application/json" },
116
+ body: JSON.stringify(
117
+ input.text === undefined ? fields : { ...fields, text: input.text },
118
+ ),
119
+ });
120
+ }
package/src/args.js ADDED
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Argument parsing. Pure: argv in, a command description out.
3
+ */
4
+ import { parseArgs } from "node:util";
5
+
6
+ export class UsageError extends Error {
7
+ /** @param {string} message */
8
+ constructor(message) {
9
+ super(message);
10
+ this.name = "UsageError";
11
+ }
12
+ }
13
+
14
+ /** CLI command → API tool id. */
15
+ export const TOOL_COMMANDS = /** @type {const} */ ({
16
+ deslop: "deslop",
17
+ check: "slop-check",
18
+ simplify: "simplify",
19
+ shorten: "shorten",
20
+ explain: "explain",
21
+ });
22
+
23
+ export const OTHER_COMMANDS = ["login", "logout", "usage", "tools", "mcp"];
24
+
25
+ export const AUDIENCES = [
26
+ "general",
27
+ "customers",
28
+ "patients",
29
+ "second-language",
30
+ "young",
31
+ "newcomer",
32
+ "decision-maker",
33
+ "specialist",
34
+ ];
35
+
36
+ /** @typedef {keyof typeof TOOL_COMMANDS} ToolCommand */
37
+
38
+ /**
39
+ * @typedef {object} Parsed
40
+ * @property {string | null} command
41
+ * @property {boolean} help
42
+ * @property {boolean} version
43
+ * @property {string | undefined} key
44
+ * @property {string | undefined} input
45
+ * @property {Record<string, string | boolean | undefined>} options
46
+ */
47
+
48
+ const COMMON = {
49
+ help: { type: "boolean", short: "h" },
50
+ version: { type: "boolean", short: "v" },
51
+ key: { type: "string" },
52
+ };
53
+
54
+ const TOOL_OPTIONS = {
55
+ ...COMMON,
56
+ url: { type: "string" },
57
+ json: { type: "boolean" },
58
+ out: { type: "string", short: "o" },
59
+ quiet: { type: "boolean", short: "q" },
60
+ };
61
+
62
+ const WRITING_OPTIONS = { ...TOOL_OPTIONS, language: { type: "string" } };
63
+
64
+ /** @type {Record<string, Record<string, { type: "string" | "boolean", short?: string }>>} */
65
+ const OPTIONS = {
66
+ deslop: {
67
+ ...WRITING_OPTIONS,
68
+ voice: { type: "string" },
69
+ "no-voice-profile": { type: "boolean" },
70
+ },
71
+ // Slop check never rewrites, so it takes no output language.
72
+ check: TOOL_OPTIONS,
73
+ simplify: {
74
+ ...WRITING_OPTIONS,
75
+ audience: { type: "string" },
76
+ level: { type: "string" },
77
+ reader: { type: "string" },
78
+ },
79
+ shorten: { ...WRITING_OPTIONS, length: { type: "string" } },
80
+ explain: {
81
+ ...WRITING_OPTIONS,
82
+ audience: { type: "string" },
83
+ level: { type: "string" },
84
+ reader: { type: "string" },
85
+ },
86
+ login: { ...COMMON },
87
+ logout: { ...COMMON },
88
+ usage: { ...COMMON, json: { type: "boolean" } },
89
+ tools: { ...COMMON },
90
+ mcp: { ...COMMON },
91
+ };
92
+
93
+ /** @param {string} name */
94
+ export function isToolCommand(name) {
95
+ return Object.hasOwn(TOOL_COMMANDS, name);
96
+ }
97
+
98
+ /**
99
+ * @param {string[]} argv arguments after `readable`
100
+ * @returns {Parsed}
101
+ */
102
+ export function parse(argv) {
103
+ const [first, ...rest] = argv;
104
+ if (first === undefined || first.startsWith("-")) {
105
+ const { values } = safeParse(argv, COMMON, false);
106
+ return {
107
+ command: null,
108
+ help: Boolean(values.help) || first === undefined,
109
+ version: Boolean(values.version),
110
+ key: undefined,
111
+ input: undefined,
112
+ options: {},
113
+ };
114
+ }
115
+ if (first === "help")
116
+ return {
117
+ command: rest[0] ?? null,
118
+ help: true,
119
+ version: false,
120
+ key: undefined,
121
+ input: undefined,
122
+ options: {},
123
+ };
124
+ const spec = OPTIONS[first];
125
+ if (!spec)
126
+ throw new UsageError(
127
+ `Unknown command "${first}". Run \`readable --help\` for the list.`,
128
+ );
129
+ const tool = isToolCommand(first);
130
+ const { values, positionals } = safeParse(rest, spec, tool);
131
+ if (positionals.length > (tool ? 1 : 0))
132
+ throw new UsageError(`Unexpected argument "${positionals[tool ? 1 : 0]}".`);
133
+ const { help, version, key, ...options } = values;
134
+ const parsed = {
135
+ command: first,
136
+ help: Boolean(help),
137
+ version: Boolean(version),
138
+ key: /** @type {string | undefined} */ (key),
139
+ input: positionals[0],
140
+ options: /** @type {Record<string, string | boolean | undefined>} */ (
141
+ options
142
+ ),
143
+ };
144
+ if (tool && !parsed.help) validateToolOptions(parsed);
145
+ return parsed;
146
+ }
147
+
148
+ /**
149
+ * @param {string[]} args
150
+ * @param {Record<string, { type: "string" | "boolean", short?: string }>} options
151
+ * @param {boolean} allowPositionals
152
+ */
153
+ function safeParse(args, options, allowPositionals) {
154
+ try {
155
+ return parseArgs({ args, options, allowPositionals, strict: true });
156
+ } catch (caught) {
157
+ const message = /** @type {Error} */ (caught).message
158
+ .split(". To specify")[0]
159
+ .replace(/\.$/, "");
160
+ throw new UsageError(`${message}. See \`readable --help\`.`);
161
+ }
162
+ }
163
+
164
+ /** @param {Parsed} parsed */
165
+ function validateToolOptions(parsed) {
166
+ const { options } = parsed;
167
+ if (parsed.input && options.url)
168
+ throw new UsageError("Pass a file or --url, not both.");
169
+ if (
170
+ typeof options.audience === "string" &&
171
+ !AUDIENCES.includes(options.audience)
172
+ )
173
+ throw new UsageError(
174
+ `Unknown audience "${options.audience}". Use one of ${AUDIENCES.join(", ")}.`,
175
+ );
176
+ if (options.level !== undefined) {
177
+ const level = Number(options.level);
178
+ if (!Number.isInteger(level) || level < 0 || level > 5)
179
+ throw new UsageError("--level must be a whole number from 0 to 5.");
180
+ }
181
+ if (options.length !== undefined) {
182
+ const length = Number(options.length);
183
+ if (!Number.isFinite(length) || length < 0.2 || length > 0.9)
184
+ throw new UsageError(
185
+ "--length must be a share of the original from 0.2 to 0.9 (0.5 = half).",
186
+ );
187
+ }
188
+ }
189
+
190
+ /**
191
+ * The JSON body for `POST /api/v1/transform`, without the text or file.
192
+ * @param {ToolCommand} command
193
+ * @param {Record<string, string | boolean | undefined>} options
194
+ * @param {{ voice?: string }} [extra]
195
+ */
196
+ export function transformFields(command, options, extra = {}) {
197
+ /** @type {Record<string, string | number | boolean>} */
198
+ const body = { tool: TOOL_COMMANDS[command] };
199
+ if (typeof options.url === "string") body.url = options.url;
200
+ if (typeof options.audience === "string") body.audience = options.audience;
201
+ if (options.level !== undefined) body.level = Number(options.level);
202
+ if (typeof options.reader === "string") body.reader = options.reader;
203
+ if (options.length !== undefined) body.length = Number(options.length);
204
+ if (typeof options.language === "string")
205
+ body.output_language = options.language;
206
+ if (extra.voice) body.voice = extra.voice.slice(0, 6_000);
207
+ if (options["no-voice-profile"]) body.voice_profile = false;
208
+ return body;
209
+ }
@@ -0,0 +1,460 @@
1
+ /**
2
+ * The commands. `run(argv, io)` returns an exit code and never calls
3
+ * process.exit, so it can be tested with a fake io and fetch.
4
+ */
5
+ import { readFile, writeFile } from "node:fs/promises";
6
+ import { basename, extname } from "node:path";
7
+ import { ApiError, EXIT, getUsage, transform } from "./api.js";
8
+ import {
9
+ AUDIENCES,
10
+ isToolCommand,
11
+ parse,
12
+ transformFields,
13
+ UsageError,
14
+ } from "./args.js";
15
+ import {
16
+ configPath,
17
+ looksLikeKey,
18
+ readConfig,
19
+ removeConfig,
20
+ resolveApiUrl,
21
+ resolveKey,
22
+ writeConfig,
23
+ } from "./config.js";
24
+
25
+ /**
26
+ * @typedef {object} Io
27
+ * @property {NodeJS.ProcessEnv} env
28
+ * @property {typeof fetch} fetch
29
+ * @property {{ write(chunk: string): unknown }} stdout
30
+ * @property {{ write(chunk: string): unknown }} stderr
31
+ * @property {NodeJS.ReadableStream & { isTTY?: boolean, setRawMode?: (mode: boolean) => unknown }} stdin
32
+ * @property {string} version
33
+ */
34
+
35
+ /** Keep in step with `src/lib/tools.ts`. */
36
+ export const TOOL_LIST = [
37
+ {
38
+ command: "deslop",
39
+ summary:
40
+ "Strip filler, hedging, recap paragraphs and stock phrases from AI-written text, optionally toward your own voice.",
41
+ },
42
+ {
43
+ command: "check",
44
+ summary:
45
+ "Score a text and mark the sentences that read as machine-written. No rewrite, and free.",
46
+ },
47
+ {
48
+ command: "simplify",
49
+ summary:
50
+ "Rewrite to the reading level of the person who has to understand it, and explain the terms.",
51
+ },
52
+ {
53
+ command: "shorten",
54
+ summary:
55
+ "Cut a document to the length you set without dropping a number, date, condition or deadline.",
56
+ },
57
+ {
58
+ command: "explain",
59
+ summary:
60
+ "Keep the text as it is and add a short plain explanation next to each piece of jargon.",
61
+ },
62
+ ];
63
+
64
+ const MAIN_HELP = `readable: make text readable from the command line (https://readable.md)
65
+
66
+ Usage
67
+ readable <tool> [file|-] [options]
68
+ readable <command> [options]
69
+
70
+ Tools
71
+ ${TOOL_LIST.map((tool) => ` ${tool.command.padEnd(10)}${tool.summary}`).join("\n")}
72
+
73
+ Commands
74
+ login Save an API key (from https://readable.md/account)
75
+ logout Remove the saved key
76
+ usage Show your plan and the pages left this billing cycle
77
+ tools List the tools
78
+ mcp Show how to add Readable to Claude Code, Cursor and other MCP clients
79
+
80
+ Options
81
+ --key <key> API key (else READABLE_API_KEY, else the saved key)
82
+ -h, --help Help for a command: readable deslop --help
83
+ -v, --version Print the version
84
+
85
+ Examples
86
+ readable deslop draft.md > clean.md
87
+ pbpaste | readable check
88
+ readable simplify --url https://example.com/terms --audience customers
89
+ `;
90
+
91
+ const TOOL_HELP = {
92
+ common: `Input
93
+ [file] A text, Markdown or PDF file; "-" or a pipe reads stdin
94
+ --url <url> A web page or PDF to fetch instead
95
+
96
+ Output
97
+ --json Print the full JSON result
98
+ -o, --out <file> Write the Markdown to a file instead of stdout
99
+ -q, --quiet Do not print the summary line and share link
100
+ --key <key> API key
101
+
102
+ The result's Markdown goes to stdout; the summary line and share link go to
103
+ stderr, so piping works: readable deslop draft.md | pbcopy`,
104
+ deslop: ` --voice <file> A sample of your own writing to edit toward
105
+ (replaces your saved voice profile for this run)
106
+ --no-voice-profile Do not edit toward your saved voice profile
107
+ --language <name> Write the result in this language`,
108
+ check: "",
109
+ simplify: ` --audience <id> ${AUDIENCES.join(", ")}
110
+ --level <0-5> 0 very simple … 2 plain (default) … 5 expert
111
+ --reader <text> Describe the reader in your own words
112
+ --language <name> Write the result in this language`,
113
+ shorten: ` --length <0.2-0.9> Share of the original to keep (default 0.5)
114
+ --language <name> Write the result in this language`,
115
+ explain: ` --audience <id> ${AUDIENCES.join(", ")}
116
+ --level <0-5> Level of the explanations (default 2)
117
+ --reader <text> Describe the reader in your own words
118
+ --language <name> Write the result in this language`,
119
+ };
120
+
121
+ const COMMAND_HELP = {
122
+ login: `Usage: readable login [--key <key>]
123
+
124
+ Saves an API key after checking it. Without --key it asks for the key
125
+ (input hidden) or reads it from stdin. Keys: https://readable.md/account`,
126
+ logout: "Usage: readable logout\n\nRemoves the saved key.",
127
+ usage: `Usage: readable usage [--json]
128
+
129
+ Shows your plan and the pages used this billing cycle. The app, the API,
130
+ the MCP server and this CLI share the same pages; a page is 500 words.`,
131
+ tools: "Usage: readable tools\n\nLists the tools.",
132
+ mcp: `Usage: readable mcp
133
+
134
+ Prints the MCP server URL and config for Claude Code, Cursor and other
135
+ clients.`,
136
+ };
137
+
138
+ /** @param {string | null} command */
139
+ export function helpFor(command) {
140
+ if (command && isToolCommand(command)) {
141
+ const tool = TOOL_LIST.find((entry) => entry.command === command);
142
+ const specific = TOOL_HELP[/** @type {keyof typeof TOOL_HELP} */ (command)];
143
+ return `Usage: readable ${command} [file|-] [options]
144
+
145
+ ${tool?.summary}
146
+
147
+ ${specific ? `Options\n${specific}\n\n` : ""}${TOOL_HELP.common}
148
+ `;
149
+ }
150
+ if (command && command in COMMAND_HELP)
151
+ return `${COMMAND_HELP[/** @type {keyof typeof COMMAND_HELP} */ (command)]}\n`;
152
+ return MAIN_HELP;
153
+ }
154
+
155
+ /**
156
+ * @param {NodeJS.ReadableStream} stream
157
+ * @returns {Promise<string>}
158
+ */
159
+ export async function readAll(stream) {
160
+ const chunks = [];
161
+ for await (const chunk of stream)
162
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
163
+ return Buffer.concat(chunks).toString("utf8");
164
+ }
165
+
166
+ /**
167
+ * Ask for a secret on a terminal without echoing it.
168
+ * @param {Io} io
169
+ * @param {string} question
170
+ * @returns {Promise<string>}
171
+ */
172
+ export function promptHidden(io, question) {
173
+ const { stdin } = io;
174
+ io.stderr.write(question);
175
+ return new Promise((resolve, reject) => {
176
+ let value = "";
177
+ stdin.setRawMode?.(true);
178
+ stdin.resume();
179
+ stdin.setEncoding?.("utf8");
180
+ /** @param {string} chunk */
181
+ const onData = (chunk) => {
182
+ for (const char of chunk) {
183
+ if (char === "\r" || char === "\n" || char === "\u0004") {
184
+ finish();
185
+ resolve(value);
186
+ return;
187
+ }
188
+ if (char === "\u0003") {
189
+ finish();
190
+ reject(new UsageError("Cancelled."));
191
+ return;
192
+ }
193
+ if (char === "\u007f" || char === "\b") value = value.slice(0, -1);
194
+ else value += char;
195
+ }
196
+ };
197
+ const finish = () => {
198
+ stdin.removeListener("data", onData);
199
+ stdin.setRawMode?.(false);
200
+ stdin.pause();
201
+ io.stderr.write("\n");
202
+ };
203
+ stdin.on("data", onData);
204
+ });
205
+ }
206
+
207
+ /**
208
+ * @param {Io} io
209
+ * @param {string | undefined} flag
210
+ */
211
+ async function client(io, flag) {
212
+ const config = await readConfig(io.env);
213
+ const key = resolveKey(flag, io.env, config);
214
+ if (!key)
215
+ throw new ApiError(
216
+ "unauthorized",
217
+ "No API key. Run `readable login`, set READABLE_API_KEY or pass --key. Keys: https://readable.md/account",
218
+ );
219
+ return {
220
+ baseUrl: resolveApiUrl(io.env, config),
221
+ key: key.trim(),
222
+ fetch: io.fetch,
223
+ version: io.version,
224
+ };
225
+ }
226
+
227
+ /**
228
+ * Where the text comes from: a file, stdin or --url.
229
+ * @param {Io} io
230
+ * @param {string | undefined} input
231
+ * @param {Record<string, string | boolean | undefined>} options
232
+ * @returns {Promise<{ text?: string, pdf?: { name: string, bytes: Uint8Array } }>}
233
+ */
234
+ export async function readInput(io, input, options) {
235
+ if (options.url) return {};
236
+ if (input === "-" || (!input && !io.stdin.isTTY)) {
237
+ const text = await readAll(io.stdin);
238
+ if (!text.trim()) throw new UsageError("Nothing on stdin.");
239
+ return { text };
240
+ }
241
+ if (!input)
242
+ throw new UsageError(
243
+ "Give a file, pipe text in, or pass --url. Example: readable deslop draft.md",
244
+ );
245
+ let bytes;
246
+ try {
247
+ bytes = await readFile(input);
248
+ } catch {
249
+ throw new UsageError(`Cannot read ${input}.`);
250
+ }
251
+ if (extname(input).toLowerCase() === ".pdf")
252
+ return { pdf: { name: basename(input), bytes: new Uint8Array(bytes) } };
253
+ return { text: bytes.toString("utf8") };
254
+ }
255
+
256
+ /**
257
+ * @param {Io} io
258
+ * @param {import("./args.js").Parsed} parsed
259
+ */
260
+ async function runTool(io, parsed) {
261
+ const { options } = parsed;
262
+ const input = await readInput(io, parsed.input, options);
263
+ let voice;
264
+ if (typeof options.voice === "string") {
265
+ try {
266
+ voice = await readFile(options.voice, "utf8");
267
+ } catch {
268
+ throw new UsageError(`Cannot read the voice sample ${options.voice}.`);
269
+ }
270
+ }
271
+ const fields = transformFields(
272
+ /** @type {import("./args.js").ToolCommand} */ (parsed.command),
273
+ options,
274
+ { voice },
275
+ );
276
+ const result = await transform(await client(io, parsed.key), fields, input);
277
+ if (options.json) {
278
+ io.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
279
+ return EXIT.ok;
280
+ }
281
+ const markdown = String(result.markdown ?? "");
282
+ if (typeof options.out === "string") {
283
+ await writeFile(options.out, markdown);
284
+ if (!options.quiet) io.stderr.write(`Wrote ${options.out}\n`);
285
+ } else io.stdout.write(markdown.endsWith("\n") ? markdown : `${markdown}\n`);
286
+ if (!options.quiet) {
287
+ if (result.summary) io.stderr.write(`${result.summary}\n`);
288
+ if (result.share_url) io.stderr.write(`${result.share_url}\n`);
289
+ }
290
+ return EXIT.ok;
291
+ }
292
+
293
+ /**
294
+ * @param {Io} io
295
+ * @param {import("./args.js").Parsed} parsed
296
+ */
297
+ async function login(io, parsed) {
298
+ let key = parsed.key;
299
+ if (!key) {
300
+ if (io.stdin.isTTY) {
301
+ io.stderr.write(
302
+ "Create a key at https://readable.md/account, then paste it here.\n",
303
+ );
304
+ key = await promptHidden(io, "API key: ");
305
+ } else key = (await readAll(io.stdin)).split(/\r?\n/)[0];
306
+ }
307
+ key = (key ?? "").trim();
308
+ if (!key) throw new UsageError("No key given.");
309
+ if (!looksLikeKey(key))
310
+ throw new UsageError(
311
+ "That is not a Readable API key. Keys start with rdbl_ followed by 64 hex characters.",
312
+ );
313
+ const config = await readConfig(io.env);
314
+ const usage = await getUsage({
315
+ baseUrl: resolveApiUrl(io.env, config),
316
+ key,
317
+ fetch: io.fetch,
318
+ version: io.version,
319
+ });
320
+ const path = await writeConfig(io.env, { ...config, api_key: key });
321
+ io.stderr.write(
322
+ `Logged in (${usage.plan ?? "unknown"} plan). Key saved to ${path}\n`,
323
+ );
324
+ return EXIT.ok;
325
+ }
326
+
327
+ /** @param {Io} io */
328
+ async function logout(io) {
329
+ const config = await readConfig(io.env);
330
+ if (!config.api_key) {
331
+ io.stderr.write("No saved key.\n");
332
+ return EXIT.ok;
333
+ }
334
+ const { api_key: _removed, ...rest } = config;
335
+ if (Object.keys(rest).length) await writeConfig(io.env, rest);
336
+ else await removeConfig(io.env);
337
+ io.stderr.write(`Removed the key from ${configPath(io.env)}\n`);
338
+ return EXIT.ok;
339
+ }
340
+
341
+ /** @param {unknown} value */
342
+ const count = (value) =>
343
+ typeof value === "number" ? value.toLocaleString("en") : "unlimited";
344
+
345
+ /**
346
+ * @param {Io} io
347
+ * @param {import("./args.js").Parsed} parsed
348
+ */
349
+ async function usage(io, parsed) {
350
+ const data = await getUsage(await client(io, parsed.key));
351
+ if (parsed.options.json) {
352
+ io.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
353
+ return EXIT.ok;
354
+ }
355
+ const lines = [
356
+ `Plan: ${data.plan}`,
357
+ `Pages: ${count(data.pages_used)} of ${count(data.pages_limit)} used this billing cycle`,
358
+ ];
359
+ if (data.renews_at)
360
+ lines.push(
361
+ `Renews: ${new Date(data.renews_at).toISOString().slice(0, 10)}`,
362
+ );
363
+ if (data.words_per_page)
364
+ lines.push(
365
+ `A page: ${count(data.words_per_page)} words, up to ${count(data.pages_per_run)} pages at once`,
366
+ );
367
+ io.stdout.write(`${lines.join("\n")}\n`);
368
+ return EXIT.ok;
369
+ }
370
+
371
+ /** @param {Io} io */
372
+ function tools(io) {
373
+ io.stdout.write(
374
+ `${TOOL_LIST.map((tool) => `${tool.command.padEnd(10)}${tool.summary}`).join("\n")}\n`,
375
+ );
376
+ return EXIT.ok;
377
+ }
378
+
379
+ /** @param {string} baseUrl */
380
+ export function mcpText(baseUrl) {
381
+ const url = `${baseUrl}/mcp`;
382
+ const cursor = {
383
+ mcpServers: {
384
+ readable: { url, headers: { Authorization: "Bearer <key>" } },
385
+ },
386
+ };
387
+ const generic = {
388
+ mcpServers: {
389
+ readable: {
390
+ type: "http",
391
+ url,
392
+ headers: { Authorization: "Bearer <key>" },
393
+ },
394
+ },
395
+ };
396
+ return `Readable MCP server (Streamable HTTP): ${url}
397
+ Authenticate with an API key from https://readable.md/account as
398
+ "Authorization: Bearer <key>".
399
+
400
+ Claude Code
401
+ claude mcp add --transport http readable ${url} --header "Authorization: Bearer <key>"
402
+
403
+ Cursor (~/.cursor/mcp.json)
404
+ ${JSON.stringify(cursor, null, 2)}
405
+
406
+ Other clients (VS Code, Claude Desktop via config, Windsurf, …)
407
+ ${JSON.stringify(generic, null, 2)}
408
+
409
+ Tools: deslop, slop_check, simplify, shorten, explain.
410
+ `;
411
+ }
412
+
413
+ /**
414
+ * @param {string[]} argv
415
+ * @param {Io} io
416
+ * @returns {Promise<number>}
417
+ */
418
+ export async function run(argv, io) {
419
+ try {
420
+ const parsed = parse(argv);
421
+ if (parsed.version) {
422
+ io.stdout.write(`${io.version}\n`);
423
+ return EXIT.ok;
424
+ }
425
+ if (parsed.help) {
426
+ io.stdout.write(helpFor(parsed.command));
427
+ return EXIT.ok;
428
+ }
429
+ switch (parsed.command) {
430
+ case "login":
431
+ return await login(io, parsed);
432
+ case "logout":
433
+ return await logout(io);
434
+ case "usage":
435
+ return await usage(io, parsed);
436
+ case "tools":
437
+ return tools(io);
438
+ case "mcp":
439
+ io.stdout.write(
440
+ mcpText(resolveApiUrl(io.env, await readConfig(io.env))),
441
+ );
442
+ return EXIT.ok;
443
+ default:
444
+ return await runTool(io, parsed);
445
+ }
446
+ } catch (caught) {
447
+ if (caught instanceof UsageError) {
448
+ io.stderr.write(`readable: ${caught.message}\n`);
449
+ return EXIT.usage;
450
+ }
451
+ if (caught instanceof ApiError) {
452
+ io.stderr.write(`readable: ${caught.message}\n`);
453
+ return caught.exitCode;
454
+ }
455
+ io.stderr.write(
456
+ `readable: ${/** @type {Error} */ (caught)?.message ?? caught}\n`,
457
+ );
458
+ return EXIT.usage;
459
+ }
460
+ }
package/src/config.js ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Config file and environment: where the key and the API live.
3
+ */
4
+ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
5
+ import { homedir } from "node:os";
6
+ import { dirname, join } from "node:path";
7
+
8
+ export const DEFAULT_API_URL = "https://readable.md";
9
+
10
+ /**
11
+ * @typedef {object} Config
12
+ * @property {string} [api_key]
13
+ * @property {string} [api_url]
14
+ */
15
+
16
+ /** @param {NodeJS.ProcessEnv} env */
17
+ export function configPath(env) {
18
+ const base = env.XDG_CONFIG_HOME || join(env.HOME || homedir(), ".config");
19
+ return join(base, "readable", "config.json");
20
+ }
21
+
22
+ /**
23
+ * @param {NodeJS.ProcessEnv} env
24
+ * @returns {Promise<Config>}
25
+ */
26
+ export async function readConfig(env) {
27
+ try {
28
+ const parsed = JSON.parse(await readFile(configPath(env), "utf8"));
29
+ return parsed && typeof parsed === "object" ? parsed : {};
30
+ } catch {
31
+ return {};
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Write the config readable by its owner only.
37
+ * @param {NodeJS.ProcessEnv} env
38
+ * @param {Config} config
39
+ */
40
+ export async function writeConfig(env, config) {
41
+ const path = configPath(env);
42
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
43
+ await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, {
44
+ mode: 0o600,
45
+ });
46
+ await chmod(path, 0o600);
47
+ return path;
48
+ }
49
+
50
+ /** @param {NodeJS.ProcessEnv} env */
51
+ export async function removeConfig(env) {
52
+ const path = configPath(env);
53
+ await rm(path, { force: true });
54
+ return path;
55
+ }
56
+
57
+ /**
58
+ * The key to use: `--key`, then READABLE_API_KEY, then the config file.
59
+ * @param {string | undefined} flag
60
+ * @param {NodeJS.ProcessEnv} env
61
+ * @param {Config} config
62
+ */
63
+ export function resolveKey(flag, env, config) {
64
+ return flag || env.READABLE_API_KEY || config.api_key || undefined;
65
+ }
66
+
67
+ /**
68
+ * @param {NodeJS.ProcessEnv} env
69
+ * @param {Config} config
70
+ */
71
+ export function resolveApiUrl(env, config) {
72
+ return (env.READABLE_API_URL || config.api_url || DEFAULT_API_URL).replace(
73
+ /\/+$/,
74
+ "",
75
+ );
76
+ }
77
+
78
+ /** @param {string} key */
79
+ export function looksLikeKey(key) {
80
+ return /^rdbl_[0-9a-f]{64}$/i.test(key.trim());
81
+ }