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/README.md ADDED
@@ -0,0 +1,196 @@
1
+ # rtm
2
+
3
+ **Read the Manual.** Fast, local full-text search for any project's documentation.
4
+
5
+ Point it at a GitHub repo, and it pulls down the markdown, indexes every section with SQLite FTS5, and gives you instant search from the terminal, an HTTP API, or an MCP server your AI tools can talk to.
6
+
7
+ ## Why
8
+
9
+ Documentation lives in repos. Searching it means context-switching to a browser, waiting for GitHub's search, and losing your flow. `rtm` keeps a local, searchable copy of any project's docs — one command to ingest, instant results from your terminal.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ git clone https://github.com/rgr4y/rtm.git
15
+ cd rtm
16
+ npm install && npm run build
17
+ npm link # makes `rtm` available globally
18
+ ```
19
+
20
+ ## Quick start
21
+
22
+ ```bash
23
+ # Add a source (any public GitHub repo with markdown docs)
24
+ rtm add react facebook/react -p docs/
25
+
26
+ # Pull and index the docs
27
+ rtm ingest -s react -v
28
+
29
+ # Search
30
+ rtm "concurrent rendering"
31
+ ```
32
+
33
+ That's it. Results come back with highlighted snippets, section headings, and file paths.
34
+
35
+ ## How it works
36
+
37
+ 1. **Ingest** fetches the repo tree via the GitHub API, downloads every `.md` file under the docs prefix, and saves them locally to `~/.local/rtm/<source>/`
38
+ 2. Each file is split by headings into sections
39
+ 3. Sections are indexed into SQLite with [FTS5](https://www.sqlite.org/fts5.html) using the `porter unicode61` tokenizer — the same stemming and Unicode handling you'd get from a dedicated search engine, in a single file
40
+ 4. **Search** runs FTS5 `MATCH` queries with ranked results and highlighted snippets
41
+
42
+ ## Usage
43
+
44
+ ### Search
45
+
46
+ ```bash
47
+ # Bare query (no subcommand needed)
48
+ rtm "hooks lifecycle"
49
+
50
+ # Scope to a source
51
+ rtm search "routing" -s nextjs
52
+
53
+ # JSON output for scripting
54
+ rtm search "middleware" --json
55
+
56
+ # Limit results
57
+ rtm search "state management" -n 5
58
+ ```
59
+
60
+ ### Manage sources
61
+
62
+ ```bash
63
+ # Add a source
64
+ rtm add <name> <owner/repo> [-b branch] [-p docs-prefix]
65
+
66
+ # Examples
67
+ rtm add nextjs vercel/next.js -p docs/
68
+ rtm add django django/django -p docs/ -b main
69
+ rtm add myproject myorg/myproject -p documentation/
70
+
71
+ # Remove a source
72
+ rtm remove nextjs
73
+
74
+ # Switch default source for searches
75
+ rtm use django
76
+
77
+ # See all configured sources
78
+ rtm config
79
+ ```
80
+
81
+ ### Ingest
82
+
83
+ ```bash
84
+ # Ingest all sources
85
+ rtm ingest -v
86
+
87
+ # Ingest one source
88
+ rtm ingest -s react -v
89
+
90
+ # Include non-English docs
91
+ rtm ingest --all-langs
92
+ ```
93
+
94
+ ### Read a full section
95
+
96
+ ```bash
97
+ # Get a section by its ID (shown in search results)
98
+ rtm get 42
99
+ ```
100
+
101
+ ### List indexed sections
102
+
103
+ ```bash
104
+ rtm list
105
+ rtm list -l en -n 100
106
+ ```
107
+
108
+ ## MCP server
109
+
110
+ Run as an [MCP](https://modelcontextprotocol.io/) stdio server so AI tools can search your docs:
111
+
112
+ ```bash
113
+ rtm mcp
114
+ ```
115
+
116
+ Exposes four tools: `search`, `get_document`, `list_sources`, `list_documents`.
117
+
118
+ Add it to any MCP-compatible client. Example for a `config.toml`:
119
+
120
+ ```toml
121
+ [mcp]
122
+ enabled = true
123
+
124
+ [[mcp.servers]]
125
+ name = "rtm"
126
+ command = "rtm"
127
+ args = ["mcp"]
128
+ ```
129
+
130
+ Or for Claude Code's `settings.json`:
131
+
132
+ ```json
133
+ {
134
+ "mcpServers": {
135
+ "rtm": {
136
+ "command": "rtm",
137
+ "args": ["mcp"]
138
+ }
139
+ }
140
+ }
141
+ ```
142
+
143
+ ## HTTP server
144
+
145
+ For network access or integration with other tools:
146
+
147
+ ```bash
148
+ rtm serve -p 3777
149
+ ```
150
+
151
+ JSON-RPC endpoint at `POST /rpc`:
152
+
153
+ ```bash
154
+ curl -s http://localhost:3777/rpc \
155
+ -H 'Content-Type: application/json' \
156
+ -d '{"jsonrpc":"2.0","id":1,"method":"search","params":{"query":"authentication"}}' | jq
157
+ ```
158
+
159
+ Methods: `search`, `get_document`, `list_documents`.
160
+
161
+ ## Configuration
162
+
163
+ Config lives at `~/.local/rtm/config.json`. Downloaded docs and the search database are stored alongside it. You can edit it directly or use the CLI commands above.
164
+
165
+ ```json
166
+ {
167
+ "sources": [
168
+ {
169
+ "name": "react",
170
+ "repo": "facebook/react",
171
+ "branch": "main",
172
+ "docsPrefix": "docs/"
173
+ }
174
+ ],
175
+ "dataDir": "/home/you/.local/rtm",
176
+ "lastSource": "react"
177
+ }
178
+ ```
179
+
180
+ Set `GITHUB_TOKEN` for private repos or to avoid rate limits:
181
+
182
+ ```bash
183
+ export GITHUB_TOKEN=ghp_...
184
+ ```
185
+
186
+ ## Stack
187
+
188
+ - **[SQLite FTS5](https://www.sqlite.org/fts5.html)** — full-text search with porter stemming
189
+ - **[better-sqlite3](https://github.com/WiseLibs/better-sqlite3)** — synchronous, fast SQLite bindings
190
+ - **[Commander](https://github.com/tj/commander.js)** — CLI framework
191
+ - **[Hono](https://hono.dev/)** — lightweight HTTP server
192
+ - **[@modelcontextprotocol/sdk](https://github.com/modelcontextprotocol/typescript-sdk)** — MCP server implementation
193
+
194
+ ## License
195
+
196
+ ISC
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __importDefault = (this && this.__importDefault) || function (mod) {
4
+ return (mod && mod.__esModule) ? mod : { "default": mod };
5
+ };
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ const commander_1 = require("commander");
8
+ const ingest_1 = require("./ingest");
9
+ const server_1 = require("./server");
10
+ const mcp_1 = require("./mcp");
11
+ const db_1 = require("./db");
12
+ const config_1 = require("./config");
13
+ const path_1 = __importDefault(require("path"));
14
+ const config = (0, config_1.loadConfig)();
15
+ const defaultDb = (0, config_1.getDbPath)(config);
16
+ const program = new commander_1.Command();
17
+ program
18
+ .name("rtm")
19
+ .description("Read the Manual — fast local full-text search for documentation")
20
+ .version("0.0.1");
21
+ program
22
+ .command("ingest")
23
+ .description("Fetch and index docs from configured GitHub sources")
24
+ .option("-d, --db <path>", "SQLite database path", defaultDb)
25
+ .option("-s, --source <name>", "Ingest only this source")
26
+ .option("--all-langs", "Index all languages, not just English", false)
27
+ .option("-v, --verbose", "Verbose output", false)
28
+ .action(async (opts) => {
29
+ const result = await (0, ingest_1.ingest)({
30
+ dbPath: opts.db,
31
+ source: opts.source,
32
+ allLangs: opts.allLangs,
33
+ verbose: opts.verbose,
34
+ });
35
+ // Update last source if a specific one was ingested
36
+ if (opts.source) {
37
+ (0, config_1.setLastSource)(opts.source);
38
+ }
39
+ console.log(`Indexed ${result.filesProcessed} files (${result.sectionsInserted} sections).`);
40
+ });
41
+ program
42
+ .command("serve")
43
+ .description("Start the JSON-RPC HTTP server")
44
+ .option("-d, --db <path>", "SQLite database path", defaultDb)
45
+ .option("-p, --port <number>", "Port to listen on", "3777")
46
+ .action((opts) => {
47
+ (0, server_1.startServer)({ dbPath: opts.db, port: parseInt(opts.port, 10) });
48
+ });
49
+ program
50
+ .command("mcp")
51
+ .description("Start as an MCP server over stdio")
52
+ .option("-d, --db <path>", "SQLite database path", defaultDb)
53
+ .action((opts) => {
54
+ (0, mcp_1.startMcp)({ dbPath: opts.db });
55
+ });
56
+ program
57
+ .command("search <query>")
58
+ .description("Search the docs index")
59
+ .option("-d, --db <path>", "SQLite database path", defaultDb)
60
+ .option("-s, --source <name>", "Search only this source")
61
+ .option("-l, --lang <language>", "Filter by language")
62
+ .option("-n, --limit <number>", "Max results", "10")
63
+ .option("--json", "Output raw JSON", false)
64
+ .action((query, opts) => {
65
+ const cfg = (0, config_1.loadConfig)();
66
+ const db = (0, db_1.openDb)(opts.db);
67
+ // Determine source scope: explicit flag > lastSource > all
68
+ const sourceName = opts.source ?? (0, config_1.getDefaultSource)(cfg);
69
+ const { results, total } = (0, db_1.search)(db, query, {
70
+ language: opts.lang,
71
+ limit: parseInt(opts.limit, 10),
72
+ sourcePrefix: sourceName ? `${sourceName}/` : undefined,
73
+ });
74
+ db.close();
75
+ if (opts.json) {
76
+ console.log(JSON.stringify({ results, total }, null, 2));
77
+ return;
78
+ }
79
+ if (results.length === 0) {
80
+ console.log("No results found.");
81
+ return;
82
+ }
83
+ for (const r of results) {
84
+ const fullPath = path_1.default.join(cfg.dataDir, r.file_path);
85
+ const snippet = r.snippet.replace(/<\/?mark>/g, (m) => m === "<mark>" ? "\x1b[1;33m" : "\x1b[0m");
86
+ console.log(`\x1b[36m[${r.id}]\x1b[0m \x1b[1m${r.heading}\x1b[0m`);
87
+ console.log(` ${fullPath} (${r.language})`);
88
+ console.log(` ${snippet}`);
89
+ console.log();
90
+ }
91
+ if (sourceName) {
92
+ console.log(`${results.length} of ${total} match(es) [source: ${sourceName}]`);
93
+ }
94
+ else {
95
+ console.log(`${results.length} of ${total} match(es)`);
96
+ }
97
+ });
98
+ program
99
+ .command("get <id>")
100
+ .description("Get a document section by ID")
101
+ .option("-d, --db <path>", "SQLite database path", defaultDb)
102
+ .option("--json", "Output raw JSON", false)
103
+ .action((id, opts) => {
104
+ const db = (0, db_1.openDb)(opts.db);
105
+ const doc = (0, db_1.getDocument)(db, parseInt(id, 10));
106
+ db.close();
107
+ if (!doc) {
108
+ console.error(`Document ${id} not found.`);
109
+ process.exit(1);
110
+ }
111
+ if (opts.json) {
112
+ console.log(JSON.stringify(doc, null, 2));
113
+ return;
114
+ }
115
+ const cfg = (0, config_1.loadConfig)();
116
+ const fullPath = path_1.default.join(cfg.dataDir, doc.file_path);
117
+ console.log(`\x1b[1m${doc.heading}\x1b[0m`);
118
+ console.log(`${fullPath} (${doc.language})`);
119
+ console.log();
120
+ console.log(doc.content);
121
+ });
122
+ program
123
+ .command("list")
124
+ .description("List indexed documents")
125
+ .option("-d, --db <path>", "SQLite database path", defaultDb)
126
+ .option("-l, --lang <language>", "Filter by language")
127
+ .option("-n, --limit <number>", "Max results", "50")
128
+ .option("-o, --offset <number>", "Offset", "0")
129
+ .option("--json", "Output raw JSON", false)
130
+ .action((opts) => {
131
+ const db = (0, db_1.openDb)(opts.db);
132
+ const result = (0, db_1.listDocuments)(db, {
133
+ language: opts.lang,
134
+ limit: parseInt(opts.limit, 10),
135
+ offset: parseInt(opts.offset, 10),
136
+ });
137
+ db.close();
138
+ if (opts.json) {
139
+ console.log(JSON.stringify(result, null, 2));
140
+ return;
141
+ }
142
+ const cfg = (0, config_1.loadConfig)();
143
+ for (const d of result.docs) {
144
+ const fullPath = path_1.default.join(cfg.dataDir, d.file_path);
145
+ console.log(`\x1b[36m[${d.id}]\x1b[0m ${fullPath} — ${d.heading} (${d.language})`);
146
+ }
147
+ console.log(`\nShowing ${result.docs.length} of ${result.total} total sections`);
148
+ });
149
+ program
150
+ .command("add <name> <repo>")
151
+ .description("Add a GitHub repo as a doc source (repo = owner/repo)")
152
+ .option("-b, --branch <branch>", "Branch to index", "main")
153
+ .option("-p, --prefix <prefix>", "Path prefix for docs in the repo", "docs/")
154
+ .action((name, repo, opts) => {
155
+ (0, config_1.addSource)({ name, repo, branch: opts.branch, docsPrefix: opts.prefix });
156
+ console.log(`Added source "${name}" → ${repo}@${opts.branch} (prefix: ${opts.prefix})`);
157
+ console.log(`Run \`rtm ingest -s ${name}\` to index it.`);
158
+ });
159
+ program
160
+ .command("remove <name>")
161
+ .description("Remove a doc source")
162
+ .action((name) => {
163
+ if ((0, config_1.removeSource)(name)) {
164
+ console.log(`Removed source "${name}".`);
165
+ }
166
+ else {
167
+ console.error(`Source "${name}" not found.`);
168
+ process.exit(1);
169
+ }
170
+ });
171
+ program
172
+ .command("use <name>")
173
+ .description("Set the default source for searches")
174
+ .action((name) => {
175
+ const cfg = (0, config_1.loadConfig)();
176
+ if (!cfg.sources.some((s) => s.name === name)) {
177
+ const available = cfg.sources.map((s) => s.name).join(", ");
178
+ console.error(`Source "${name}" not found. Available: ${available}`);
179
+ process.exit(1);
180
+ }
181
+ (0, config_1.setLastSource)(name);
182
+ console.log(`Default source set to "${name}".`);
183
+ });
184
+ program
185
+ .command("config")
186
+ .description("Show config file path and contents")
187
+ .action(() => {
188
+ const cfg = (0, config_1.loadConfig)();
189
+ console.log(`Config: ${(0, config_1.getConfigPath)()}`);
190
+ console.log(`Data: ${cfg.dataDir}`);
191
+ console.log(`Install: ${(0, config_1.isGlobal)() ? "global (/usr/local)" : "local (~/.local)"}`);
192
+ console.log(`Default: ${(0, config_1.getDefaultSource)(cfg) ?? "(none)"}`);
193
+ console.log();
194
+ console.log(`Sources (${cfg.sources.length}):`);
195
+ for (const s of cfg.sources) {
196
+ const marker = s.name === (0, config_1.getDefaultSource)(cfg) ? " *" : "";
197
+ console.log(` - ${s.name}: ${s.repo}@${s.branch ?? "main"} (prefix: ${s.docsPrefix ?? "docs/"})${marker}`);
198
+ }
199
+ });
200
+ // Default: bare args with no subcommand → search
201
+ const knownCommands = ["ingest", "serve", "mcp", "search", "get", "list", "add", "remove", "use", "config", "help"];
202
+ const firstArg = process.argv[2];
203
+ if (firstArg && !firstArg.startsWith("-") && !knownCommands.includes(firstArg)) {
204
+ process.argv.splice(2, 0, "search");
205
+ }
206
+ program.parse();
@@ -0,0 +1,21 @@
1
+ export interface SourceConfig {
2
+ name: string;
3
+ repo: string;
4
+ branch?: string;
5
+ docsPrefix?: string;
6
+ }
7
+ export interface Config {
8
+ sources: SourceConfig[];
9
+ dataDir: string;
10
+ lastSource?: string;
11
+ }
12
+ export declare function getConfigPath(): string;
13
+ export declare function isGlobal(): boolean;
14
+ export declare function checkWriteAccess(): void;
15
+ export declare function loadConfig(): Config;
16
+ export declare function getDbPath(config: Config): string;
17
+ export declare function getDocsDir(config: Config, sourceName: string): string;
18
+ export declare function addSource(source: SourceConfig): void;
19
+ export declare function removeSource(name: string): boolean;
20
+ export declare function setLastSource(name: string): void;
21
+ export declare function getDefaultSource(config: Config): string | undefined;
package/dist/config.js ADDED
@@ -0,0 +1,134 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.getConfigPath = getConfigPath;
7
+ exports.isGlobal = isGlobal;
8
+ exports.checkWriteAccess = checkWriteAccess;
9
+ exports.loadConfig = loadConfig;
10
+ exports.getDbPath = getDbPath;
11
+ exports.getDocsDir = getDocsDir;
12
+ exports.addSource = addSource;
13
+ exports.removeSource = removeSource;
14
+ exports.setLastSource = setLastSource;
15
+ exports.getDefaultSource = getDefaultSource;
16
+ const fs_1 = __importDefault(require("fs"));
17
+ const os_1 = __importDefault(require("os"));
18
+ const path_1 = __importDefault(require("path"));
19
+ function isGlobalInstall() {
20
+ return __dirname.startsWith("/usr/local");
21
+ }
22
+ // Data always lives in user's home — it's user data, not system data
23
+ const DATA_DIR = path_1.default.join(os_1.default.homedir(), ".local", "rtm");
24
+ const CONFIG_PATH = path_1.default.join(DATA_DIR, "config.json");
25
+ const DEFAULT_CONFIG = {
26
+ sources: [
27
+ {
28
+ name: "zeroclaw",
29
+ repo: "zeroclaw-labs/zeroclaw",
30
+ branch: "main",
31
+ docsPrefix: "docs/",
32
+ },
33
+ ],
34
+ dataDir: DATA_DIR,
35
+ lastSource: "zeroclaw",
36
+ };
37
+ function getConfigPath() {
38
+ return CONFIG_PATH;
39
+ }
40
+ function isGlobal() {
41
+ return isGlobalInstall();
42
+ }
43
+ function hasWriteAccess(dir) {
44
+ // Walk up to find the first existing ancestor and check write permission
45
+ let check = dir;
46
+ while (!fs_1.default.existsSync(check)) {
47
+ const parent = path_1.default.dirname(check);
48
+ if (parent === check)
49
+ return false;
50
+ check = parent;
51
+ }
52
+ try {
53
+ fs_1.default.accessSync(check, fs_1.default.constants.W_OK);
54
+ return true;
55
+ }
56
+ catch {
57
+ return false;
58
+ }
59
+ }
60
+ function checkWriteAccess() {
61
+ if (!hasWriteAccess(DATA_DIR)) {
62
+ console.error(`Error: No write permission to ${DATA_DIR}\nCheck directory permissions.`);
63
+ process.exit(1);
64
+ }
65
+ }
66
+ function loadConfig() {
67
+ if (!fs_1.default.existsSync(CONFIG_PATH)) {
68
+ // Return defaults in memory; only write if we have access
69
+ if (hasWriteAccess(DATA_DIR) || hasWriteAccess(path_1.default.dirname(DATA_DIR))) {
70
+ fs_1.default.mkdirSync(DATA_DIR, { recursive: true });
71
+ fs_1.default.writeFileSync(CONFIG_PATH, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n", "utf-8");
72
+ }
73
+ return DEFAULT_CONFIG;
74
+ }
75
+ const raw = JSON.parse(fs_1.default.readFileSync(CONFIG_PATH, "utf-8"));
76
+ return {
77
+ sources: raw.sources ?? DEFAULT_CONFIG.sources,
78
+ dataDir: raw.dataDir ?? DATA_DIR,
79
+ lastSource: raw.lastSource,
80
+ };
81
+ }
82
+ function getDbPath(config) {
83
+ return path_1.default.join(config.dataDir, "rtm.db");
84
+ }
85
+ function getDocsDir(config, sourceName) {
86
+ return path_1.default.join(config.dataDir, sourceName);
87
+ }
88
+ function saveConfig(config) {
89
+ checkWriteAccess();
90
+ fs_1.default.mkdirSync(path_1.default.dirname(CONFIG_PATH), { recursive: true });
91
+ fs_1.default.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", "utf-8");
92
+ }
93
+ function addSource(source) {
94
+ const config = loadConfig();
95
+ const existing = config.sources.findIndex((s) => s.name === source.name);
96
+ if (existing >= 0) {
97
+ config.sources[existing] = source;
98
+ }
99
+ else {
100
+ config.sources.push(source);
101
+ }
102
+ config.lastSource = source.name;
103
+ saveConfig(config);
104
+ }
105
+ function removeSource(name) {
106
+ const config = loadConfig();
107
+ const before = config.sources.length;
108
+ config.sources = config.sources.filter((s) => s.name !== name);
109
+ if (config.sources.length === before)
110
+ return false;
111
+ // If we removed the last-used source, reset to the final remaining one
112
+ if (config.lastSource === name) {
113
+ config.lastSource = config.sources.length > 0
114
+ ? config.sources[config.sources.length - 1].name
115
+ : undefined;
116
+ }
117
+ saveConfig(config);
118
+ return true;
119
+ }
120
+ function setLastSource(name) {
121
+ const config = loadConfig();
122
+ config.lastSource = name;
123
+ saveConfig(config);
124
+ }
125
+ function getDefaultSource(config) {
126
+ // Last-used source, or the last one in the list, or undefined
127
+ if (config.lastSource && config.sources.some((s) => s.name === config.lastSource)) {
128
+ return config.lastSource;
129
+ }
130
+ if (config.sources.length > 0) {
131
+ return config.sources[config.sources.length - 1].name;
132
+ }
133
+ return undefined;
134
+ }
package/dist/db.d.ts ADDED
@@ -0,0 +1,33 @@
1
+ import Database from "better-sqlite3";
2
+ export interface DocSection {
3
+ id: number;
4
+ file_path: string;
5
+ heading: string;
6
+ content: string;
7
+ language: string;
8
+ }
9
+ export interface SearchResult extends DocSection {
10
+ rank: number;
11
+ snippet: string;
12
+ }
13
+ export declare function openDb(dbPath?: string): Database.Database;
14
+ export declare function clearDocs(db: Database.Database): void;
15
+ export declare function insertSection(db: Database.Database, section: Omit<DocSection, "id">): number;
16
+ export interface SearchResponse {
17
+ results: SearchResult[];
18
+ total: number;
19
+ }
20
+ export declare function search(db: Database.Database, query: string, opts?: {
21
+ language?: string;
22
+ limit?: number;
23
+ sourcePrefix?: string;
24
+ }): SearchResponse;
25
+ export declare function getDocument(db: Database.Database, id: number): DocSection | null;
26
+ export declare function listDocuments(db: Database.Database, opts?: {
27
+ language?: string;
28
+ limit?: number;
29
+ offset?: number;
30
+ }): {
31
+ docs: Pick<DocSection, "id" | "file_path" | "heading" | "language">[];
32
+ total: number;
33
+ };