@grainulation/mill 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.
Files changed (42) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +76 -0
  4. package/bin/mill.js +320 -0
  5. package/lib/exporters/csv.js +83 -0
  6. package/lib/exporters/json-ld.js +44 -0
  7. package/lib/exporters/markdown.js +116 -0
  8. package/lib/exporters/pdf.js +104 -0
  9. package/lib/formats/bibtex.js +76 -0
  10. package/lib/formats/changelog.js +102 -0
  11. package/lib/formats/csv.js +92 -0
  12. package/lib/formats/dot.js +129 -0
  13. package/lib/formats/evidence-matrix.js +87 -0
  14. package/lib/formats/executive-summary.js +130 -0
  15. package/lib/formats/github-issues.js +89 -0
  16. package/lib/formats/graphml.js +118 -0
  17. package/lib/formats/html-report.js +181 -0
  18. package/lib/formats/jira-csv.js +89 -0
  19. package/lib/formats/json-ld.js +28 -0
  20. package/lib/formats/markdown.js +118 -0
  21. package/lib/formats/ndjson.js +25 -0
  22. package/lib/formats/obsidian.js +136 -0
  23. package/lib/formats/opml.js +108 -0
  24. package/lib/formats/ris.js +70 -0
  25. package/lib/formats/rss.js +100 -0
  26. package/lib/formats/sankey.js +72 -0
  27. package/lib/formats/slide-deck.js +200 -0
  28. package/lib/formats/sql.js +116 -0
  29. package/lib/formats/static-site.js +169 -0
  30. package/lib/formats/treemap.js +65 -0
  31. package/lib/formats/typescript-defs.js +147 -0
  32. package/lib/formats/yaml.js +144 -0
  33. package/lib/formats.js +60 -0
  34. package/lib/index.js +14 -0
  35. package/lib/json-ld-common.js +72 -0
  36. package/lib/publishers/clipboard.js +70 -0
  37. package/lib/publishers/static.js +152 -0
  38. package/lib/serve-mcp.js +340 -0
  39. package/lib/server.js +535 -0
  40. package/package.json +53 -0
  41. package/public/grainulation-tokens.css +321 -0
  42. package/public/index.html +891 -0
