@learnrudi/cli 1.8.8 → 1.9.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/dist/index.cjs CHANGED
@@ -422,6 +422,18 @@ Installing...`);
422
422
  console.error(` rudi install ${result.id}`);
423
423
  process.exit(1);
424
424
  }
425
+ try {
426
+ (0, import_core2.addStack)(result.id, {
427
+ path: result.path,
428
+ runtime: manifest.runtime || manifest.mcp?.runtime || "node",
429
+ command: manifest.command || (manifest.mcp?.command ? [manifest.mcp.command, ...manifest.mcp.args || []] : null),
430
+ secrets: getManifestSecrets(manifest),
431
+ version: manifest.version
432
+ });
433
+ console.log(` \u2713 Updated rudi.json`);
434
+ } catch (err) {
435
+ console.log(` \u26A0 Failed to update rudi.json: ${err.message}`);
436
+ }
425
437
  const { found, missing } = await checkSecrets(manifest);
426
438
  const envExampleKeys = await parseEnvExample(result.path);
427
439
  for (const key of envExampleKeys) {
@@ -440,6 +452,16 @@ Installing...`);
440
452
  if (existing === null) {
441
453
  await (0, import_secrets.setSecret)(key, "");
442
454
  }
455
+ try {
456
+ (0, import_core2.updateSecretStatus)(key, false);
457
+ } catch {
458
+ }
459
+ }
460
+ }
461
+ for (const key of found) {
462
+ try {
463
+ (0, import_core2.updateSecretStatus)(key, true);
464
+ } catch {
443
465
  }
444
466
  }
