openclew 0.2.1 → 0.3.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.
@@ -0,0 +1,313 @@
1
+ /**
2
+ * openclew MCP server — Model Context Protocol over stdio.
3
+ *
4
+ * Exposes openclew docs as MCP tools so AI agents (Claude Code, Cursor, etc.)
5
+ * can search and read project documentation natively.
6
+ *
7
+ * Tools:
8
+ * - search_docs(query) Search docs by keyword (L1/metadata)
9
+ * - read_doc(path, level?) Read a doc at specified level (L1/L2/L3/full)
10
+ * - list_docs(kind?) List all docs with L1 metadata
11
+ *
12
+ * Protocol: MCP 2024-11-05 over stdio (JSON-RPC line-delimited)
13
+ * Zero dependencies — Node 16+ standard library only.
14
+ */
15
+
16
+ const fs = require("fs");
17
+ const path = require("path");
18
+ const readline = require("readline");
19
+ const { searchDocs, collectDocs, parseFile } = require("./search");
20
+
21
+ const PROJECT_ROOT = process.cwd();
22
+ const DOC_DIR = path.join(PROJECT_ROOT, "doc");
23
+
24
+ // ── Helpers ─────────────────────────────────────────────────────────
25
+
26
+ function ocVersion() {
27
+ try {
28
+ return require(path.join(__dirname, "..", "package.json")).version;
29
+ } catch {
30
+ return "0.0.0";
31
+ }
32
+ }
33
+
34
+ function extractLevel(content, level) {
35
+ if (level === "full") return content;
36
+
37
+ const markers = {
38
+ L1: [/<!--\s*L1_START\s*-->/, /<!--\s*L1_END\s*-->/],
39
+ L2: [/<!--\s*L2_START\s*-->/, /<!--\s*L2_END\s*-->/],
40
+ L3: [/<!--\s*L3_START\s*-->/, /<!--\s*L3_END\s*-->/],
41
+ };
42
+
43
+ const key = level.toUpperCase();
44
+ if (!markers[key]) return content;
45
+
46
+ const [startRe, endRe] = markers[key];
47
+ const startMatch = content.match(startRe);
48
+ const endMatch = content.match(endRe);
49
+ if (!startMatch || !endMatch) return `No ${key} block found in this document.`;
50
+
51
+ const startIdx = startMatch.index + startMatch[0].length;
52
+ const endIdx = endMatch.index;
53
+ return content.slice(startIdx, endIdx).trim();
54
+ }
55
+
56
+ // ── MCP Tool implementations ────────────────────────────────────────
57
+
58
+ function toolSearchDocs(params) {
59
+ const query = params.query;
60
+ if (!query) return { error: "Missing required parameter: query" };
61
+ if (!fs.existsSync(DOC_DIR)) return { error: "No doc/ directory found." };
62
+
63
+ const results = searchDocs(DOC_DIR, query);
64
+ return results.map((r) => ({
65
+ path: path.relative(PROJECT_ROOT, r.filepath),
66
+ kind: r.kind,
67
+ subject: r.meta.subject || r.filename,
68
+ doc_brief: r.meta.doc_brief || "",
69
+ status: r.meta.status || "",
70
+ category: r.meta.category || "",
71
+ score: r.score,
72
+ }));
73
+ }
74
+
75
+ function toolReadDoc(params) {
76
+ const docPath = params.path;
77
+ if (!docPath) return { error: "Missing required parameter: path" };
78
+
79
+ const absPath = path.resolve(PROJECT_ROOT, docPath);
80
+ // Security: ensure path is within project
81
+ if (!absPath.startsWith(PROJECT_ROOT)) return { error: "Path outside project." };
82
+ if (!fs.existsSync(absPath)) return { error: `File not found: ${docPath}` };
83
+
84
+ const content = fs.readFileSync(absPath, "utf-8");
85
+ const level = params.level || "L2";
86
+
87
+ return {
88
+ path: docPath,
89
+ level: level,
90
+ content: extractLevel(content, level),
91
+ };
92
+ }
93
+
94
+ function toolListDocs(params) {
95
+ if (!fs.existsSync(DOC_DIR)) return { error: "No doc/ directory found." };
96
+
97
+ const docs = collectDocs(DOC_DIR);
98
+ const kind = params.kind; // "refdoc", "log", or undefined (all)
99
+
100
+ return docs
101
+ .filter((d) => !kind || d.kind === kind)
102
+ .map((d) => ({
103
+ path: path.relative(PROJECT_ROOT, d.filepath),
104
+ kind: d.kind,
105
+ subject: d.meta.subject || d.filename,
106
+ doc_brief: d.meta.doc_brief || "",
107
+ status: d.meta.status || "",
108
+ category: d.meta.category || "",
109
+ }));
110
+ }
111
+
112
+ // ── MCP Protocol ────────────────────────────────────────────────────
113
+
114
+ const TOOLS = [
115
+ {
116
+ name: "search_docs",
117
+ description:
118
+ "Search project documentation by keyword. Searches subject, doc_brief, category, keywords, type, and status fields. Returns results sorted by relevance.",
119
+ inputSchema: {
120
+ type: "object",
121
+ properties: {
122
+ query: {
123
+ type: "string",
124
+ description: "Search query (space-separated terms)",
125
+ },
126
+ },
127
+ required: ["query"],
128
+ },
129
+ },
130
+ {
131
+ name: "read_doc",
132
+ description:
133
+ "Read a project document at a specified level. L1 = subject + brief (~40 tokens). L2 = summary + key points. L3 = full technical details. full = entire file.",
134
+ inputSchema: {
135
+ type: "object",
136
+ properties: {
137
+ path: {
138
+ type: "string",
139
+ description: "Relative path to the document (e.g. doc/_ARCHITECTURE.md)",
140
+ },
141
+ level: {
142
+ type: "string",
143
+ enum: ["L1", "L2", "L3", "full"],
144
+ description: "Level of detail to return (default: L2)",
145
+ },
146
+ },
147
+ required: ["path"],
148
+ },
149
+ },
150
+ {
151
+ name: "list_docs",
152
+ description:
153
+ "List all project documents with their L1 metadata (subject, brief, status, category).",
154
+ inputSchema: {
155
+ type: "object",
156
+ properties: {
157
+ kind: {
158
+ type: "string",
159
+ enum: ["refdoc", "log"],
160
+ description: "Filter by document type. Omit to list all.",
161
+ },
162
+ },
163
+ },
164
+ },
165
+ ];
166
+
167
+ const TOOL_HANDLERS = {
168
+ search_docs: toolSearchDocs,
169
+ read_doc: toolReadDoc,
170
+ list_docs: toolListDocs,
171
+ };
172
+
173
+ function handleMessage(msg) {
174
+ const { method, id, params } = msg;
175
+
176
+ switch (method) {
177
+ case "initialize":
178
+ return {
179
+ jsonrpc: "2.0",
180
+ id,
181
+ result: {
182
+ protocolVersion: "2024-11-05",
183
+ capabilities: { tools: {} },
184
+ serverInfo: {
185
+ name: "openclew",
186
+ version: ocVersion(),
187
+ },
188
+ },
189
+ };
190
+
191
+ case "notifications/initialized":
192
+ return null; // No response for notifications
193
+
194
+ case "tools/list":
195
+ return {
196
+ jsonrpc: "2.0",
197
+ id,
198
+ result: { tools: TOOLS },
199
+ };
200
+
201
+ case "tools/call": {
202
+ const toolName = params && params.name;
203
+ const handler = TOOL_HANDLERS[toolName];
204
+ if (!handler) {
205
+ return {
206
+ jsonrpc: "2.0",
207
+ id,
208
+ error: { code: -32601, message: `Unknown tool: ${toolName}` },
209
+ };
210
+ }
211
+
212
+ const toolArgs = params.arguments || {};
213
+ const result = handler(toolArgs);
214
+
215
+ // If result has an error field, return as tool error content
216
+ if (result && result.error) {
217
+ return {
218
+ jsonrpc: "2.0",
219
+ id,
220
+ result: {
221
+ content: [{ type: "text", text: result.error }],
222
+ isError: true,
223
+ },
224
+ };
225
+ }
226
+
227
+ return {
228
+ jsonrpc: "2.0",
229
+ id,
230
+ result: {
231
+ content: [
232
+ {
233
+ type: "text",
234
+ text: typeof result === "string" ? result : JSON.stringify(result, null, 2),
235
+ },
236
+ ],
237
+ },
238
+ };
239
+ }
240
+
241
+ default:
242
+ if (id !== undefined) {
243
+ return {
244
+ jsonrpc: "2.0",
245
+ id,
246
+ error: { code: -32601, message: `Method not found: ${method}` },
247
+ };
248
+ }
249
+ return null; // Ignore unknown notifications
250
+ }
251
+ }
252
+
253
+ // ── stdio transport ─────────────────────────────────────────────────
254
+
255
+ function run() {
256
+ // Check if running as CLI help
257
+ const args = process.argv.slice(2);
258
+ const cmdIndex = args.indexOf("mcp");
259
+ const extraArgs = cmdIndex >= 0 ? args.slice(cmdIndex + 1) : args.slice(1);
260
+
261
+ if (extraArgs.includes("--help") || extraArgs.includes("-h")) {
262
+ console.log("openclew MCP server — Model Context Protocol over stdio");
263
+ console.log("");
264
+ console.log("Usage: openclew mcp");
265
+ console.log("");
266
+ console.log("Starts an MCP server on stdin/stdout for AI agent integration.");
267
+ console.log("Configure in your AI tool's MCP settings:");
268
+ console.log("");
269
+ console.log(' { "command": "npx", "args": ["openclew", "mcp"] }');
270
+ console.log("");
271
+ console.log("Tools exposed:");
272
+ console.log(" search_docs(query) Search docs by keyword");
273
+ console.log(" read_doc(path, level?) Read doc at L1/L2/L3/full");
274
+ console.log(" list_docs(kind?) List all docs with L1 metadata");
275
+ process.exit(0);
276
+ }
277
+
278
+ const rl = readline.createInterface({ input: process.stdin, terminal: false });
279
+
280
+ rl.on("line", (line) => {
281
+ const trimmed = line.trim();
282
+ if (!trimmed) return;
283
+
284
+ let msg;
285
+ try {
286
+ msg = JSON.parse(trimmed);
287
+ } catch {
288
+ const err = {
289
+ jsonrpc: "2.0",
290
+ id: null,
291
+ error: { code: -32700, message: "Parse error" },
292
+ };
293
+ process.stdout.write(JSON.stringify(err) + "\n");
294
+ return;
295
+ }
296
+
297
+ const response = handleMessage(msg);
298
+ if (response) {
299
+ process.stdout.write(JSON.stringify(response) + "\n");
300
+ }
301
+ });
302
+
303
+ rl.on("close", () => process.exit(0));
304
+ }
305
+
306
+ // Export for tests
307
+ module.exports = { handleMessage, extractLevel, TOOLS };
308
+
309
+ // Run as CLI (invoked via dispatcher or directly)
310
+ const calledAsMcp = process.argv.includes("mcp");
311
+ if (require.main === module || calledAsMcp) {
312
+ run();
313
+ }
package/lib/new-doc.js CHANGED
@@ -8,13 +8,21 @@ const { refdocContent, slugify } = require("./templates");
8
8
  const { readConfig } = require("./config");