@@ -0,0 +1,340 @@
1
+ /**
2
+ * mill serve-mcp — Local MCP server for Claude Code
3
+ *
4
+ * Exposes format conversion tools over stdio.
5
+ * Zero npm dependencies.
6
+ *
7
+ * Tools:
8
+ * mill/convert — Convert compilation/claims to any of 23+ formats
9
+ * mill/formats — List all available export formats
10
+ * mill/preview — Preview a conversion without writing to disk
11
+ *
12
+ * Resources:
13
+ * mill://formats — Full format catalog with descriptions and MIME types
14
+ *
15
+ * Install:
16
+ * claude mcp add mill -- npx @grainulation/mill serve-mcp
17
+ */
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const readline = require('readline');
22
+
23
+ // ─── Constants ──────────────────────────────────────────────────────────────
24
+
25
+ const SERVER_NAME = 'mill';
26
+ const SERVER_VERSION = '1.0.0';
27
+ const PROTOCOL_VERSION = '2024-11-05';
28
+
29
+ const FORMATS_DIR = path.join(__dirname, 'formats');
30
+
31
+ // ─── JSON-RPC helpers ───────────────────────────────────────────────────────
32
+
33
+ function jsonRpcResponse(id, result) {
34
+ return JSON.stringify({ jsonrpc: '2.0', id, result });
35
+ }
36
+
37
+ function jsonRpcError(id, code, message) {
38
+ return JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } });
39
+ }
40
+
41
+ // ─── Format discovery ───────────────────────────────────────────────────────
42
+
43
+ let _formatCache = null;
44
+
45
+ async function discoverFormats() {
46
+ if (_formatCache) return _formatCache;
47
+
48
+ const formats = [];
49
+ try {
50
+ const files = fs.readdirSync(FORMATS_DIR).filter(f => f.endsWith('.js'));
51
+ for (const file of files) {
52
+ try {
53
+ const mod = await import(path.join(FORMATS_DIR, file));
54
+ formats.push({
55
+ id: file.replace('.js', ''),
56
+ name: mod.name || file.replace('.js', ''),
57
+ extension: mod.extension || '',
58
+ mimeType: mod.mimeType || 'text/plain',
59
+ description: mod.description || '',
60
+ convert: mod.convert,
61
+ });
62
+ } catch (err) {
63
+ process.stderr.write(`mill: skipping ${file}: ${err.message}\n`);
64
+ }
65
+ }
66
+ } catch {}
67
+
68
+ _formatCache = formats;
69
+ return formats;
70
+ }
71
+
72
+ // ─── Tool implementations ───────────────────────────────────────────────────
73
+
74
+ async function toolConvert(dir, args) {
75
+ const { format, source, output } = args;
76
+ if (!format) {
77
+ return { status: 'error', message: 'Required field: format (e.g., "csv", "markdown", "json-ld")' };
78
+ }
79
+
80
+ const formats = await discoverFormats();
81
+ const fmt = formats.find(f => f.id === format || f.name === format);
82
+ if (!fmt) {
83
+ return { status: 'error', message: `Unknown format: "${format}". Use mill/formats to list available formats.` };
84
+ }
85
+ if (!fmt.convert) {
86
+ return { status: 'error', message: `Format "${format}" does not have a convert function.` };
87
+ }
88
+
89
+ // Resolve source file
90
+ const sourceFile = source || path.join(dir, 'compilation.json');
91
+ const fallbackFile = path.join(dir, 'claims.json');
92
+ let dataPath = sourceFile;
93
+
94
+ if (!fs.existsSync(dataPath)) {
95
+ if (fs.existsSync(fallbackFile)) {
96
+ dataPath = fallbackFile;
97
+ } else {
98
+ return { status: 'error', message: `No source file found. Tried: ${sourceFile}, ${fallbackFile}` };
99
+ }
100
+ }
101
+
102
+ let data;
103
+ try {
104
+ data = JSON.parse(fs.readFileSync(dataPath, 'utf8'));
105
+ } catch (err) {
106
+ return { status: 'error', message: `Failed to parse ${dataPath}: ${err.message}` };
107
+ }
108
+
109
+ // Normalize: compilation.json uses resolved_claims, claims.json uses claims
110
+ // Use resolved_claims if claims is missing or empty
111
+ if (data.resolved_claims && (!data.claims || data.claims.length === 0)) {
112
+ data.claims = data.resolved_claims;
113
+ }
114
+ if (data.sprint_meta && !data.meta) {
115
+ data.meta = data.sprint_meta;
116
+ }
117
+
118
+
119
+ // Run conversion
120
+ let result;
121
+ try {
122
+ result = fmt.convert(data);
123
+ } catch (err) {
124
+ return { status: 'error', message: `Conversion failed: ${err.message}` };
125
+ }
126
+
127
+ // Write output if path provided
128
+ if (output) {
129
+ const outPath = path.resolve(dir, output);
130
+ const outDir = path.dirname(outPath);
131
+ if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
132
+ fs.writeFileSync(outPath, result);
133
+ return { status: 'ok', message: `Converted to ${format}. Written to ${outPath}`, format: fmt.id, outputPath: outPath, bytes: Buffer.byteLength(result) };
134
+ }
135
+
136
+ // Return inline (truncate large outputs)
137
+ const maxLen = 10000;
138
+ const truncated = result.length > maxLen;
139
+ return {
140
+ status: 'ok',
141
+ format: fmt.id,
142
+ mimeType: fmt.mimeType,
143
+ output: truncated ? result.slice(0, maxLen) + '\n\n... (truncated, use output parameter to write full file)' : result,
144
+ bytes: Buffer.byteLength(result),
145
+ truncated,
146
+ };
147
+ }
148
+
149
+ async function toolFormats() {
150
+ const formats = await discoverFormats();
151
+ return {
152
+ status: 'ok',
153
+ count: formats.length,
154
+ formats: formats.map(f => ({
155
+ id: f.id,
156
+ name: f.name,
157
+ extension: f.extension,
158
+ mimeType: f.mimeType,
159
+ description: f.description,
160
+ })),
161
+ };
162
+ }
163
+
164
+ async function toolPreview(dir, args) {
165
+ const { format, source, lines } = args;
166
+ if (!format) {
167
+ return { status: 'error', message: 'Required field: format' };
168
+ }
169
+
170
+ // Run the same conversion but only return first N lines
171
+ const result = await toolConvert(dir, { format, source });
172
+ if (result.status === 'error') return result;
173
+
174
+ const maxLines = lines || 30;
175
+ const outputLines = (result.output || '').split('\n');
176
+ const preview = outputLines.slice(0, maxLines).join('\n');
177
+ const hasMore = outputLines.length > maxLines;
178
+
179
+ return {
180
+ status: 'ok',
181
+ format: result.format,
182
+ preview,
183
+ totalLines: outputLines.length,
184
+ showing: Math.min(maxLines, outputLines.length),
185
+ hasMore,
186
+ };
187
+ }
188
+
189
+ // ─── Tool & Resource definitions ────────────────────────────────────────────
190
+
191
+ const TOOLS = [
192
+ {
193
+ name: 'mill/convert',
194
+ description: 'Convert sprint compilation or claims to any supported format (csv, markdown, json-ld, yaml, sql, ndjson, html-report, executive-summary, slide-deck, jira-csv, github-issues, obsidian, graphml, dot, and more). Returns the converted output inline or writes to a file.',
195
+ inputSchema: {
196
+ type: 'object',
197
+ properties: {
198
+ format: { type: 'string', description: 'Target format ID (e.g., "csv", "markdown", "json-ld", "yaml", "sql", "obsidian")' },
199
+ source: { type: 'string', description: 'Source file path (default: ./compilation.json, falls back to ./claims.json)' },
200
+ output: { type: 'string', description: 'Output file path. If omitted, returns content inline.' },
201
+ },
202
+ required: ['format'],
203
+ },
204
+ },
205
+ {
206
+ name: 'mill/formats',
207
+ description: 'List all available export formats with descriptions, file extensions, and MIME types.',
208
+ inputSchema: { type: 'object', properties: {} },
209
+ },
210
+ {
211
+ name: 'mill/preview',
212
+ description: 'Preview a format conversion — shows first N lines without writing to disk.',
213
+ inputSchema: {
214
+ type: 'object',
215
+ properties: {
216
+ format: { type: 'string', description: 'Target format ID' },
217
+ source: { type: 'string', description: 'Source file path (default: ./compilation.json)' },
218
+ lines: { type: 'number', description: 'Number of lines to preview (default: 30)' },
219
+ },
220
+ required: ['format'],
221
+ },
222
+ },
223
+ ];
224
+
225
+ const RESOURCES = [
226
+ {
227
+ uri: 'mill://formats',
228
+ name: 'Format Catalog',
229
+ description: 'All available export formats with descriptions, extensions, and MIME types.',
230
+ mimeType: 'application/json',
231
+ },
232
+ ];
233
+
234
+ // ─── Request handler ────────────────────────────────────────────────────────
235
+
236
+ async function handleRequest(dir, method, params, id) {
237
+ switch (method) {
238
+ case 'initialize':
239
+ return jsonRpcResponse(id, {
240
+ protocolVersion: PROTOCOL_VERSION,
241
+ capabilities: { tools: {}, resources: {} },
242
+ serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
243
+ });
244
+
245
+ case 'notifications/initialized':
246
+ return null;
247
+
248
+ case 'tools/list':
249
+ return jsonRpcResponse(id, { tools: TOOLS });
250
+
251
+ case 'tools/call': {
252
+ const toolName = params.name;
253
+ const toolArgs = params.arguments || {};
254
+ let result;
255
+
256
+ switch (toolName) {
257
+ case 'mill/convert': result = await toolConvert(dir, toolArgs); break;
258
+ case 'mill/formats': result = await toolFormats(); break;
259
+ case 'mill/preview': result = await toolPreview(dir, toolArgs); break;
260
+ default:
261
+ return jsonRpcError(id, -32601, `Unknown tool: ${toolName}`);
262
+ }
263
+
264
+ return jsonRpcResponse(id, {
265
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
266
+ isError: result.status === 'error',
267
+ });
268
+ }
269
+
270
+ case 'resources/list':
271
+ return jsonRpcResponse(id, { resources: RESOURCES });
272
+
273
+ case 'resources/read': {
274
+ if (params.uri === 'mill://formats') {
275
+ const formats = await discoverFormats();
276
+ const text = JSON.stringify(formats.map(f => ({
277
+ id: f.id, name: f.name, extension: f.extension, mimeType: f.mimeType, description: f.description,
278
+ })), null, 2);
279
+ return jsonRpcResponse(id, {
280
+ contents: [{ uri: params.uri, mimeType: 'application/json', text }],
281
+ });
282
+ }
283
+ return jsonRpcError(id, -32602, `Unknown resource: ${params.uri}`);
284
+ }
285
+
286
+ case 'ping':
287
+ return jsonRpcResponse(id, {});
288
+
289
+ default:
290
+ if (id === undefined || id === null) return null;
291
+ return jsonRpcError(id, -32601, `Method not found: ${method}`);
292
+ }
293
+ }
294
+
295
+ // ─── Stdio transport ────────────────────────────────────────────────────────
296
+
297
+ function startServer(dir) {
298
+ const rl = readline.createInterface({ input: process.stdin, terminal: false });
299
+
300
+ if (process.stdout._handle && process.stdout._handle.setBlocking) {
301
+ process.stdout._handle.setBlocking(true);
302
+ }
303
+
304
+ let pending = 0;
305
+ let closing = false;
306
+
307
+ function maybeDrain() {
308
+ if (closing && pending === 0) process.exit(0);
309
+ }
310
+
311
+ rl.on('line', async (line) => {
312
+ if (!line.trim()) return;
313
+ let msg;
314
+ try { msg = JSON.parse(line); } catch {
315
+ process.stdout.write(jsonRpcError(null, -32700, 'Parse error') + '\n');
316
+ return;
317
+ }
318
+ pending++;
319
+ const response = await handleRequest(dir, msg.method, msg.params || {}, msg.id);
320
+ if (response !== null) process.stdout.write(response + '\n');
321
+ pending--;
322
+ maybeDrain();
323
+ });
324
+
325
+ rl.on('close', () => { closing = true; maybeDrain(); });
326
+
327
+ process.stderr.write(`mill MCP server v${SERVER_VERSION} ready on stdio\n`);
328
+ process.stderr.write(` Formats dir: ${FORMATS_DIR}\n`);
329
+ process.stderr.write(` Tools: ${TOOLS.length} | Resources: ${RESOURCES.length}\n`);
330
+ }
331
+
332
+ // ─── Entry point ────────────────────────────────────────────────────────────
333
+
334
+ if (require.main === module) {
335
+ startServer(process.cwd());
336
+ }
337
+
338
+ async function run(dir) { startServer(dir); }
339
+
340
+ module.exports = { startServer, handleRequest, TOOLS, RESOURCES, run };