readthemanual 0.0.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/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "readthemanual",
3
+ "version": "0.0.1",
4
+ "description": "Read the Manual — fast local full-text search for any project's docs",
5
+ "main": "dist/cli.js",
6
+ "bin": {
7
+ "rtm": "dist/cli.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc",
11
+ "dev": "tsx src/cli.ts",
12
+ "start": "node dist/cli.js"
13
+ },
14
+ "keywords": [],
15
+ "author": "",
16
+ "license": "ISC",
17
+ "type": "commonjs",
18
+ "dependencies": {
19
+ "@hono/node-server": "^1.19.9",
20
+ "@modelcontextprotocol/sdk": "^1.27.1",
21
+ "better-sqlite3": "^12.6.2",
22
+ "commander": "^14.0.3",
23
+ "hono": "^4.12.3",
24
+ "zod": "^4.3.6"
25
+ },
26
+ "devDependencies": {
27
+ "@types/better-sqlite3": "^7.6.13",
28
+ "@types/node": "^25.3.3",
29
+ "tsx": "^4.21.0",
30
+ "typescript": "^5.9.3"
31
+ }
32
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { ingest } from "./ingest";
4
+ import { startServer } from "./server";
5
+ import { startMcp } from "./mcp";
6
+ import { openDb, search, getDocument, listDocuments } from "./db";
7
+ import {
8
+ loadConfig, getConfigPath, getDbPath, getDefaultSource,
9
+ addSource, removeSource, setLastSource, isGlobal,
10
+ } from "./config";
11
+ import path from "path";
12
+
13
+ const config = loadConfig();
14
+ const defaultDb = getDbPath(config);
15
+
16
+ const program = new Command();
17
+
18
+ program
19
+ .name("rtm")
20
+ .description("Read the Manual — fast local full-text search for documentation")
21
+ .version("0.0.1");
22
+
23
+ program
24
+ .command("ingest")
25
+ .description("Fetch and index docs from configured GitHub sources")
26
+ .option("-d, --db <path>", "SQLite database path", defaultDb)
27
+ .option("-s, --source <name>", "Ingest only this source")
28
+ .option("--all-langs", "Index all languages, not just English", false)
29
+ .option("-v, --verbose", "Verbose output", false)
30
+ .action(async (opts) => {
31
+ const result = await ingest({
32
+ dbPath: opts.db,
33
+ source: opts.source,
34
+ allLangs: opts.allLangs,
35
+ verbose: opts.verbose,
36
+ });
37
+ // Update last source if a specific one was ingested
38
+ if (opts.source) {
39
+ setLastSource(opts.source);
40
+ }
41
+ console.log(
42
+ `Indexed ${result.filesProcessed} files (${result.sectionsInserted} sections).`
43
+ );
44
+ });
45
+
46
+ program
47
+ .command("serve")
48
+ .description("Start the JSON-RPC HTTP server")
49
+ .option("-d, --db <path>", "SQLite database path", defaultDb)
50
+ .option("-p, --port <number>", "Port to listen on", "3777")
51
+ .action((opts) => {
52
+ startServer({ dbPath: opts.db, port: parseInt(opts.port, 10) });
53
+ });
54
+
55
+ program
56
+ .command("mcp")
57
+ .description("Start as an MCP server over stdio")
58
+ .option("-d, --db <path>", "SQLite database path", defaultDb)
59
+ .action((opts) => {
60
+ startMcp({ dbPath: opts.db });
61
+ });
62
+
63
+ program
64
+ .command("search <query>")
65
+ .description("Search the docs index")
66
+ .option("-d, --db <path>", "SQLite database path", defaultDb)
67
+ .option("-s, --source <name>", "Search only this source")
68
+ .option("-l, --lang <language>", "Filter by language")
69
+ .option("-n, --limit <number>", "Max results", "10")
70
+ .option("--json", "Output raw JSON", false)
71
+ .action((query, opts) => {
72
+ const cfg = loadConfig();
73
+ const db = openDb(opts.db);
74
+
75
+ // Determine source scope: explicit flag > lastSource > all
76
+ const sourceName = opts.source ?? getDefaultSource(cfg);
77
+
78
+ const { results, total } = search(db, query, {
79
+ language: opts.lang,
80
+ limit: parseInt(opts.limit, 10),
81
+ sourcePrefix: sourceName ? `${sourceName}/` : undefined,
82
+ });
83
+ db.close();
84
+ if (opts.json) {
85
+ console.log(JSON.stringify({ results, total }, null, 2));
86
+ return;
87
+ }
88
+ if (results.length === 0) {
89
+ console.log("No results found.");
90
+ return;
91
+ }
92
+ for (const r of results) {
93
+ const fullPath = path.join(cfg.dataDir, r.file_path);
94
+ const snippet = r.snippet.replace(/<\/?mark>/g, (m) => m === "<mark>" ? "\x1b[1;33m" : "\x1b[0m");
95
+ console.log(`\x1b[36m[${r.id}]\x1b[0m \x1b[1m${r.heading}\x1b[0m`);
96
+ console.log(` ${fullPath} (${r.language})`);
97
+ console.log(` ${snippet}`);
98
+ console.log();
99
+ }
100
+ if (sourceName) {
101
+ console.log(`${results.length} of ${total} match(es) [source: ${sourceName}]`);
102
+ } else {
103
+ console.log(`${results.length} of ${total} match(es)`);
104
+ }
105
+ });
106
+
107
+ program
108
+ .command("get <id>")
109
+ .description("Get a document section by ID")
110
+ .option("-d, --db <path>", "SQLite database path", defaultDb)
111
+ .option("--json", "Output raw JSON", false)
112
+ .action((id, opts) => {
113
+ const db = openDb(opts.db);
114
+ const doc = getDocument(db, parseInt(id, 10));
115
+ db.close();
116
+ if (!doc) {
117
+ console.error(`Document ${id} not found.`);
118
+ process.exit(1);
119
+ }
120
+ if (opts.json) {
121
+ console.log(JSON.stringify(doc, null, 2));
122
+ return;
123
+ }
124
+ const cfg = loadConfig();
125
+ const fullPath = path.join(cfg.dataDir, doc.file_path);
126
+ console.log(`\x1b[1m${doc.heading}\x1b[0m`);
127
+ console.log(`${fullPath} (${doc.language})`);
128
+ console.log();
129
+ console.log(doc.content);
130
+ });
131
+
132
+ program
133
+ .command("list")
134
+ .description("List indexed documents")
135
+ .option("-d, --db <path>", "SQLite database path", defaultDb)
136
+ .option("-l, --lang <language>", "Filter by language")
137
+ .option("-n, --limit <number>", "Max results", "50")
138
+ .option("-o, --offset <number>", "Offset", "0")
139
+ .option("--json", "Output raw JSON", false)
140
+ .action((opts) => {
141
+ const db = openDb(opts.db);
142
+ const result = listDocuments(db, {
143
+ language: opts.lang,
144
+ limit: parseInt(opts.limit, 10),
145
+ offset: parseInt(opts.offset, 10),
146
+ });
147
+ db.close();
148
+ if (opts.json) {
149
+ console.log(JSON.stringify(result, null, 2));
150
+ return;
151
+ }
152
+ const cfg = loadConfig();
153
+ for (const d of result.docs) {
154
+ const fullPath = path.join(cfg.dataDir, d.file_path);
155
+ console.log(`\x1b[36m[${d.id}]\x1b[0m ${fullPath} — ${d.heading} (${d.language})`);
156
+ }
157
+ console.log(`\nShowing ${result.docs.length} of ${result.total} total sections`);
158
+ });
159
+
160
+ program
161
+ .command("add <name> <repo>")
162
+ .description("Add a GitHub repo as a doc source (repo = owner/repo)")
163
+ .option("-b, --branch <branch>", "Branch to index", "main")
164
+ .option("-p, --prefix <prefix>", "Path prefix for docs in the repo", "docs/")
165
+ .action((name, repo, opts) => {
166
+ addSource({ name, repo, branch: opts.branch, docsPrefix: opts.prefix });
167
+ console.log(`Added source "${name}" → ${repo}@${opts.branch} (prefix: ${opts.prefix})`);
168
+ console.log(`Run \`rtm ingest -s ${name}\` to index it.`);
169
+ });
170
+
171
+ program
172
+ .command("remove <name>")
173
+ .description("Remove a doc source")
174
+ .action((name) => {
175
+ if (removeSource(name)) {
176
+ console.log(`Removed source "${name}".`);
177
+ } else {
178
+ console.error(`Source "${name}" not found.`);
179
+ process.exit(1);
180
+ }
181
+ });
182
+
183
+ program
184
+ .command("use <name>")
185
+ .description("Set the default source for searches")
186
+ .action((name) => {
187
+ const cfg = loadConfig();
188
+ if (!cfg.sources.some((s) => s.name === name)) {
189
+ const available = cfg.sources.map((s) => s.name).join(", ");
190
+ console.error(`Source "${name}" not found. Available: ${available}`);
191
+ process.exit(1);
192
+ }
193
+ setLastSource(name);
194
+ console.log(`Default source set to "${name}".`);
195
+ });
196
+
197
+ program
198
+ .command("config")
199
+ .description("Show config file path and contents")
200
+ .action(() => {
201
+ const cfg = loadConfig();
202
+ console.log(`Config: ${getConfigPath()}`);
203
+ console.log(`Data: ${cfg.dataDir}`);
204
+ console.log(`Install: ${isGlobal() ? "global (/usr/local)" : "local (~/.local)"}`);
205
+ console.log(`Default: ${getDefaultSource(cfg) ?? "(none)"}`);
206
+ console.log();
207
+ console.log(`Sources (${cfg.sources.length}):`);
208
+ for (const s of cfg.sources) {
209
+ const marker = s.name === getDefaultSource(cfg) ? " *" : "";
210
+ console.log(` - ${s.name}: ${s.repo}@${s.branch ?? "main"} (prefix: ${s.docsPrefix ?? "docs/"})${marker}`);
211
+ }
212
+ });
213
+
214
+ // Default: bare args with no subcommand → search
215
+ const knownCommands = ["ingest", "serve", "mcp", "search", "get", "list", "add", "remove", "use", "config", "help"];
216
+ const firstArg = process.argv[2];
217
+ if (firstArg && !firstArg.startsWith("-") && !knownCommands.includes(firstArg)) {
218
+ process.argv.splice(2, 0, "search");
219
+ }
220
+
221
+ program.parse();
package/src/config.ts ADDED
@@ -0,0 +1,143 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+
5
+ function isGlobalInstall(): boolean {
6
+ return __dirname.startsWith("/usr/local");
7
+ }
8
+
9
+ // Data always lives in user's home — it's user data, not system data
10
+ const DATA_DIR = path.join(os.homedir(), ".local", "rtm");
11
+ const CONFIG_PATH = path.join(DATA_DIR, "config.json");
12
+
13
+ export interface SourceConfig {
14
+ name: string;
15
+ repo: string;
16
+ branch?: string;
17
+ docsPrefix?: string;
18
+ }
19
+
20
+ export interface Config {
21
+ sources: SourceConfig[];
22
+ dataDir: string;
23
+ lastSource?: string;
24
+ }
25
+
26
+ const DEFAULT_CONFIG: Config = {
27
+ sources: [
28
+ {
29
+ name: "zeroclaw",
30
+ repo: "zeroclaw-labs/zeroclaw",
31
+ branch: "main",
32
+ docsPrefix: "docs/",
33
+ },
34
+ ],
35
+ dataDir: DATA_DIR,
36
+ lastSource: "zeroclaw",
37
+ };
38
+
39
+ export function getConfigPath(): string {
40
+ return CONFIG_PATH;
41
+ }
42
+
43
+ export function isGlobal(): boolean {
44
+ return isGlobalInstall();
45
+ }
46
+
47
+ function hasWriteAccess(dir: string): boolean {
48
+ // Walk up to find the first existing ancestor and check write permission
49
+ let check = dir;
50
+ while (!fs.existsSync(check)) {
51
+ const parent = path.dirname(check);
52
+ if (parent === check) return false;
53
+ check = parent;
54
+ }
55
+ try {
56
+ fs.accessSync(check, fs.constants.W_OK);
57
+ return true;
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ export function checkWriteAccess(): void {
64
+ if (!hasWriteAccess(DATA_DIR)) {
65
+ console.error(`Error: No write permission to ${DATA_DIR}\nCheck directory permissions.`);
66
+ process.exit(1);
67
+ }
68
+ }
69
+
70
+ export function loadConfig(): Config {
71
+ if (!fs.existsSync(CONFIG_PATH)) {
72
+ // Return defaults in memory; only write if we have access
73
+ if (hasWriteAccess(DATA_DIR) || hasWriteAccess(path.dirname(DATA_DIR))) {
74
+ fs.mkdirSync(DATA_DIR, { recursive: true });
75
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n", "utf-8");
76
+ }
77
+ return DEFAULT_CONFIG;
78
+ }
79
+ const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
80
+ return {
81
+ sources: raw.sources ?? DEFAULT_CONFIG.sources,
82
+ dataDir: raw.dataDir ?? DATA_DIR,
83
+ lastSource: raw.lastSource,
84
+ };
85
+ }
86
+
87
+ export function getDbPath(config: Config): string {
88
+ return path.join(config.dataDir, "rtm.db");
89
+ }
90
+
91
+ export function getDocsDir(config: Config, sourceName: string): string {
92
+ return path.join(config.dataDir, sourceName);
93
+ }
94
+
95
+ function saveConfig(config: Config): void {
96
+ checkWriteAccess();
97
+ fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
98
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", "utf-8");
99
+ }
100
+
101
+ export function addSource(source: SourceConfig): void {
102
+ const config = loadConfig();
103
+ const existing = config.sources.findIndex((s) => s.name === source.name);
104
+ if (existing >= 0) {
105
+ config.sources[existing] = source;
106
+ } else {
107
+ config.sources.push(source);
108
+ }
109
+ config.lastSource = source.name;
110
+ saveConfig(config);
111
+ }
112
+
113
+ export function removeSource(name: string): boolean {
114
+ const config = loadConfig();
115
+ const before = config.sources.length;
116
+ config.sources = config.sources.filter((s) => s.name !== name);
117
+ if (config.sources.length === before) return false;
118
+ // If we removed the last-used source, reset to the final remaining one
119
+ if (config.lastSource === name) {
120
+ config.lastSource = config.sources.length > 0
121
+ ? config.sources[config.sources.length - 1].name
122
+ : undefined;
123
+ }
124
+ saveConfig(config);
125
+ return true;
126
+ }
127
+
128
+ export function setLastSource(name: string): void {
129
+ const config = loadConfig();
130
+ config.lastSource = name;
131
+ saveConfig(config);
132
+ }
133
+
134
+ export function getDefaultSource(config: Config): string | undefined {
135
+ // Last-used source, or the last one in the list, or undefined
136
+ if (config.lastSource && config.sources.some((s) => s.name === config.lastSource)) {
137
+ return config.lastSource;
138
+ }
139
+ if (config.sources.length > 0) {
140
+ return config.sources[config.sources.length - 1].name;
141
+ }
142
+ return undefined;
143
+ }
package/src/db.ts ADDED
@@ -0,0 +1,207 @@
1
+ import Database from "better-sqlite3";
2
+ import fs from "fs";
3
+ import os from "os";
4
+ import path from "path";
5
+
6
+ const DEFAULT_DB_PATH = path.join(os.homedir(), ".local", "rtm", "rtm.db");
7
+
8
+ export interface DocSection {
9
+ id: number;
10
+ file_path: string;
11
+ heading: string;
12
+ content: string;
13
+ language: string;
14
+ }
15
+
16
+ export interface SearchResult extends DocSection {
17
+ rank: number;
18
+ snippet: string;
19
+ }
20
+
21
+ export function openDb(dbPath: string = DEFAULT_DB_PATH): Database.Database {
22
+ const dir = path.dirname(dbPath);
23
+ if (!fs.existsSync(dir)) {
24
+ try {
25
+ fs.mkdirSync(dir, { recursive: true });
26
+ } catch (err: any) {
27
+ if (err.code === "EACCES") {
28
+ console.error(
29
+ `Error: No write permission to ${dir}\n` +
30
+ `If installed globally, run with sudo or install locally.`
31
+ );
32
+ process.exit(1);
33
+ }
34
+ throw err;
35
+ }
36
+ }
37
+ const db = new Database(dbPath);
38
+ db.pragma("journal_mode = WAL");
39
+ db.pragma("foreign_keys = ON");
40
+ initSchema(db);
41
+ return db;
42
+ }
43
+
44
+ function initSchema(db: Database.Database): void {
45
+ db.exec(`
46
+ CREATE TABLE IF NOT EXISTS docs (
47
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
48
+ file_path TEXT NOT NULL,
49
+ heading TEXT NOT NULL,
50
+ content TEXT NOT NULL,
51
+ language TEXT NOT NULL DEFAULT 'en'
52
+ );
53
+
54
+ CREATE INDEX IF NOT EXISTS idx_docs_path ON docs(file_path);
55
+ CREATE INDEX IF NOT EXISTS idx_docs_lang ON docs(language);
56
+
57
+ CREATE VIRTUAL TABLE IF NOT EXISTS docs_fts USING fts5(
58
+ heading,
59
+ content,
60
+ content='docs',
61
+ content_rowid='id',
62
+ tokenize='porter unicode61'
63
+ );
64
+
65
+ CREATE TRIGGER IF NOT EXISTS docs_ai AFTER INSERT ON docs BEGIN
66
+ INSERT INTO docs_fts(rowid, heading, content)
67
+ VALUES (new.id, new.heading, new.content);
68
+ END;
69
+
70
+ CREATE TRIGGER IF NOT EXISTS docs_ad AFTER DELETE ON docs BEGIN
71
+ INSERT INTO docs_fts(docs_fts, rowid, heading, content)
72
+ VALUES ('delete', old.id, old.heading, old.content);
73
+ END;
74
+
75
+ CREATE TRIGGER IF NOT EXISTS docs_au AFTER UPDATE ON docs BEGIN
76
+ INSERT INTO docs_fts(docs_fts, rowid, heading, content)
77
+ VALUES ('delete', old.id, old.heading, old.content);
78
+ INSERT INTO docs_fts(rowid, heading, content)
79
+ VALUES (new.id, new.heading, new.content);
80
+ END;
81
+ `);
82
+ }
83
+
84
+ export function clearDocs(db: Database.Database): void {
85
+ db.exec("DELETE FROM docs");
86
+ }
87
+
88
+ export function insertSection(
89
+ db: Database.Database,
90
+ section: Omit<DocSection, "id">
91
+ ): number {
92
+ const stmt = db.prepare(
93
+ "INSERT INTO docs (file_path, heading, content, language) VALUES (?, ?, ?, ?)"
94
+ );
95
+ const result = stmt.run(
96
+ section.file_path,
97
+ section.heading,
98
+ section.content,
99
+ section.language
100
+ );
101
+ return Number(result.lastInsertRowid);
102
+ }
103
+
104
+ export interface SearchResponse {
105
+ results: SearchResult[];
106
+ total: number;
107
+ }
108
+
109
+ function sanitizeFtsQuery(query: string): string {
110
+ const tokens = query
111
+ .replace(/"/g, "")
112
+ .split(/\s+/)
113
+ .filter(Boolean);
114
+ if (tokens.length === 0) return '""';
115
+ return tokens.map((t) => `"${t}"`).join(" ");
116
+ }
117
+
118
+ export function search(
119
+ db: Database.Database,
120
+ query: string,
121
+ opts: { language?: string; limit?: number; sourcePrefix?: string } = {}
122
+ ): SearchResponse {
123
+ const limit = opts.limit ?? 20;
124
+ const ftsQuery = sanitizeFtsQuery(query);
125
+
126
+ let countSql = `
127
+ SELECT COUNT(*) as total
128
+ FROM docs_fts
129
+ JOIN docs d ON d.id = docs_fts.rowid
130
+ WHERE docs_fts MATCH ?
131
+ `;
132
+ let sql = `
133
+ SELECT
134
+ d.id, d.file_path, d.heading, d.content, d.language,
135
+ docs_fts.rank AS rank,
136
+ snippet(docs_fts, 1, '<mark>', '</mark>', '...', 40) AS snippet
137
+ FROM docs_fts
138
+ JOIN docs d ON d.id = docs_fts.rowid
139
+ WHERE docs_fts MATCH ?
140
+ `;
141
+ const params: (string | number)[] = [ftsQuery];
142
+ const countParams: (string | number)[] = [ftsQuery];
143
+
144
+ if (opts.sourcePrefix) {
145
+ countSql += " AND d.file_path LIKE ?";
146
+ sql += " AND d.file_path LIKE ?";
147
+ params.push(opts.sourcePrefix + "%");
148
+ countParams.push(opts.sourcePrefix + "%");
149
+ }
150
+
151
+ if (opts.language) {
152
+ countSql += " AND d.language = ?";
153
+ sql += " AND d.language = ?";
154
+ params.push(opts.language);
155
+ countParams.push(opts.language);
156
+ }
157
+
158
+ sql += " ORDER BY docs_fts.rank LIMIT ?";
159
+ params.push(limit);
160
+
161
+ const { total } = db.prepare(countSql).get(...countParams) as { total: number };
162
+ const results = db.prepare(sql).all(...params) as SearchResult[];
163
+
164
+ return { results, total };
165
+ }
166
+
167
+ export function getDocument(
168
+ db: Database.Database,
169
+ id: number
170
+ ): DocSection | null {
171
+ return (
172
+ (db.prepare("SELECT * FROM docs WHERE id = ?").get(id) as
173
+ | DocSection
174
+ | undefined) ?? null
175
+ );
176
+ }
177
+
178
+ export function listDocuments(
179
+ db: Database.Database,
180
+ opts: { language?: string; limit?: number; offset?: number } = {}
181
+ ): { docs: Pick<DocSection, "id" | "file_path" | "heading" | "language">[]; total: number } {
182
+ const limit = opts.limit ?? 100;
183
+ const offset = opts.offset ?? 0;
184
+
185
+ let countSql = "SELECT COUNT(*) as total FROM docs";
186
+ let sql = "SELECT id, file_path, heading, language FROM docs";
187
+ const params: (string | number)[] = [];
188
+ const countParams: string[] = [];
189
+
190
+ if (opts.language) {
191
+ countSql += " WHERE language = ?";
192
+ sql += " WHERE language = ?";
193
+ params.push(opts.language);
194
+ countParams.push(opts.language);
195
+ }
196
+
197
+ sql += " ORDER BY file_path, id LIMIT ? OFFSET ?";
198
+ params.push(limit, offset);
199
+
200
+ const { total } = db.prepare(countSql).get(...countParams) as { total: number };
201
+ const docs = db.prepare(sql).all(...params) as Pick<
202
+ DocSection,
203
+ "id" | "file_path" | "heading" | "language"
204
+ >[];
205
+
206
+ return { docs, total };
207
+ }