9
9
 
10
10
  const args = process.argv.slice(2);
11
- // Remove "new" command from args
12
- const cmdIndex = args.indexOf("new");
13
- const titleArgs = cmdIndex >= 0 ? args.slice(cmdIndex + 1) : args.slice(1);
11
+ // Support both "add ref <title>" and legacy "new <title>"
12
+ const refIndex = args.indexOf("ref");
13
+ const newIndex = args.indexOf("new");
14
+ let titleArgs;
15
+ if (refIndex >= 0) {
16
+ titleArgs = args.slice(refIndex + 1);
17
+ } else if (newIndex >= 0) {
18
+ titleArgs = args.slice(newIndex + 1);
19
+ } else {
20
+ titleArgs = args.slice(1);
21
+ }
14
22
  const title = titleArgs.join(" ");
15
23
 
16
24
  if (!title) {
17
- console.error('Usage: openclew new "Title of the document"');
25
+ console.error('Usage: openclew add ref "Title of the document"');
18
26
  process.exit(1);
19
27
  }
20
28
 
package/lib/new-log.js CHANGED
@@ -8,13 +8,13 @@ const { logContent, slugifyLog, today } = require("./templates");
8
8
  const { readConfig } = require("./config");
9
9
 
10
10
  const args = process.argv.slice(2);
