readable-cli 0.1.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 ADDED
@@ -0,0 +1,97 @@
1
+ # @readable/cli
2
+
3
+ Publish Markdown pages from your terminal.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g readable-cli
9
+ ```
10
+
11
+ Or use without installing:
12
+
13
+ ```bash
14
+ npx readable-cli publish README.md
15
+ ```
16
+
17
+ ## Authentication
18
+
19
+ Get your API key from [readable.ashwinsathian.com](https://readable.ashwinsathian.com) → My Pages → Settings → API Keys.
20
+
21
+ ```bash
22
+ readable login
23
+ # Paste your API key when prompted, or:
24
+ readable login --key rdbl_xxxxxxxxxxxx
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ ### Publish a file
30
+
31
+ ```bash
32
+ readable publish README.md
33
+ ```
34
+
35
+ ### Publish from stdin
36
+
37
+ ```bash
38
+ cat NOTES.md | readable publish -
39
+ echo "# Hello" | readable publish -
40
+ ```
41
+
42
+ ### Custom slug and visibility
43
+
44
+ ```bash
45
+ readable publish README.md --slug my-readme --visibility unlisted
46
+ ```
47
+
48
+ ### Update an existing page
49
+
50
+ ```bash
51
+ readable publish README.md --update <page-id>
52
+ ```
53
+
54
+ ### Watch and auto-republish on save
55
+
56
+ ```bash
57
+ readable publish README.md --watch
58
+ readable publish README.md --update <id> --watch
59
+ ```
60
+
61
+ ### List your pages
62
+
63
+ ```bash
64
+ readable pages list
65
+ readable pages list --json
66
+ ```
67
+
68
+ ### Delete a page
69
+
70
+ ```bash
71
+ readable pages delete <id>
72
+ readable pages delete <id> --yes # skip confirmation
73
+ ```
74
+
75
+ ## Environment variables
76
+
77
+ | Variable | Description |
78
+ | ------------------- | --------------------------------------------- |
79
+ | `READABLE_API_KEY` | API key (overrides `~/.readable/config.json`) |
80
+ | `READABLE_API_URL` | API base URL (default: production) |
81
+ | `NO_COLOR` | Disable ANSI colour output |
82
+
83
+ ## Frontmatter
84
+
85
+ Frontmatter in your Markdown is parsed and applied automatically:
86
+
87
+ ```markdown
88
+ ---
89
+ title: My Page
90
+ slug: my-page
91
+ visibility: unlisted
92
+ ---
93
+
94
+ # My Page
95
+
96
+ Content here…
97
+ ```
package/dist/api.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ export type ApiResponse<T> = {
2
+ ok: true;
3
+ status: number;
4
+ data: T;
5
+ } | {
6
+ ok: false;
7
+ status: number;
8
+ error: string;
9
+ };
10
+ export declare function apiRequest<T>(path: string, options?: {
11
+ method?: string;
12
+ body?: unknown;
13
+ }): Promise<ApiResponse<T>>;
package/dist/api.js ADDED
@@ -0,0 +1,43 @@
1
+ import { getApiKey, getApiBase } from "./config.js";
2
+ export async function apiRequest(path, options = {}) {
3
+ const key = await getApiKey();
4
+ if (!key) {
5
+ return { ok: false, status: 401, error: "Not authenticated. Run `readable login` to set your API key." };
6
+ }
7
+ const base = await getApiBase();
8
+ const url = `${base}${path}`;
9
+ const headers = {
10
+ Authorization: `Bearer ${key}`,
11
+ "X-Readable-Source": "cli",
12
+ };
13
+ if (options.body !== undefined) {
14
+ headers["Content-Type"] = "application/json";
15
+ }
16
+ let res;
17
+ try {
18
+ res = await fetch(url, {
19
+ method: options.method ?? "GET",
20
+ headers,
21
+ body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
22
+ });
23
+ }
24
+ catch (e) {
25
+ const msg = e instanceof Error ? e.message : String(e);
26
+ return { ok: false, status: 0, error: `Network error: ${msg}` };
27
+ }
28
+ let data;
29
+ const ct = res.headers.get("content-type") ?? "";
30
+ try {
31
+ data = ct.includes("application/json") ? await res.json() : await res.text();
32
+ }
33
+ catch {
34
+ data = null;
35
+ }
36
+ if (!res.ok) {
37
+ const errMsg = data && typeof data === "object" && "error" in data
38
+ ? String(data.error)
39
+ : `HTTP ${res.status}`;
40
+ return { ok: false, status: res.status, error: errMsg };
41
+ }
42
+ return { ok: true, status: res.status, data: data };
43
+ }
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function registerAuthCommands(program: Command): Command;
@@ -0,0 +1,86 @@
1
+ import { readConfig, writeConfig, getApiKey, getApiBase } from "../config.js";
2
+ import { apiRequest } from "../api.js";
3
+ import { success, error, info, bold, dim, gray } from "../fmt.js";
4
+ import { createInterface } from "readline";
5
+ function prompt(question) {
6
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
7
+ return new Promise((resolve) => {
8
+ rl.question(question, (answer) => {
9
+ rl.close();
10
+ resolve(answer.trim());
11
+ });
12
+ });
13
+ }
14
+ export function registerAuthCommands(program) {
15
+ const auth = program
16
+ .command("login")
17
+ .description("Save your Readable API key")
18
+ .option("--key <key>", "API key (skip interactive prompt)")
19
+ .option("--api-url <url>", "Override API base URL")
20
+ .action(async (opts) => {
21
+ let key = opts.key;
22
+ if (!key) {
23
+ const base = opts.apiUrl ?? (await (async () => {
24
+ const cfg = await readConfig();
25
+ return cfg.apiBase;
26
+ })());
27
+ console.log();
28
+ console.log(`${bold("Readable CLI")} — authenticate`);
29
+ console.log(dim(`API: ${base}`));
30
+ console.log();
31
+ console.log(`Get your key at: ${bold(`${base}/my-pages`)} → Settings → API Keys`);
32
+ console.log();
33
+ key = await prompt("Paste your API key: ");
34
+ }
35
+ if (!key) {
36
+ error("No key provided.");
37
+ process.exit(1);
38
+ }
39
+ // Validate the key by hitting the pages list endpoint
40
+ const existing = await readConfig();
41
+ const testBase = opts.apiUrl ?? existing.apiBase;
42
+ const testConfig = { ...existing, apiKey: key, apiBase: testBase };
43
+ // Temporarily write so apiRequest can pick it up
44
+ await writeConfig(testConfig);
45
+ const res = await apiRequest("/api/v1/pages");
46
+ if (!res.ok) {
47
+ error(`Invalid key or unreachable server: ${res.error}`);
48
+ // Roll back
49
+ await writeConfig(existing);
50
+ process.exit(1);
51
+ }
52
+ success(`Authenticated. Key saved to ~/.readable/config.json`);
53
+ info(`You have ${res.data.pages.length} page(s).`);
54
+ });
55
+ program
56
+ .command("logout")
57
+ .description("Remove saved API key")
58
+ .action(async () => {
59
+ const config = await readConfig();
60
+ if (!config.apiKey) {
61
+ info("No key stored.");
62
+ return;
63
+ }
64
+ const { apiKey: _removed, ...rest } = config;
65
+ await writeConfig(rest);
66
+ success("Logged out. API key removed.");
67
+ });
68
+ program
69
+ .command("whoami")
70
+ .description("Show the active API key and base URL")
71
+ .action(async () => {
72
+ const key = await getApiKey();
73
+ const base = await getApiBase();
74
+ if (!key) {
75
+ info("Not authenticated. Run `readable login` to set your API key.");
76
+ }
77
+ else {
78
+ const masked = key.length > 8
79
+ ? `${key.slice(0, 4)}${"•".repeat(key.length - 8)}${key.slice(-4)}`
80
+ : "•".repeat(key.length);
81
+ console.log(`${bold("Key:")} ${masked}`);
82
+ console.log(`${bold("Base:")} ${gray(base)}`);
83
+ }
84
+ });
85
+ return auth;
86
+ }
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function registerPagesCommand(program: Command): void;
@@ -0,0 +1,99 @@
1
+ import { apiRequest } from "../api.js";
2
+ import { success, error, info, bold, dim, gray } from "../fmt.js";
3
+ import { table } from "../fmt.js";
4
+ import { createInterface } from "readline";
5
+ function formatDate(iso) {
6
+ try {
7
+ return new Date(iso).toLocaleDateString("en-GB", {
8
+ year: "numeric",
9
+ month: "short",
10
+ day: "numeric",
11
+ });
12
+ }
13
+ catch {
14
+ return iso;
15
+ }
16
+ }
17
+ async function confirm(question) {
18
+ if (!process.stdin.isTTY)
19
+ return false;
20
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
21
+ return new Promise((resolve) => {
22
+ rl.question(`${question} [y/N] `, (ans) => {
23
+ rl.close();
24
+ resolve(ans.trim().toLowerCase() === "y");
25
+ });
26
+ });
27
+ }
28
+ export function registerPagesCommand(program) {
29
+ const pages = program
30
+ .command("pages")
31
+ .description("Manage your Readable pages");
32
+ pages
33
+ .command("list")
34
+ .description("List all your pages")
35
+ .option("--json", "Output raw JSON")
36
+ .action(async (opts) => {
37
+ const res = await apiRequest("/api/v1/pages");
38
+ if (!res.ok) {
39
+ error(res.error);
40
+ process.exit(1);
41
+ }
42
+ const items = res.data.pages;
43
+ if (opts.json) {
44
+ console.log(JSON.stringify(items, null, 2));
45
+ return;
46
+ }
47
+ if (items.length === 0) {
48
+ info("No pages yet. Run `readable publish <file>` to create one.");
49
+ return;
50
+ }
51
+ console.log();
52
+ table(["Title", "ID / Slug", "Visibility", "Views", "Updated"], items.map((p) => [
53
+ p.title ?? dim("Untitled"),
54
+ p.slug ?? gray(p.id),
55
+ p.visibility,
56
+ p.view_count,
57
+ formatDate(p.updated_at),
58
+ ]));
59
+ console.log();
60
+ info(`${items.length} page${items.length === 1 ? "" : "s"}`);
61
+ });
62
+ pages
63
+ .command("open <id>")
64
+ .description("Print the URL of a page")
65
+ .action(async (id) => {
66
+ const res = await apiRequest("/api/v1/pages");
67
+ if (!res.ok) {
68
+ error(res.error);
69
+ process.exit(1);
70
+ }
71
+ const page = res.data.pages.find((p) => p.id === id || p.slug === id);
72
+ if (!page) {
73
+ error(`Page not found: ${id}`);
74
+ process.exit(1);
75
+ }
76
+ console.log(bold(page.url));
77
+ });
78
+ pages
79
+ .command("delete <id>")
80
+ .description("Delete a page by ID")
81
+ .option("-y, --yes", "Skip confirmation prompt")
82
+ .action(async (id, opts) => {
83
+ if (!opts.yes) {
84
+ const ok = await confirm(`Delete page ${bold(id)}?`);
85
+ if (!ok) {
86
+ info("Aborted.");
87
+ return;
88
+ }
89
+ }
90
+ const res = await apiRequest(`/api/v1/pages/${id}`, {
91
+ method: "DELETE",
92
+ });
93
+ if (!res.ok) {
94
+ error(`Delete failed: ${res.error}`);
95
+ process.exit(1);
96
+ }
97
+ success(`Deleted page ${id}`);
98
+ });
99
+ }
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function registerPublishCommand(program: Command): void;
@@ -0,0 +1,128 @@
1
+ import { readFile, watch } from "fs/promises";
2
+ import { apiRequest } from "../api.js";
3
+ import { success, error, info, warn, bold, dim } from "../fmt.js";
4
+ async function readInput(filePath) {
5
+ if (filePath === "-") {
6
+ // Read from stdin
7
+ const chunks = [];
8
+ for await (const chunk of process.stdin) {
9
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
10
+ }
11
+ return Buffer.concat(chunks).toString("utf8");
12
+ }
13
+ return readFile(filePath, "utf8");
14
+ }
15
+ async function doPublish(raw, opts, isWatch = false) {
16
+ const body = { raw };
17
+ if (opts.visibility)
18
+ body.settings = { visibility: opts.visibility };
19
+ if (opts.update) {
20
+ // PATCH existing page
21
+ const patchBody = { raw };
22
+ if (opts.slug)
23
+ patchBody.slug = opts.slug;
24
+ if (opts.visibility)
25
+ patchBody.visibility = opts.visibility;
26
+ const res = await apiRequest(`/api/v1/pages/${opts.update}`, {
27
+ method: "PATCH",
28
+ body: patchBody,
29
+ });
30
+ if (!res.ok) {
31
+ error(`Update failed: ${res.error}`);
32
+ return null;
33
+ }
34
+ if (isWatch) {
35
+ info(`Updated → ${bold(res.data.url)}`);
36
+ }
37
+ else {
38
+ success(`Updated: ${bold(res.data.url)}`);
39
+ }
40
+ return res.data;
41
+ }
42
+ // POST new page
43
+ const res = await apiRequest("/api/v1/publish", {
44
+ method: "POST",
45
+ body: { raw },
46
+ });
47
+ if (!res.ok) {
48
+ error(`Publish failed: ${res.error}`);
49
+ return null;
50
+ }
51
+ const { id, url } = res.data;
52
+ // Apply post-publish metadata patches
53
+ if (opts.slug || opts.visibility) {
54
+ const metaPatch = {};
55
+ if (opts.slug)
56
+ metaPatch.slug = opts.slug;
57
+ if (opts.visibility)
58
+ metaPatch.visibility = opts.visibility;
59
+ const patchRes = await apiRequest(`/api/v1/pages/${id}`, {
60
+ method: "PATCH",
61
+ body: metaPatch,
62
+ });
63
+ if (!patchRes.ok) {
64
+ warn(`Published but metadata patch failed: ${patchRes.error}`);
65
+ success(`Published: ${bold(url)}`);
66
+ return { id, url };
67
+ }
68
+ success(`Published: ${bold(patchRes.data.url)}`);
69
+ return { id, url: patchRes.data.url };
70
+ }
71
+ success(`Published: ${bold(url)}`);
72
+ console.log(dim(` ID: ${id}`));
73
+ return { id, url };
74
+ }
75
+ export function registerPublishCommand(program) {
76
+ program
77
+ .command("publish [file]")
78
+ .description("Publish a Markdown file (use - for stdin)")
79
+ .option("--slug <slug>", "Set a custom URL slug")
80
+ .option("--visibility <v>", "public or unlisted (default: public)", "public")
81
+ .option("--update <id>", "Update an existing page by ID instead of creating new")
82
+ .option("--watch", "Watch file for changes and re-publish automatically")
83
+ .action(async (file, opts) => {
84
+ const filePath = file ?? "-";
85
+ if (opts.watch && filePath === "-") {
86
+ error("--watch cannot be used with stdin.");
87
+ process.exit(1);
88
+ }
89
+ // Initial publish
90
+ let raw;
91
+ try {
92
+ raw = await readInput(filePath);
93
+ }
94
+ catch (e) {
95
+ error(`Cannot read "${filePath}": ${e instanceof Error ? e.message : String(e)}`);
96
+ process.exit(1);
97
+ }
98
+ const result = await doPublish(raw, opts);
99
+ if (!result)
100
+ process.exit(1);
101
+ if (!opts.watch)
102
+ return;
103
+ // --watch mode: watch file for changes
104
+ const pageId = opts.update ?? result.id;
105
+ info(`Watching ${bold(filePath)} for changes… (Ctrl+C to stop)`);
106
+ console.log();
107
+ try {
108
+ const watcher = watch(filePath);
109
+ for await (const event of watcher) {
110
+ if (event.eventType !== "change")
111
+ continue;
112
+ // Debounce: small delay to let the write finish
113
+ await new Promise((r) => setTimeout(r, 80));
114
+ try {
115
+ const updated = await readFile(filePath, "utf8");
116
+ await doPublish(updated, { ...opts, update: pageId }, true);
117
+ }
118
+ catch (readErr) {
119
+ warn(`Read error: ${readErr instanceof Error ? readErr.message : String(readErr)}`);
120
+ }
121
+ }
122
+ }
123
+ catch (e) {
124
+ error(`Watch failed: ${e instanceof Error ? e.message : String(e)}`);
125
+ process.exit(1);
126
+ }
127
+ });
128
+ }
@@ -0,0 +1,9 @@
1
+ export declare const DEFAULT_API_BASE = "https://readable.ashwinsathian.com";
2
+ export type Config = {
3
+ apiKey?: string;
4
+ apiBase: string;
5
+ };
6
+ export declare function readConfig(): Promise<Config>;
7
+ export declare function writeConfig(config: Config): Promise<void>;
8
+ export declare function getApiKey(): Promise<string | null>;
9
+ export declare function getApiBase(): Promise<string>;
package/dist/config.js ADDED
@@ -0,0 +1,34 @@
1
+ import { homedir } from "os";
2
+ import { join } from "path";
3
+ import { readFile, writeFile, mkdir } from "fs/promises";
4
+ const CONFIG_DIR = join(homedir(), ".readable");
5
+ const CONFIG_PATH = join(CONFIG_DIR, "config.json");
6
+ export const DEFAULT_API_BASE = "https://readable.ashwinsathian.com";
7
+ export async function readConfig() {
8
+ try {
9
+ const raw = await readFile(CONFIG_PATH, "utf8");
10
+ const parsed = JSON.parse(raw);
11
+ return { apiBase: DEFAULT_API_BASE, ...parsed };
12
+ }
13
+ catch {
14
+ return { apiBase: DEFAULT_API_BASE };
15
+ }
16
+ }
17
+ export async function writeConfig(config) {
18
+ await mkdir(CONFIG_DIR, { recursive: true });
19
+ await writeFile(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", "utf8");
20
+ }
21
+ export async function getApiKey() {
22
+ const env = process.env.READABLE_API_KEY;
23
+ if (env)
24
+ return env;
25
+ const config = await readConfig();
26
+ return config.apiKey ?? null;
27
+ }
28
+ export async function getApiBase() {
29
+ const env = process.env.READABLE_API_URL;
30
+ if (env)
31
+ return env.replace(/\/$/, "");
32
+ const config = await readConfig();
33
+ return config.apiBase.replace(/\/$/, "");
34
+ }
package/dist/fmt.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ export declare const dim: (s: string) => string;
2
+ export declare const bold: (s: string) => string;
3
+ export declare const green: (s: string) => string;
4
+ export declare const red: (s: string) => string;
5
+ export declare const yellow: (s: string) => string;
6
+ export declare const cyan: (s: string) => string;
7
+ export declare const gray: (s: string) => string;
8
+ export declare function success(msg: string): void;
9
+ export declare function info(msg: string): void;
10
+ export declare function warn(msg: string): void;
11
+ export declare function error(msg: string): void;
12
+ type Row = (string | number | null | undefined)[];
13
+ export declare function table(headers: string[], rows: Row[]): void;
14
+ export {};
package/dist/fmt.js ADDED
@@ -0,0 +1,41 @@
1
+ // ANSI colour helpers — fall back gracefully when NO_COLOR is set
2
+ const NO_COLOR = Boolean(process.env.NO_COLOR) || !process.stdout.isTTY;
3
+ const c = (code, s) => (NO_COLOR ? s : `\x1b[${code}m${s}\x1b[0m`);
4
+ export const dim = (s) => c(2, s);
5
+ export const bold = (s) => c(1, s);
6
+ export const green = (s) => c(32, s);
7
+ export const red = (s) => c(31, s);
8
+ export const yellow = (s) => c(33, s);
9
+ export const cyan = (s) => c(36, s);
10
+ export const gray = (s) => c(90, s);
11
+ export function success(msg) {
12
+ console.log(`${green("✓")} ${msg}`);
13
+ }
14
+ export function info(msg) {
15
+ console.log(`${cyan("→")} ${msg}`);
16
+ }
17
+ export function warn(msg) {
18
+ console.warn(`${yellow("!")} ${msg}`);
19
+ }
20
+ export function error(msg) {
21
+ console.error(`${red("✗")} ${msg}`);
22
+ }
23
+ export function table(headers, rows) {
24
+ if (rows.length === 0) {
25
+ console.log(dim("(no results)"));
26
+ return;
27
+ }
28
+ // Compute column widths
29
+ const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => String(r[i] ?? "").length)));
30
+ const divider = widths.map((w) => "─".repeat(w + 2)).join("┼");
31
+ const pad = (s, w) => s + " ".repeat(Math.max(0, w - s.length));
32
+ const headerRow = widths
33
+ .map((w, i) => ` ${bold(pad(headers[i], w))} `)
34
+ .join("│");
35
+ const dataRows = rows.map((row) => widths.map((w, i) => ` ${pad(String(row[i] ?? ""), w)} `).join("│"));
36
+ console.log(headerRow);
37
+ console.log(dim(divider));
38
+ for (const row of dataRows) {
39
+ console.log(row);
40
+ }
41
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { registerAuthCommands } from "./commands/auth.js";
4
+ import { registerPublishCommand } from "./commands/publish.js";
5
+ import { registerPagesCommand } from "./commands/pages.js";
6
+ const program = new Command();
7
+ program
8
+ .name("readable")
9
+ .description("Publish Markdown pages from your terminal")
10
+ .version("0.1.0");
11
+ registerAuthCommands(program);
12
+ registerPublishCommand(program);
13
+ registerPagesCommand(program);
14
+ program.parse(process.argv);
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "readable-cli",
3
+ "version": "0.1.1",
4
+ "description": "CLI for Readable — publish Markdown from your terminal",
5
+ "type": "module",
6
+ "bin": {
7
+ "readable": "dist/index.js"
8
+ },
9
+ "main": "./dist/index.js",
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsc",
15
+ "dev": "tsc --watch",
16
+ "prepublishOnly": "npm run build"
17
+ },
18
+ "dependencies": {
19
+ "commander": "^12.1.0"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "^20.19.0",
23
+ "typescript": "^5.7.0"
24
+ },
25
+ "engines": {
26
+ "node": ">=18.0.0"
27
+ },
28
+ "author": {
29
+ "name": "Ashwin Sathian",
30
+ "email": "ashwinsathyan19@gmail.com"
31
+ },
32
+ "license": "MIT",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/AshwinSathian/readable.git"
36
+ },
37
+ "keywords": [
38
+ "readable",
39
+ "markdown",
40
+ "publish",
41
+ "cli"
42
+ ]
43
+ }