445
467
  console.log(`
@@ -3691,6 +3713,140 @@ async function migrateConfigs(flags) {
3691
3713
  console.log("Restart your agents to use the updated configs.");
3692
3714
  }
3693
3715
 
3716
+ // src/commands/index-tools.js
3717
+ var import_core10 = require("@learnrudi/core");
3718
+ var import_core11 = require("@learnrudi/core");
3719
+ async function cmdIndex(args, flags) {
3720
+ const stackFilter = args.length > 0 ? args : null;
3721
+ const forceReindex = flags.force || false;
3722
+ const jsonOutput = flags.json || false;
3723
+ const config = (0, import_core11.readRudiConfig)();
3724
+ if (!config) {
3725
+ console.error("Error: rudi.json not found. Run `rudi doctor` to check setup.");
3726
+ process.exit(1);
3727
+ }
3728
+ const installedStacks = Object.keys(config.stacks || {}).filter(
3729
+ (id) => config.stacks[id].installed
3730
+ );
3731
+ if (installedStacks.length === 0) {
3732
+ if (jsonOutput) {
3733
+ console.log(JSON.stringify({ indexed: 0, failed: 0, stacks: [] }));
3734
+ } else {
3735
+ console.log("No installed stacks to index.");
3736
+ console.log("\nInstall stacks with: rudi install <stack>");
3737
+ }
3738
+ return;
3739
+ }
3740
+ const stacksToIndex = stackFilter ? stackFilter.filter((id) => {
3741
+ if (!installedStacks.includes(id)) {
3742
+ if (!jsonOutput) {
3743
+ console.log(`\u26A0 Stack not installed: ${id}`);
3744
+ }
3745
+ return false;
3746
+ }
3747
+ return true;
3748
+ }) : installedStacks;
3749
+ if (stacksToIndex.length === 0) {
3750
+ if (jsonOutput) {
3751
+ console.log(JSON.stringify({ indexed: 0, failed: 0, stacks: [] }));
3752
+ } else {
3753
+ console.log("No valid stacks to index.");
3754
+ }
3755
+ return;
3756
+ }
3757
+ const existingIndex = (0, import_core10.readToolIndex)();
3758
+ if (existingIndex && !forceReindex && !stackFilter) {
3759
+ const allCached = stacksToIndex.every((id) => {
3760
+ const entry = existingIndex.byStack?.[id];
3761
+ return entry && entry.tools && entry.tools.length > 0 && !entry.error;
3762
+ });
3763
+ if (allCached) {
3764
+ const totalTools = stacksToIndex.reduce((sum, id) => {
3765
+ return sum + (existingIndex.byStack[id]?.tools?.length || 0);
3766
+ }, 0);
3767
+ if (jsonOutput) {
3768
+ console.log(JSON.stringify({
3769
+ indexed: stacksToIndex.length,
3770
+ failed: 0,
3771
+ cached: true,
3772
+ totalTools,
3773
+ stacks: stacksToIndex.map((id) => ({
3774
+ id,
3775
+ tools: existingIndex.byStack[id]?.tools?.length || 0,
3776
+ indexedAt: existingIndex.byStack[id]?.indexedAt
3777
+ }))
3778
+ }));
3779
+ } else {
3780
+ console.log(`Tool index is up to date (${totalTools} tools from ${stacksToIndex.length} stacks)`);
3781
+ console.log(`Last updated: ${existingIndex.updatedAt}`);
3782
+ console.log(`
3783
+ Use --force to re-index.`);
3784
+ }
3785
+ return;
3786
+ }
3787
+ }
3788
+ if (!jsonOutput) {
3789
+ console.log(`Indexing ${stacksToIndex.length} stack(s)...
3790
+ `);
3791
+ }
3792
+ const log = jsonOutput ? () => {
3793
+ } : console.log;
3794
+ try {
3795
+ const result = await (0, import_core10.indexAllStacks)({
3796
+ stacks: stacksToIndex,
3797
+ log,
3798
+ timeout: 2e4
3799
+ // 20s per stack
3800
+ });
3801
+ const totalTools = Object.values(result.index.byStack).reduce(
3802
+ (sum, entry) => sum + (entry.tools?.length || 0),
3803
+ 0
3804
+ );
3805
+ if (jsonOutput) {
3806
+ console.log(JSON.stringify({
3807
+ indexed: result.indexed,
3808
+ failed: result.failed,
3809
+ totalTools,
3810
+ stacks: stacksToIndex.map((id) => ({
3811
+ id,
3812
+ tools: result.index.byStack[id]?.tools?.length || 0,
3813
+ error: result.index.byStack[id]?.error || null,
3814
+ missingSecrets: result.index.byStack[id]?.missingSecrets || null
3815
+ }))
3816
+ }, null, 2));
3817
+ } else {
3818
+ console.log(`
3819
+ ${"\u2500".repeat(50)}`);
3820
+ console.log(`Indexed: ${result.indexed}/${stacksToIndex.length} stacks`);
3821
+ console.log(`Tools discovered: ${totalTools}`);
3822
+ console.log(`Cache: ${import_core10.TOOL_INDEX_PATH}`);
3823
+ if (result.failed > 0) {
3824
+ console.log(`
3825
+ \u26A0 ${result.failed} stack(s) failed to index.`);
3826
+ const missingSecretStacks = Object.entries(result.index.byStack).filter(([_, entry]) => entry.missingSecrets?.length > 0);
3827
+ if (missingSecretStacks.length > 0) {
3828
+ console.log(`
3829
+ Missing secrets:`);
3830
+ for (const [stackId, entry] of missingSecretStacks) {
3831
+ for (const secret of entry.missingSecrets) {
3832
+ console.log(` rudi secrets set ${secret}`);
3833
+ }
3834
+ }
3835
+ console.log(`
3836
+ After configuring secrets, run: rudi index`);
3837
+ }
3838
+ }
3839
+ }
3840
+ } catch (error) {
3841
+ if (jsonOutput) {
3842
+ console.log(JSON.stringify({ error: error.message }));
3843
+ } else {
3844
+ console.error(`Index failed: ${error.message}`);
3845
+ }
3846
+ process.exit(1);
3847
+ }
3848
+ }
3849
+
3694
3850
  // src/index.js
3695
3851
  var VERSION = "2.0.0";
3696
3852
  async function main() {
@@ -3772,6 +3928,9 @@ async function main() {
3772
3928
  case "migrate":
3773
3929
  await cmdMigrate(args, flags);
3774
3930
  break;
3931
+ case "index":
3932
+ await cmdIndex(args, flags);
3933
+ break;
3775
3934
  case "home":
3776
3935
  case "status":
3777
3936
  await cmdHome(args, flags);
@@ -0,0 +1,604 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * RUDI Router MCP Server
4
+ *
5
+ * Central MCP dispatcher that:
6
+ * - Reads ~/.rudi/rudi.json
7
+ * - Lists all installed stack tools (namespaced)
8
+ * - Proxies tool calls to correct stack subprocess
9
+ * - Maintains connection pool per stack
10
+ *
11
+ * Design decisions:
12
+ * - Cached tool index: Fast tools/list without spawning all stacks
13
+ * - Lazy spawn: Only spawn stack servers when needed
14
+ * - stdout = protocol only: All logging goes to stderr
15
+ * - Handle null IDs: MCP notifications have id: null
16
+ */
17
+
18
+ import { spawn } from 'child_process';
19
+ import * as fs from 'fs';
20
+ import * as path from 'path';
21
+ import * as readline from 'readline';
22
+ import * as os from 'os';
23
+
24
+ // =============================================================================
25
+ // CONSTANTS
26
+ // =============================================================================
27
+
28
+ const RUDI_HOME = process.env.RUDI_HOME || path.join(os.homedir(), '.rudi');
29
+ const RUDI_JSON_PATH = path.join(RUDI_HOME, 'rudi.json');
30
+ const SECRETS_PATH = path.join(RUDI_HOME, 'secrets.json');
31
+ const TOOL_INDEX_PATH = path.join(RUDI_HOME, 'cache', 'tool-index.json');
32
+
33
+ const REQUEST_TIMEOUT_MS = 30000;
34
+ const PROTOCOL_VERSION = '2024-11-05';
35
+
36
+ // =============================================================================
37
+ // STATE
38
+ // =============================================================================
39
+
40
+ /** @type {Map<string, StackServer>} */
41
+ const serverPool = new Map();
42
+
43
+ /** @type {RudiConfig | null} */
44
+ let rudiConfig = null;
45
+
46
+ /** @type {Object | null} */
47
+ let toolIndex = null;
48
+
49
+ // =============================================================================
50
+ // TYPES (JSDoc)
51
+ // =============================================================================
52
+
53
+ /**
54
+ * @typedef {Object} StackServer
55
+ * @property {import('child_process').ChildProcess} process
56
+ * @property {readline.Interface} rl
57
+ * @property {Map<string|number, PendingRequest>} pending
58
+ * @property {string} buffer
59
+ * @property {boolean} initialized
60
+ */
61
+
62
+ /**
63
+ * @typedef {Object} PendingRequest
64
+ * @property {(value: JsonRpcResponse) => void} resolve
65
+ * @property {(error: Error) => void} reject
66
+ * @property {NodeJS.Timeout} timeout
67
+ */
68
+
69
+ /**
70
+ * @typedef {Object} JsonRpcRequest
71
+ * @property {'2.0'} jsonrpc
72
+ * @property {string|number|null} [id]
73
+ * @property {string} method
74
+ * @property {Object} [params]
75
+ */
76
+
77
+ /**
78
+ * @typedef {Object} JsonRpcResponse
79
+ * @property {'2.0'} jsonrpc
80
+ * @property {string|number|null} id
81
+ * @property {*} [result]
82
+ * @property {{code: number, message: string, data?: *}} [error]
83
+ */
84
+
85
+ // =============================================================================
86
+ // LOGGING (all to stderr to keep stdout clean for MCP protocol)
87
+ // =============================================================================
88
+
89
+ function log(msg) {
90
+ process.stderr.write(`[rudi-router] ${msg}\n`);
91
+ }
92
+
93
+ function debug(msg) {
94
+ if (process.env.DEBUG) {
95
+ process.stderr.write(`[rudi-router:debug] ${msg}\n`);
96
+ }
97
+ }
98
+
99
+ // =============================================================================
100
+ // CONFIG & SECRETS
101
+ // =============================================================================
102
+
103
+ /**
104
+ * Load rudi.json
105
+ * @returns {Object}
106
+ */
107
+ function loadRudiConfig() {
108
+ try {
109
+ const content = fs.readFileSync(RUDI_JSON_PATH, 'utf-8');
110
+ return JSON.parse(content);
111
+ } catch (err) {
112
+ log(`Failed to load rudi.json: ${err.message}`);
113
+ return { stacks: {}, runtimes: {}, binaries: {}, secrets: {} };
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Load tool index from cache file
119
+ * @returns {Object | null}
120
+ */
121
+ function loadToolIndex() {
122
+ try {
123
+ const content = fs.readFileSync(TOOL_INDEX_PATH, 'utf-8');
124
+ return JSON.parse(content);
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Load secrets.json
132
+ * @returns {Object<string, string>}
133
+ */
134
+ function loadSecrets() {
135
+ try {
136
+ const content = fs.readFileSync(SECRETS_PATH, 'utf-8');
137
+ return JSON.parse(content);
138
+ } catch {
139
+ return {};
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Get secrets for a specific stack
145
+ * @param {string} stackId
146
+ * @returns {Object<string, string>}
147
+ */
148
+ function getStackSecrets(stackId) {
149
+ const allSecrets = loadSecrets();
150
+ const stackConfig = rudiConfig?.stacks?.[stackId];
151
+ if (!stackConfig?.secrets) return {};
152
+
153
+ const result = {};
154
+ for (const secretDef of stackConfig.secrets) {
155
+ const name = typeof secretDef === 'string' ? secretDef : secretDef.name;
156
+ if (allSecrets[name]) {
157
+ result[name] = allSecrets[name];
158
+ }
159
+ }
160
+ return result;
161
+ }
162
+
163
+ // =============================================================================
164
+ // STACK SERVER MANAGEMENT
165
+ // =============================================================================
166
+
167
+ /**
168
+ * Spawn a stack MCP server as subprocess
169
+ * @param {string} stackId
170
+ * @param {Object} stackConfig
171
+ * @returns {StackServer}
172
+ */
173
+ function spawnStackServer(stackId, stackConfig) {
174
+ const launch = stackConfig.launch;
175
+ if (!launch || !launch.bin) {
176
+ throw new Error(`Stack ${stackId} has no launch configuration`);
177
+ }
178
+
179
+ const secrets = getStackSecrets(stackId);
180
+ const env = { ...process.env, ...secrets };
181
+
182
+ // Add bundled runtimes to PATH
183
+ const nodeBin = path.join(RUDI_HOME, 'runtimes', 'node', 'bin');
184
+ const pythonBin = path.join(RUDI_HOME, 'runtimes', 'python', 'bin');
185
+ if (fs.existsSync(nodeBin) || fs.existsSync(pythonBin)) {
186
+ const runtimePaths = [];
187
+ if (fs.existsSync(nodeBin)) runtimePaths.push(nodeBin);
188
+ if (fs.existsSync(pythonBin)) runtimePaths.push(pythonBin);
189
+ env.PATH = runtimePaths.join(path.delimiter) + path.delimiter + (env.PATH || '');
190
+ }
191
+
192
+ debug(`Spawning stack ${stackId}: ${launch.bin} ${launch.args?.join(' ')}`);
193
+
194
+ const childProcess = spawn(launch.bin, launch.args || [], {
195
+ cwd: launch.cwd || stackConfig.path,
196
+ stdio: ['pipe', 'pipe', 'pipe'],
197
+ env
198
+ });
199
+
200
+ const rl = readline.createInterface({
201
+ input: childProcess.stdout,
202
+ terminal: false
203
+ });
204
+
205
+ /** @type {StackServer} */
206
+ const server = {
207
+ process: childProcess,
208
+ rl,
209
+ pending: new Map(),
210
+ buffer: '',
211
+ initialized: false
212
+ };
213
+
214
+ // Handle responses from stack
215
+ rl.on('line', (line) => {
216
+ try {
217
+ const response = JSON.parse(line);
218
+ debug(`<< ${stackId}: ${line.slice(0, 200)}`);
219
+
220
+ // Handle notifications (id is null or missing)
221
+ if (response.id === null || response.id === undefined) {
222
+ debug(`Notification from ${stackId}: ${response.method || 'unknown'}`);
223
+ return;
224
+ }
225
+
226
+ const pending = server.pending.get(response.id);
227
+ if (pending) {
228
+ clearTimeout(pending.timeout);
229
+ server.pending.delete(response.id);
230
+ pending.resolve(response);
231
+ }
232
+ } catch (err) {
233
+ debug(`Failed to parse response from ${stackId}: ${err.message}`);
234
+ }
235
+ });
236
+
237
+ // Pipe stack stderr to our stderr
238
+ childProcess.stderr?.on('data', (data) => {
239
+ process.stderr.write(`[${stackId}] ${data}`);
240
+ });
241
+
242
+ childProcess.on('error', (err) => {
243
+ log(`Stack process error (${stackId}): ${err.message}`);
244
+ serverPool.delete(stackId);
245
+ });
246
+
247
+ childProcess.on('exit', (code, signal) => {
248
+ debug(`Stack ${stackId} exited: code=${code}, signal=${signal}`);
249
+ rl.close();
250
+ serverPool.delete(stackId);
251
+ });
252
+
253
+ return server;
254
+ }
255
+
256
+ /**
257
+ * Get or spawn a stack server
258
+ * @param {string} stackId
259
+ * @returns {StackServer}
260
+ */
261
+ function getOrSpawnServer(stackId) {
262
+ const existing = serverPool.get(stackId);
263
+ if (existing && !existing.process.killed) {
264
+ return existing;
265
+ }
266
+
267
+ const stackConfig = rudiConfig?.stacks?.[stackId];
268
+ if (!stackConfig) {
269
+ throw new Error(`Stack not found: ${stackId}`);
270
+ }
271
+
272
+ if (!stackConfig.installed) {
273
+ throw new Error(`Stack not installed: ${stackId}`);
274
+ }
275
+
276
+ const server = spawnStackServer(stackId, stackConfig);
277
+ serverPool.set(stackId, server);
278
+ return server;
279
+ }
280
+
281
+ /**
282
+ * Send JSON-RPC request to stack server
283
+ * @param {StackServer} server
284
+ * @param {JsonRpcRequest} request
285
+ * @param {number} [timeoutMs]
286
+ * @returns {Promise<JsonRpcResponse>}
287
+ */
288
+ async function sendToStack(server, request, timeoutMs = REQUEST_TIMEOUT_MS) {
289
+ return new Promise((resolve, reject) => {
290
+ const timeout = setTimeout(() => {
291
+ server.pending.delete(request.id);
292
+ reject(new Error(`Request timeout: ${request.method}`));
293
+ }, timeoutMs);
294
+
295
+ server.pending.set(request.id, { resolve, reject, timeout });
296
+
297
+ const line = JSON.stringify(request) + '\n';
298
+ debug(`>> ${line.slice(0, 200)}`);
299
+ server.process.stdin?.write(line);
300
+ });
301
+ }
302
+
303
+ /**
304
+ * Initialize a stack server (MCP handshake)
305
+ * @param {StackServer} server
306
+ * @param {string} stackId
307
+ */
308
+ async function initializeStack(server, stackId) {
309
+ if (server.initialized) return;
310
+
311
+ const initRequest = {
312
+ jsonrpc: '2.0',
313
+ id: `init-${stackId}-${Date.now()}`,
314
+ method: 'initialize',
315
+ params: {
316
+ protocolVersion: PROTOCOL_VERSION,
317
+ capabilities: {},
318
+ clientInfo: {
319
+ name: 'rudi-router',
320
+ version: '1.0.0'
321
+ }
322
+ }
323
+ };
324
+
325
+ try {
326
+ const response = await sendToStack(server, initRequest);
327
+ if (!response.error) {
328
+ server.initialized = true;
329
+ debug(`Stack ${stackId} initialized`);
330
+
331
+ // Send initialized notification
332
+ server.process.stdin?.write(JSON.stringify({
333
+ jsonrpc: '2.0',
334
+ method: 'notifications/initialized'
335
+ }) + '\n');
336
+ }
337
+ } catch (err) {
338
+ debug(`Failed to initialize ${stackId}: ${err.message}`);
339
+ }
340
+ }
341
+
342
+ // =============================================================================
343
+ // MCP HANDLERS
344
+ // =============================================================================
345
+
346
+ /**
347
+ * List all tools from all installed stacks (namespaced)
348
+ * Priority: 1. tool-index.json cache, 2. rudi.json inline tools, 3. live query
349
+ * @returns {Promise<Array<{name: string, description: string, inputSchema: Object}>>}
350
+ */
351
+ async function listTools() {
352
+ const tools = [];
353
+
354
+ for (const [stackId, stackConfig] of Object.entries(rudiConfig?.stacks || {})) {
355
+ if (!stackConfig.installed) continue;
356
+
357
+ // 1. Check tool-index.json cache (from `rudi index` command)
358
+ const indexEntry = toolIndex?.byStack?.[stackId];
359
+ if (indexEntry?.tools && indexEntry.tools.length > 0 && !indexEntry.error) {
360
+ tools.push(...indexEntry.tools.map(t => ({
361
+ name: `${stackId}.${t.name}`,
362
+ description: `[${stackId}] ${t.description || t.name}`,
363
+ inputSchema: t.inputSchema || { type: 'object', properties: {} }
364
+ })));
365
+ continue;
366
+ }
367
+
368
+ // 2. Check inline tools in rudi.json (legacy/fallback)
369
+ if (stackConfig.tools && stackConfig.tools.length > 0) {
370
+ tools.push(...stackConfig.tools.map(t => ({
371
+ name: `${stackId}.${t.name}`,
372
+ description: `[${stackId}] ${t.description || t.name}`,
373
+ inputSchema: t.inputSchema || { type: 'object', properties: {} }
374
+ })));
375
+ continue;
376
+ }
377
+
378
+ // 3. Fall back to querying the stack (slow, spawns server)
379
+ try {
380
+ const server = getOrSpawnServer(stackId);
381
+ await initializeStack(server, stackId);
382
+
383
+ const response = await sendToStack(server, {
384
+ jsonrpc: '2.0',
385
+ id: `list-${stackId}-${Date.now()}`,
386
+ method: 'tools/list'
387
+ });
388
+
389
+ if (response.result?.tools) {
390
+ tools.push(...response.result.tools.map(t => ({
391
+ name: `${stackId}.${t.name}`,
392
+ description: `[${stackId}] ${t.description || t.name}`,
393
+ inputSchema: t.inputSchema || { type: 'object', properties: {} }
394
+ })));
395
+ }
396
+ } catch (err) {
397
+ log(`Failed to list tools from ${stackId}: ${err.message}`);
398
+ // Continue with other stacks
399
+ }
400
+ }
401
+
402
+ return tools;
403
+ }
404
+
405
+ /**
406
+ * Call a tool on the appropriate stack
407
+ * @param {string} toolName - Namespaced tool name (e.g., "slack.send_message")
408
+ * @param {Object} arguments_
409
+ * @returns {Promise<*>}
410
+ */
411
+ async function callTool(toolName, arguments_) {
412
+ // Parse namespace: "slack.send_message" → stackId="slack", actualTool="send_message"
413
+ const dotIndex = toolName.indexOf('.');
414
+ if (dotIndex === -1) {
415
+ throw new Error(`Invalid tool name format: ${toolName} (expected: stack.tool_name)`);
416
+ }
417
+
418
+ const stackId = toolName.slice(0, dotIndex);
419
+ const actualToolName = toolName.slice(dotIndex + 1);
420
+
421
+ if (!rudiConfig?.stacks?.[stackId]) {
422
+ throw new Error(`Stack not found: ${stackId}`);
423
+ }
424
+
425
+ const server = getOrSpawnServer(stackId);
426
+ await initializeStack(server, stackId);
427
+
428
+ const response = await sendToStack(server, {
429
+ jsonrpc: '2.0',
430
+ id: `call-${Date.now()}-${Math.random().toString(36).slice(2)}`,
431
+ method: 'tools/call',
432
+ params: {
433
+ name: actualToolName,
434
+ arguments: arguments_
435
+ }
436
+ });
437
+
438
+ if (response.error) {
439
+ throw new Error(`Tool error: ${response.error.message}`);
440
+ }
441
+
442
+ return response.result;
443
+ }
444
+
445
+ // =============================================================================
446
+ // MAIN MCP PROTOCOL LOOP
447
+ // =============================================================================
448
+
449
+ /**
450
+ * Handle incoming JSON-RPC request
451
+ * @param {JsonRpcRequest} request
452
+ * @returns {Promise<JsonRpcResponse>}
453
+ */
454
+ async function handleRequest(request) {
455
+ /** @type {JsonRpcResponse} */
456
+ const response = {
457
+ jsonrpc: '2.0',
458
+ id: request.id ?? null
459
+ };
460
+
461
+ try {
462
+ switch (request.method) {
463
+ case 'initialize':
464
+ response.result = {
465
+ protocolVersion: PROTOCOL_VERSION,
466
+ capabilities: {
467
+ tools: {}
468
+ },
469
+ serverInfo: {
470
+ name: 'rudi-router',
471
+ version: '1.0.0'
472
+ }
473
+ };
474
+ break;
475
+
476
+ case 'notifications/initialized':
477
+ // Client acknowledges initialization - no response needed for notifications
478
+ return null;
479
+
480
+ case 'tools/list': {
481
+ const tools = await listTools();
482
+ response.result = { tools };
483
+ break;
484
+ }
485
+
486
+ case 'tools/call': {
487
+ const params = request.params;
488
+ const result = await callTool(params.name, params.arguments || {});
489
+ response.result = result;
490
+ break;
491
+ }
492
+
493
+ case 'ping':
494
+ response.result = {};
495
+ break;
496
+
497
+ default:
498
+ // Unknown method
499
+ if (request.id !== null && request.id !== undefined) {
500
+ response.error = {
501
+ code: -32601,
502
+ message: `Method not found: ${request.method}`
503
+ };
504
+ } else {
505
+ // It's a notification, don't respond
506
+ return null;
507
+ }
508
+ }
509
+ } catch (err) {
510
+ response.error = {
511
+ code: -32603,
512
+ message: err.message || 'Internal error'
513
+ };
514
+ }
515
+
516
+ return response;
517
+ }
518
+
519
+ /**
520
+ * Main entry point
521
+ */
522
+ async function main() {
523
+ log('Starting RUDI Router MCP Server');
524
+
525
+ // Load config
526
+ rudiConfig = loadRudiConfig();
527
+ const stackCount = Object.keys(rudiConfig.stacks || {}).length;
528
+ log(`Loaded ${stackCount} stacks from rudi.json`);
529
+
530
+ // Load tool index cache
531
+ toolIndex = loadToolIndex();
532
+ if (toolIndex) {
533
+ const cachedStacks = Object.keys(toolIndex.byStack || {}).length;
534
+ log(`Loaded tool index (${cachedStacks} stacks cached)`);
535
+ } else {
536
+ log('No tool index cache found (run: rudi index)');
537
+ }
538
+
539
+ // Set up stdin/stdout for MCP protocol
540
+ const rl = readline.createInterface({
541
+ input: process.stdin,
542
+ terminal: false
543
+ });
544
+
545
+ rl.on('line', async (line) => {
546
+ try {
547
+ const request = JSON.parse(line);
548
+ debug(`Received: ${line.slice(0, 200)}`);
549
+
550
+ const response = await handleRequest(request);
551
+
552
+ // Don't respond to notifications
553
+ if (response !== null) {
554
+ const responseStr = JSON.stringify(response);
555
+ debug(`Sending: ${responseStr.slice(0, 200)}`);
556
+ process.stdout.write(responseStr + '\n');
557
+ }
558
+ } catch (err) {
559
+ // Parse error
560
+ const errorResponse = {
561
+ jsonrpc: '2.0',
562
+ id: null,
563
+ error: {
564
+ code: -32700,
565
+ message: `Parse error: ${err.message}`
566
+ }
567
+ };
568
+ process.stdout.write(JSON.stringify(errorResponse) + '\n');
569
+ }
570
+ });
571
+
572
+ rl.on('close', () => {
573
+ log('stdin closed, shutting down');
574
+ // Clean up all spawned servers
575
+ for (const [stackId, server] of serverPool) {
576
+ debug(`Killing stack ${stackId}`);
577
+ server.process.kill();
578
+ }
579
+ process.exit(0);
580
+ });
581
+
582
+ // Handle process termination
583
+ process.on('SIGTERM', () => {
584
+ log('SIGTERM received, shutting down');
585
+ for (const [stackId, server] of serverPool) {
586
+ server.process.kill();
587
+ }
588
+ process.exit(0);
589
+ });
590
+
591
+ process.on('SIGINT', () => {
592
+ log('SIGINT received, shutting down');
593
+ for (const [stackId, server] of serverPool) {
594
+ server.process.kill();
595
+ }
596
+ process.exit(0);
597
+ });
598
+ }
599
+
600
+ // Run
601
+ main().catch(err => {
602
+ log(`Fatal error: ${err.message}`);
603
+ process.exit(1);
604
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@learnrudi/cli",
3
- "version": "1.8.8",
3
+ "version": "1.9.1",
4
4
  "description": "RUDI CLI - Install and manage MCP stacks, runtimes, and AI agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -14,7 +14,7 @@
14
14
  ],
15
15
  "scripts": {
16
16
  "start": "node src/index.js",
17
- "build": "esbuild src/index.js --bundle --platform=node --format=cjs --outfile=dist/index.cjs --external:better-sqlite3 --external:@learnrudi/core --external:@learnrudi/db --external:@learnrudi/env --external:@learnrudi/manifest --external:@learnrudi/mcp --external:@learnrudi/registry-client --external:@learnrudi/runner --external:@learnrudi/secrets --external:@learnrudi/utils",
17
+ "build": "esbuild src/index.js --bundle --platform=node --format=cjs --outfile=dist/index.cjs --external:better-sqlite3 --external:@learnrudi/core --external:@learnrudi/db --external:@learnrudi/env --external:@learnrudi/manifest --external:@learnrudi/mcp --external:@learnrudi/registry-client --external:@learnrudi/runner --external:@learnrudi/secrets --external:@learnrudi/utils && cp src/router-mcp.js dist/router-mcp.js",
18
18
  "postinstall": "node scripts/postinstall.js",
19
19
  "prepublishOnly": "npm run build",
20
20
  "test": "node --test src/__tests__/"
@@ -6,6 +6,7 @@
6
6
  * 1. Node.js runtime → ~/.rudi/runtimes/node/
7
7
  * 2. Python runtime → ~/.rudi/runtimes/python/
8
8
  * 3. Creates shims → ~/.rudi/shims/
9
+ * 4. Initializes rudi.json → ~/.rudi/rudi.json
9
10
  */
10
11
 
11
12
  import { execSync } from 'child_process';
@@ -14,8 +15,131 @@ import * as path from 'path';
14
15
  import * as os from 'os';
15
16
 
16
17
  const RUDI_HOME = path.join(os.homedir(), '.rudi');
18
+ const RUDI_JSON_PATH = path.join(RUDI_HOME, 'rudi.json');
19
+ const RUDI_JSON_TMP = path.join(RUDI_HOME, 'rudi.json.tmp');
17
20
  const REGISTRY_BASE = 'https://raw.githubusercontent.com/learn-rudi/registry/main';
18
21
 
22
+ // =============================================================================
23
+ // RUDI.JSON CONFIG MANAGEMENT
24
+ // =============================================================================
25
+
26
+ /**
27
+ * Create a new empty RudiConfig
28
+ */
29
+ function createRudiConfig() {
30
+ const now = new Date().toISOString();
31
+ return {
32
+ version: '1.0.0',
33
+ schemaVersion: 1,
34
+ installed: false,
35
+ installedAt: now,
36
+ updatedAt: now,
37
+ runtimes: {},
38
+ stacks: {},
39
+ binaries: {},
40
+ secrets: {}
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Read rudi.json
46
+ */
47
+ function readRudiConfig() {
48
+ try {
49
+ const content = fs.readFileSync(RUDI_JSON_PATH, 'utf-8');
50
+ return JSON.parse(content);
51
+ } catch (err) {
52
+ if (err.code === 'ENOENT') {
53
+ return null;
54
+ }
55
+ console.log(` ⚠ Failed to read rudi.json: ${err.message}`);
56
+ return null;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Write rudi.json atomically with secure permissions
62
+ */
63
+ function writeRudiConfig(config) {
64
+ config.updatedAt = new Date().toISOString();
65
+ const content = JSON.stringify(config, null, 2);
66
+
67
+ // Write to temp file
68
+ fs.writeFileSync(RUDI_JSON_TMP, content, { mode: 0o600 });
69
+
70
+ // Atomic rename
71
+ fs.renameSync(RUDI_JSON_TMP, RUDI_JSON_PATH);
72
+
73
+ // Ensure permissions (rename may not preserve them)
74
+ fs.chmodSync(RUDI_JSON_PATH, 0o600);
75
+ }
76
+
77
+ /**
78
+ * Initialize rudi.json if it doesn't exist
79
+ */
80
+ function initRudiConfig() {
81
+ if (fs.existsSync(RUDI_JSON_PATH)) {
82
+ console.log(` ✓ rudi.json already exists`);
83
+ return readRudiConfig();
84
+ }
85
+
86
+ const config = createRudiConfig();
87
+ writeRudiConfig(config);
88
+ console.log(` ✓ Created rudi.json`);
89
+ return config;
90
+ }
91
+
92
+ /**
93
+ * Update rudi.json with runtime info after successful download
94
+ */
95
+ function updateRudiConfigRuntime(runtimeId, runtimePath, version) {
96
+ const config = readRudiConfig() || createRudiConfig();
97
+ const platform = process.platform;
98
+
99
+ let bin;
100
+ if (runtimeId === 'node') {
101
+ bin = platform === 'win32' ? 'node.exe' : 'bin/node';
102
+ } else if (runtimeId === 'python') {
103
+ bin = platform === 'win32' ? 'python.exe' : 'bin/python3';
104
+ } else {
105
+ bin = runtimeId;
106
+ }
107
+
108
+ config.runtimes[runtimeId] = {
109
+ path: runtimePath,
110
+ bin: path.join(runtimePath, bin),
111
+ version: version
112
+ };
113
+
114
+ writeRudiConfig(config);
115
+ }
116
+
117
+ /**
118
+ * Update rudi.json with binary info after successful download
119
+ */
120
+ function updateRudiConfigBinary(binaryName, binaryPath, version) {
121
+ const config = readRudiConfig() || createRudiConfig();
122
+
123
+ config.binaries[binaryName] = {
124
+ path: binaryPath,
125
+ bin: path.join(binaryPath, binaryName),
126
+ version: version,
127
+ installed: true,
128
+ installedAt: new Date().toISOString()
129
+ };
130
+
131
+ writeRudiConfig(config);
132
+ }
133
+
134
+ /**
135
+ * Mark rudi.json as fully installed
136
+ */
137
+ function markRudiConfigInstalled() {
138
+ const config = readRudiConfig() || createRudiConfig();
139
+ config.installed = true;
140
+ writeRudiConfig(config);
141
+ }
142
+
19
143
  // Detect platform
20
144
  function getPlatformArch() {
21
145
  const platform = process.platform;
@@ -110,29 +234,115 @@ async function downloadRuntime(runtimeId, platformArch) {
110
234
  // Skip if already installed
111
235
  if (fs.existsSync(binaryPath)) {
112
236
  console.log(` ✓ ${manifest.name} already installed`);
237
+ // Still update rudi.json in case it's missing this runtime
238
+ updateRudiConfigRuntime(runtimeId, destDir, manifest.version);
113
239
  return true;
114
240
  }
115
241
 
116
- return await downloadAndExtract(downloadUrl, destDir, manifest.name);
242
+ const success = await downloadAndExtract(downloadUrl, destDir, manifest.name);
243
+ if (success) {
244
+ // Update rudi.json with runtime info
245
+ updateRudiConfigRuntime(runtimeId, destDir, manifest.version);
246
+ }
247
+ return success;
117
248
  } catch (error) {
118
249
  console.log(` ⚠ Failed to fetch ${runtimeId} manifest: ${error.message}`);
119
250
  return false;
120
251
  }
121
252
  }
122
253
 
123
- // Create the rudi-mcp shim
124
- function createShim() {
125
- const shimPath = path.join(RUDI_HOME, 'shims', 'rudi-mcp');
126
-
127
- const shimContent = `#!/bin/bash
254
+ // Create all shims
255
+ function createShims() {
256
+ // Legacy shim for direct stack access: rudi-mcp <stack>
257
+ const mcpShimPath = path.join(RUDI_HOME, 'shims', 'rudi-mcp');
258
+ const mcpShimContent = `#!/bin/bash
128
259
  # RUDI MCP Shim - Routes agent calls to rudi mcp command
129
260
  # Usage: rudi-mcp <stack-name>
130
261
  exec rudi mcp "$@"
262
+ `;
263
+ fs.writeFileSync(mcpShimPath, mcpShimContent);
264
+ fs.chmodSync(mcpShimPath, 0o755);
265
+ console.log(` ✓ Created rudi-mcp shim`);
266
+
267
+ // New router shim: Master MCP server that aggregates all stacks
268
+ const routerShimPath = path.join(RUDI_HOME, 'shims', 'rudi-router');
269
+ const routerShimContent = `#!/bin/bash
270
+ # RUDI Router - Master MCP server for all installed stacks
271
+ # Reads ~/.rudi/rudi.json and proxies tool calls to correct stack
272
+ # Usage: Point agent config to this shim (no args needed)
273
+
274
+ RUDI_HOME="$HOME/.rudi"
275
+
276
+ # Use bundled Node if available
277
+ if [ -x "$RUDI_HOME/runtimes/node/bin/node" ]; then
278
+ exec "$RUDI_HOME/runtimes/node/bin/node" "$RUDI_HOME/router/router-mcp.js" "$@"
279
+ else
280
+ exec node "$RUDI_HOME/router/router-mcp.js" "$@"
281
+ fi
282
+ `;
283
+ fs.writeFileSync(routerShimPath, routerShimContent);
284
+ fs.chmodSync(routerShimPath, 0o755);
285
+ console.log(` ✓ Created rudi-router shim`);
286
+
287
+ // Create router directory for the router-mcp.js file
288
+ const routerDir = path.join(RUDI_HOME, 'router');
289
+ if (!fs.existsSync(routerDir)) {
290
+ fs.mkdirSync(routerDir, { recursive: true });
291
+ }
292
+
293
+ // Create package.json for ES module support
294
+ const routerPackageJson = path.join(routerDir, 'package.json');
295
+ fs.writeFileSync(routerPackageJson, JSON.stringify({
296
+ name: 'rudi-router',
297
+ type: 'module',
298
+ private: true
299
+ }, null, 2));
300
+
301
+ // Copy router-mcp.js to ~/.rudi/router/
302
+ copyRouterMcp();
303
+ }
304
+
305
+ /**
306
+ * Copy router-mcp.js to ~/.rudi/router/
307
+ * Looks for the file relative to this script's location
308
+ */
309
+ function copyRouterMcp() {
310
+ const routerDir = path.join(RUDI_HOME, 'router');
311
+ const destPath = path.join(routerDir, 'router-mcp.js');
312
+
313
+ // Try multiple possible source locations
314
+ const possibleSources = [
315
+ // When running from npm install (scripts dir)
316
+ path.join(path.dirname(process.argv[1]), '..', 'src', 'router-mcp.js'),
317
+ // When running from npm link or local dev
318
+ path.join(path.dirname(process.argv[1]), '..', 'dist', 'router-mcp.js'),
319
+ // Relative to this script
320
+ path.resolve(import.meta.url.replace('file://', '').replace('/scripts/postinstall.js', ''), 'src', 'router-mcp.js'),
321
+ ];
322
+
323
+ for (const srcPath of possibleSources) {
324
+ try {
325
+ if (fs.existsSync(srcPath)) {
326
+ fs.copyFileSync(srcPath, destPath);
327
+ console.log(` ✓ Installed router-mcp.js`);
328
+ return;
329
+ }
330
+ } catch {
331
+ // Try next path
332
+ }
333
+ }
334
+
335
+ // Create a minimal placeholder if source not found
336
+ // This will be updated on next CLI update
337
+ const placeholderContent = `#!/usr/bin/env node
338
+ // RUDI Router MCP Server - Placeholder
339
+ // This file should be replaced by the actual router-mcp.js from @learnrudi/cli
340
+ console.error('[rudi-router] Router not properly installed. Run: npm update -g @learnrudi/cli');
341
+ process.exit(1);
131
342
  `;
132
343
 
133
- fs.writeFileSync(shimPath, shimContent);
134
- fs.chmodSync(shimPath, 0o755);
135
- console.log(` ✓ Created shim at ${shimPath}`);
344
+ fs.writeFileSync(destPath, placeholderContent, { mode: 0o755 });
345
+ console.log(` ⚠ Created router placeholder (will be updated on next CLI install)`);
136
346
  }
137
347
 
138
348
  // Initialize secrets file with secure permissions
@@ -185,10 +395,17 @@ async function downloadBinary(binaryName, platformArch) {
185
395
  // Skip if already installed
186
396
  if (fs.existsSync(binaryPath)) {
187
397
  console.log(` ✓ ${manifest.name || binaryName} already installed`);
398
+ // Still update rudi.json in case it's missing this binary
399
+ updateRudiConfigBinary(binaryName, destDir, manifest.version);
188
400
  return true;
189
401
  }
190
402
 
191
- return await downloadAndExtract(upstream, destDir, manifest.name || binaryName);
403
+ const success = await downloadAndExtract(upstream, destDir, manifest.name || binaryName);
404
+ if (success) {
405
+ // Update rudi.json with binary info
406
+ updateRudiConfigBinary(binaryName, destDir, manifest.version);
407
+ }
408
+ return success;
192
409
  } catch (error) {
193
410
  console.log(` ⚠ Failed to fetch ${binaryName} manifest: ${error.message}`);
194
411
  return false;
@@ -220,6 +437,12 @@ async function setup() {
220
437
  // Check if already initialized (by Studio or previous install)
221
438
  if (isRudiInitialized()) {
222
439
  console.log('✓ RUDI already initialized');
440
+ // Still init rudi.json in case it's missing (migration from older version)
441
+ console.log('\nUpdating configuration...');
442
+ initRudiConfig();
443
+ // Ensure shims are up to date
444
+ console.log('\nUpdating shims...');
445
+ createShims();
223
446
  console.log(' Skipping runtime and binary downloads\n');
224
447
  console.log('Run `rudi doctor` to check system health\n');
225
448
  return;
@@ -229,8 +452,12 @@ async function setup() {
229
452
  ensureDirectories();
230
453
  console.log('✓ Created ~/.rudi directory structure\n');
231
454
 
455
+ // Initialize rudi.json (single source of truth)
456
+ console.log('Initializing configuration...');
457
+ initRudiConfig();
458
+
232
459
  // Download runtimes from registry manifests
233
- console.log('Installing runtimes...');
460
+ console.log('\nInstalling runtimes...');
234
461
  await downloadRuntime('node', platformArch);
235
462
  await downloadRuntime('python', platformArch);
236
463
 
@@ -239,9 +466,9 @@ async function setup() {
239
466
  await downloadBinary('sqlite', platformArch);
240
467
  await downloadBinary('ripgrep', platformArch);
241
468
 
242
- // Create shim
469
+ // Create shims (rudi-mcp for direct access, rudi-router for aggregated MCP)
243
470
  console.log('\nSetting up shims...');
244
- createShim();
471
+ createShims();
245
472
 
246
473
  // Initialize secrets
247
474
  initSecrets();
@@ -250,6 +477,9 @@ async function setup() {
250
477
  console.log('\nInitializing database...');
251
478
  initDatabase();
252
479
 
480
+ // Mark config as fully installed
481
+ markRudiConfigInstalled();
482
+
253
483
  console.log('\n✓ RUDI setup complete!\n');
254
484
  console.log('Get started:');
255
485
  console.log(' rudi search --all # See available stacks');