@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 +159 -0
- package/dist/router-mcp.js +604 -0
- package/package.json +2 -2
- package/scripts/postinstall.js +243 -13
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.
|
|
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__/"
|
package/scripts/postinstall.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
124
|
-
function
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const
|
|
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(
|
|
134
|
-
|
|
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
|
-
|
|
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('
|
|
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
|
|
469
|
+
// Create shims (rudi-mcp for direct access, rudi-router for aggregated MCP)
|
|
243
470
|
console.log('\nSetting up shims...');
|
|
244
|
-
|
|
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');
|