11
- // Remove "log" command from args
12
- const cmdIndex = args.indexOf("log");
13
- const titleArgs = cmdIndex >= 0 ? args.slice(cmdIndex + 1) : args.slice(1);
11
+ // Support both "add log <title>" and legacy "log <title>"
12
+ const logIndex = args.lastIndexOf("log");
13
+ const titleArgs = logIndex >= 0 ? args.slice(logIndex + 1) : args.slice(1);
14
14
  const title = titleArgs.join(" ");
15
15
 
16
16
  if (!title) {
17
- console.error('Usage: openclew log "Title of the log"');
17
+ console.error('Usage: openclew add log "Title of the log"');
18
18
  process.exit(1);
19
19
  }
20
20
 
package/lib/search.js ADDED
@@ -0,0 +1,242 @@
1
+ /**
2
+ * openclew search <query> — search docs by keyword.
3
+ *
4
+ * Searches metadata line (category, keywords, type, status) and
5
+ * L1 fields (subject, doc_brief) across all refdocs and logs.
6
+ * Returns results sorted by relevance score.
7
+ *
8
+ * Zero dependencies — Node 16+ standard library only.
9
+ */
10
+
11
+ const fs = require("fs");
12
+ const path = require("path");
13
+
14
+ // ── Parsers (JS port of generate-index.py) ─────────────────────────
15
+
16
+ function parseMetadataLine(content) {
17
+ const meta = {};
18
+ const firstLine = content.split("\n", 1)[0].trim();
19
+ if (!firstLine.startsWith("openclew@")) return meta;
20
+
21
+ const parts = firstLine.split(" · ");
22
+ for (const part of parts) {
23
+ const trimmed = part.trim();
24
+ if (trimmed.startsWith("openclew@")) {
25
+ meta.version = trimmed.split("@")[1];
26
+ continue;
27
+ }
28
+ const colonIdx = trimmed.indexOf(":");
29
+ if (colonIdx > 0) {
30
+ const key = trimmed.slice(0, colonIdx).trim().toLowerCase();
31
+ const value = trimmed.slice(colonIdx + 1).trim();
32
+ meta[key] = value;
33
+ }
34
+ }
35
+ return meta;
36
+ }
37
+
38
+ function parseL1(content) {
39
+ const meta = {};
40
+ const match = content.match(
41
+ /<!--\s*L1_START\s*-->([\s\S]+?)<!--\s*L1_END\s*-->/
42
+ );
43
+ if (!match) return meta;
44
+
45
+ const block = match[1];
46
+ const subjectMatch = block.match(/\*\*subject:\*\*\s*(.+)/);
47
+ if (subjectMatch) meta.subject = subjectMatch[1].trim();
48
+
49
+ const briefMatch = block.match(/\*\*doc_brief:\*\*\s*(.+)/);
50
+ if (briefMatch) meta.doc_brief = briefMatch[1].trim();
51
+
52
+ return meta;
53
+ }
54
+
55
+ function parseL1Legacy(content) {
56
+ const meta = {};
57
+ const match = content.match(
58
+ /<!--\s*L1_START\s*-->([\s\S]+?)<!--\s*L1_END\s*-->/
59
+ );
60
+ if (!match) return meta;
61
+
62
+ for (const line of match[1].split("\n")) {
63
+ const trimmed = line.trim();
64
+ if (!trimmed || trimmed.startsWith("#")) continue;
65
+ const colonIdx = trimmed.indexOf(":");
66
+ if (colonIdx > 0) {
67
+ const key = trimmed.slice(0, colonIdx).trim().toLowerCase();
68
+ const value = trimmed.slice(colonIdx + 1).trim();
69
+ meta[key] = value;
70
+ }
71
+ }
72
+ return meta;
73
+ }
74
+
75
+ function parseFile(filepath) {
76
+ let content;
77
+ try {
78
+ content = fs.readFileSync(filepath, "utf-8");
79
+ } catch {
80
+ return null;
81
+ }
82
+
83
+ const metaLine = parseMetadataLine(content);
84
+ const l1 = parseL1(content);
85
+
86
+ if (l1.subject) {
87
+ return { ...metaLine, ...l1 };
88
+ }
89
+
90
+ const legacy = parseL1Legacy(content);
91
+ if (Object.keys(legacy).length) {
92
+ return { ...metaLine, ...legacy };
93
+ }
94
+
95
+ return null;
96
+ }
97
+
98
+ // ── Collector ───────────────────────────────────────────────────────
99
+
100
+ function collectDocs(docDir) {
101
+ const docs = [];
102
+
103
+ // Refdocs: doc/_*.md
104
+ if (fs.existsSync(docDir)) {
105
+ for (const entry of fs.readdirSync(docDir).sort()) {
106
+ if (entry.startsWith("_") && entry.endsWith(".md") && entry !== "_INDEX.md") {
107
+ const filepath = path.join(docDir, entry);
108
+ const meta = parseFile(filepath);
109
+ if (meta) docs.push({ filepath, filename: entry, kind: "refdoc", meta });
110
+ }
111
+ }
112
+ }
113
+
114
+ // Logs: doc/log/*.md
115
+ const logDir = path.join(docDir, "log");
116
+ if (fs.existsSync(logDir)) {
117
+ for (const entry of fs.readdirSync(logDir).sort().reverse()) {
118
+ if (entry.endsWith(".md")) {
119
+ const filepath = path.join(logDir, entry);
120
+ const meta = parseFile(filepath);
121
+ if (meta) docs.push({ filepath, filename: entry, kind: "log", meta });
122
+ }
123
+ }
124
+ }
125
+
126
+ return docs;
127
+ }
128
+
129
+ // ── Search engine ───────────────────────────────────────────────────
130
+
131
+ /**
132
+ * Score a document against query terms.
133
+ * Higher score = more relevant.
134
+ *
135
+ * Weights: subject (3), doc_brief (2), keywords (2), category (1.5), type (1), status (0.5)
136
+ */
137
+ function scoreDoc(doc, queryTerms) {
138
+ const fields = [
139
+ { value: doc.meta.subject || "", weight: 3 },
140
+ { value: doc.meta.doc_brief || "", weight: 2 },
141
+ { value: doc.meta.keywords || "", weight: 2 },
142
+ { value: doc.meta.category || "", weight: 1.5 },
143
+ { value: doc.meta.type || "", weight: 1 },
144
+ { value: doc.meta.status || "", weight: 0.5 },
145
+ ];
146
+
147
+ let score = 0;
148
+ for (const term of queryTerms) {
149
+ const termLower = term.toLowerCase();
150
+ for (const field of fields) {
151
+ const valueLower = field.value.toLowerCase();
152
+ if (valueLower.includes(termLower)) {
153
+ score += field.weight;
154
+ // Bonus for exact word match (not substring)
155
+ if (new RegExp(`\\b${termLower.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i").test(field.value)) {
156
+ score += field.weight * 0.5;
157
+ }
158
+ }
159
+ }
160
+ }
161
+ return score;
162
+ }
163
+
164
+ /**
165
+ * Search docs matching query. Returns sorted results.
166
+ *
167
+ * @param {string} docDir - Path to doc/ directory
168
+ * @param {string} query - Search query (space-separated terms, AND logic)
169
+ * @returns {Array<{filepath, filename, kind, meta, score}>}
170
+ */
171
+ function searchDocs(docDir, query) {
172
+ const docs = collectDocs(docDir);
173
+ const queryTerms = query.trim().split(/\s+/).filter(Boolean);
174
+ if (!queryTerms.length) return [];
175
+
176
+ const results = [];
177
+ for (const doc of docs) {
178
+ const score = scoreDoc(doc, queryTerms);
179
+ if (score > 0) {
180
+ results.push({ ...doc, score });
181
+ }
182
+ }
183
+
184
+ results.sort((a, b) => b.score - a.score);
185
+ return results;
186
+ }
187
+
188
+ // ── CLI runner ──────────────────────────────────────────────────────
189
+
190
+ function run() {
191
+ const args = process.argv.slice(2);
192
+ const cmdIndex = args.indexOf("search");
193
+ const queryArgs = cmdIndex >= 0 ? args.slice(cmdIndex + 1) : args.slice(1);
194
+ const query = queryArgs.join(" ");
195
+
196
+ if (!query) {
197
+ console.error('Usage: openclew search <query>');
198
+ console.error('');
199
+ console.error('Examples:');
200
+ console.error(' openclew search auth # find docs about authentication');
201
+ console.error(' openclew search "API design" # multi-word search');
202
+ process.exit(1);
203
+ }
204
+
205
+ const projectRoot = process.cwd();
206
+ const docDir = path.join(projectRoot, "doc");
207
+ if (!fs.existsSync(docDir)) {
208
+ console.error("No doc/ directory found. Run 'openclew init' first.");
209
+ process.exit(1);
210
+ }
211
+
212
+ const results = searchDocs(docDir, query);
213
+
214
+ if (!results.length) {
215
+ console.log(`No docs matching "${query}".`);
216
+ process.exit(0);
217
+ }
218
+
219
+ console.log(`Found ${results.length} doc${results.length > 1 ? "s" : ""} matching "${query}":\n`);
220
+
221
+ for (const r of results) {
222
+ const relPath = path.relative(projectRoot, r.filepath);
223
+ const icon = r.kind === "refdoc" ? "📘" : "📝";
224
+ const subject = r.meta.subject || r.filename;
225
+ const brief = r.meta.doc_brief || "";
226
+ const status = r.meta.status ? ` [${r.meta.status}]` : "";
227
+
228
+ console.log(`${icon} ${subject}${status}`);
229
+ console.log(` ${relPath}`);
230
+ if (brief) console.log(` ${brief}`);
231
+ console.log("");
232
+ }
233
+ }
234
+
235
+ // Export for MCP server + tests
236
+ module.exports = { searchDocs, collectDocs, parseFile, parseMetadataLine, parseL1, parseL1Legacy };
237
+
238
+ // Run as CLI (invoked via dispatcher or directly)
239
+ const calledAsSearch = process.argv.includes("search");
240
+ if (require.main === module || calledAsSearch) {
241
+ run();
242
+ }