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 +88 -0
- package/bin/readable.mjs +16 -0
- package/package.json +19 -0
- package/src/api.js +120 -0
- package/src/args.js +209 -0
- package/src/commands.js +460 -0
- package/src/config.js +81 -0
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 |
|
package/bin/readable.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/commands.js
ADDED
|
@@ -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
|
+
}
|