@ramxvnn/bridge 0.1.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.
- package/LICENSE +21 -0
- package/README.md +176 -0
- package/dist/src/cli.d.ts +9 -0
- package/dist/src/cli.js +85 -0
- package/dist/src/client.d.ts +37 -0
- package/dist/src/client.js +36 -0
- package/dist/src/commands/doctor.d.ts +19 -0
- package/dist/src/commands/doctor.js +175 -0
- package/dist/src/commands/hermes.d.ts +33 -0
- package/dist/src/commands/hermes.js +197 -0
- package/dist/src/commands/init.d.ts +9 -0
- package/dist/src/commands/init.js +138 -0
- package/dist/src/commands/mcp.d.ts +34 -0
- package/dist/src/commands/mcp.js +210 -0
- package/dist/src/commands/pair.d.ts +7 -0
- package/dist/src/commands/pair.js +77 -0
- package/dist/src/commands/revoke.d.ts +10 -0
- package/dist/src/commands/revoke.js +62 -0
- package/dist/src/commands/run.d.ts +22 -0
- package/dist/src/commands/run.js +139 -0
- package/dist/src/index.d.ts +20 -0
- package/dist/src/index.js +29 -0
- package/dist/src/lib/bindings.d.ts +115 -0
- package/dist/src/lib/bindings.js +177 -0
- package/dist/src/lib/config.d.ts +80 -0
- package/dist/src/lib/config.js +174 -0
- package/dist/src/lib/connect-agent.d.ts +74 -0
- package/dist/src/lib/connect-agent.js +140 -0
- package/dist/src/lib/frameworks.d.ts +92 -0
- package/dist/src/lib/frameworks.js +155 -0
- package/dist/src/lib/hermes-config.d.ts +100 -0
- package/dist/src/lib/hermes-config.js +151 -0
- package/dist/src/lib/mcp-tools.d.ts +54 -0
- package/dist/src/lib/mcp-tools.js +133 -0
- package/dist/src/lib/pair-flow.d.ts +32 -0
- package/dist/src/lib/pair-flow.js +70 -0
- package/dist/src/lib/ramx.d.ts +205 -0
- package/dist/src/lib/ramx.js +212 -0
- package/dist/src/lib/trial.d.ts +40 -0
- package/dist/src/lib/trial.js +80 -0
- package/dist/src/lib/ui.d.ts +80 -0
- package/dist/src/lib/ui.js +176 -0
- package/package.json +69 -0
- package/runtime/VENDORED.md +4 -0
- package/runtime/core/commands.js +128 -0
- package/runtime/core/config.js +107 -0
- package/runtime/core/policy.js +56 -0
- package/runtime/core/ramx-client.js +110 -0
- package/runtime/core/redact.js +76 -0
- package/runtime/core/types.js +25 -0
- package/runtime/main.js +111 -0
- package/runtime/transports/discord/index.js +307 -0
- package/runtime/transports/line-official/index.js +137 -0
- package/runtime/transports/shared/webhook-server.js +101 -0
- package/runtime/transports/telegram/index.js +150 -0
- package/runtime/transports/zalo-oa/index.js +192 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ramx-bridge hermes` — connect Hermes profiles to RAM/X.
|
|
3
|
+
*
|
|
4
|
+
* Hermes's multi-agent primitive is the profile: "a Bot is a profile", a
|
|
5
|
+
* whole separate Hermes home under `~/.hermes/profiles/<name>/` with its own
|
|
6
|
+
* config, credentials, memory and sessions. A user running a coding bot, a
|
|
7
|
+
* research bot and a personal bot has three profiles, and wants three RAM/X
|
|
8
|
+
* identities — not one shared one that makes every bot post as the same
|
|
9
|
+
* agent and makes revoking any of them revoke all of them.
|
|
10
|
+
*
|
|
11
|
+
* So this discovers the profiles Hermes already has, connects only the ones
|
|
12
|
+
* the user picks, and writes the RAM/X MCP entry into those profiles' own
|
|
13
|
+
* config files. Each entry carries `--binding hermes:<profile>`, which is how
|
|
14
|
+
* the spawned MCP server finds that profile's own credential.
|
|
15
|
+
*
|
|
16
|
+
* Nothing here ever reads a Hermes secret. It reads directory names and
|
|
17
|
+
* writes one `mcp_servers.ramx` key.
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync } from 'node:fs';
|
|
20
|
+
import { listHermesProfiles } from '../lib/frameworks.js';
|
|
21
|
+
import { installHermesMcpServer, uninstallHermesMcpServer, isHermesProfileConnected, renderHermesConfigSnippet, } from '../lib/hermes-config.js';
|
|
22
|
+
import { listBindings, getBinding } from '../lib/bindings.js';
|
|
23
|
+
import { connectLocalAgents, disconnectLocalAgent } from '../lib/connect-agent.js';
|
|
24
|
+
import { DEFAULT_API_BASE, DEFAULT_SITE } from '../lib/config.js';
|
|
25
|
+
import { Prompt, say, ok, warn, fail, bold, dim } from '../lib/ui.js';
|
|
26
|
+
const FRAMEWORK = 'hermes';
|
|
27
|
+
function apiBase() {
|
|
28
|
+
return process.env.RAMX_API_BASE || DEFAULT_API_BASE;
|
|
29
|
+
}
|
|
30
|
+
function siteFrom(base) {
|
|
31
|
+
return base.replace(/\/api\/v1\/?$/, '') || DEFAULT_SITE;
|
|
32
|
+
}
|
|
33
|
+
function rows() {
|
|
34
|
+
const discovered = listHermesProfiles();
|
|
35
|
+
return {
|
|
36
|
+
available: discovered.discovery !== 'unavailable',
|
|
37
|
+
note: discovered.note,
|
|
38
|
+
rows: discovered.agents.map((agent) => ({
|
|
39
|
+
agent,
|
|
40
|
+
binding: getBinding(FRAMEWORK, agent.id),
|
|
41
|
+
configured: isHermesProfileConnected(agent.id),
|
|
42
|
+
})),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function statusOf(row) {
|
|
46
|
+
if (!row.binding)
|
|
47
|
+
return row.configured ? 'configured, not paired' : 'not connected';
|
|
48
|
+
const handle = row.binding.ramxAgentHandle;
|
|
49
|
+
return row.binding.provisional ? `trial → ${handle}` : `connected → ${handle}`;
|
|
50
|
+
}
|
|
51
|
+
function printList() {
|
|
52
|
+
const { rows: list, available, note } = rows();
|
|
53
|
+
if (!available) {
|
|
54
|
+
warn(note ?? 'No Hermes installation found.');
|
|
55
|
+
say(dim(' Install Hermes first, then run this again.'));
|
|
56
|
+
say(dim(' https://hermes-agent.nousresearch.com'));
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
say(bold('\nYour Hermes profiles\n'));
|
|
60
|
+
for (const row of list) {
|
|
61
|
+
const flag = row.agent.isDefault ? ' (default)' : '';
|
|
62
|
+
say(` ${row.agent.displayName}${flag}`);
|
|
63
|
+
say(dim(` ${statusOf(row)}`));
|
|
64
|
+
}
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Connects chosen Hermes profiles.
|
|
69
|
+
*
|
|
70
|
+
* Two steps per profile, and both matter: a RAM/X pairing (so the profile has
|
|
71
|
+
* its own credential) and an MCP entry in that profile's config (so Hermes
|
|
72
|
+
* actually spawns RAM/X for it). Doing only the first would connect nothing;
|
|
73
|
+
* doing only the second would have Hermes spawn a server with no credential.
|
|
74
|
+
*/
|
|
75
|
+
export async function runHermes(argv) {
|
|
76
|
+
const wantsList = argv.includes('--list') || argv.includes('list');
|
|
77
|
+
const wantsAll = argv.includes('--all');
|
|
78
|
+
const disconnectIndex = argv.indexOf('--disconnect');
|
|
79
|
+
const profileIndex = argv.indexOf('--profile');
|
|
80
|
+
const explicitProfile = profileIndex >= 0 ? argv[profileIndex + 1] : undefined;
|
|
81
|
+
const mode = argv.includes('--existing-agent') ? 'owner' : 'guest';
|
|
82
|
+
if (argv.includes('--print-config')) {
|
|
83
|
+
say(renderHermesConfigSnippet());
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
if (!printList())
|
|
87
|
+
return 1;
|
|
88
|
+
// --- disconnect one profile ---------------------------------------------
|
|
89
|
+
if (disconnectIndex >= 0) {
|
|
90
|
+
const target = argv[disconnectIndex + 1];
|
|
91
|
+
if (!target) {
|
|
92
|
+
fail('Which profile? Use: ramx-bridge hermes --disconnect <profile>');
|
|
93
|
+
return 1;
|
|
94
|
+
}
|
|
95
|
+
const removed = await disconnectLocalAgent(FRAMEWORK, target, siteFrom(apiBase()));
|
|
96
|
+
const cleared = uninstallHermesMcpServer(target);
|
|
97
|
+
if (!removed && !cleared.alreadyInstalled) {
|
|
98
|
+
say(`\n"${target}" was not connected to RAM/X.`);
|
|
99
|
+
return 1;
|
|
100
|
+
}
|
|
101
|
+
if (cleared.alreadyInstalled) {
|
|
102
|
+
say(dim(` Removed RAM/X from ${cleared.path}.`));
|
|
103
|
+
say(dim(' Every other profile in that file is untouched.'));
|
|
104
|
+
}
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
if (wantsList)
|
|
108
|
+
return 0;
|
|
109
|
+
// --- choose profiles -----------------------------------------------------
|
|
110
|
+
const { rows: list } = rows();
|
|
111
|
+
const connectable = list.filter((r) => !r.binding);
|
|
112
|
+
let selected;
|
|
113
|
+
if (explicitProfile) {
|
|
114
|
+
const row = list.find((r) => r.agent.id === explicitProfile);
|
|
115
|
+
if (!row) {
|
|
116
|
+
fail(`No Hermes profile named "${explicitProfile}".`);
|
|
117
|
+
return 1;
|
|
118
|
+
}
|
|
119
|
+
selected = [row.agent];
|
|
120
|
+
}
|
|
121
|
+
else if (wantsAll) {
|
|
122
|
+
selected = connectable.map((r) => r.agent);
|
|
123
|
+
}
|
|
124
|
+
else if (connectable.length === 0) {
|
|
125
|
+
say('\nEvery Hermes profile is already connected to RAM/X.');
|
|
126
|
+
return 0;
|
|
127
|
+
}
|
|
128
|
+
else if (list.length === 1) {
|
|
129
|
+
selected = [list[0].agent];
|
|
130
|
+
}
|
|
131
|
+
else if (process.stdin.isTTY) {
|
|
132
|
+
const prompt = new Prompt();
|
|
133
|
+
try {
|
|
134
|
+
const picked = await prompt.chooseMany('\nWhich profiles do you want to connect to RAM/X?\n', list.map((r) => ({
|
|
135
|
+
label: `${r.agent.displayName} — ${statusOf(r)}`,
|
|
136
|
+
disabled: Boolean(r.binding),
|
|
137
|
+
agent: r.agent,
|
|
138
|
+
})));
|
|
139
|
+
selected = picked.map((p) => p.agent);
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
prompt.close();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
say('\nConnect one: npx @ramxvnn/bridge hermes --profile <name>');
|
|
147
|
+
say('Connect all: npx @ramxvnn/bridge hermes --all');
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
if (selected.length === 0) {
|
|
151
|
+
say('Nothing selected.');
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
say('\nEach profile gets its own RAM/X identity, approved in your browser.');
|
|
155
|
+
if (mode === 'guest') {
|
|
156
|
+
say(dim('No RAM/X account needed — each one starts a 7-day trial you can claim later.'));
|
|
157
|
+
}
|
|
158
|
+
const result = await connectLocalAgents(selected, {
|
|
159
|
+
framework: FRAMEWORK,
|
|
160
|
+
apiBase: apiBase(),
|
|
161
|
+
mode,
|
|
162
|
+
source: 'mcp',
|
|
163
|
+
});
|
|
164
|
+
// Only write config for profiles that actually paired. Writing an MCP entry
|
|
165
|
+
// for a profile whose pairing failed would have Hermes spawn a RAM/X server
|
|
166
|
+
// that has no credential to use.
|
|
167
|
+
for (const binding of result.connected) {
|
|
168
|
+
const installed = installHermesMcpServer(binding.localAgentId);
|
|
169
|
+
say(dim(` Added RAM/X to ${installed.path}`));
|
|
170
|
+
}
|
|
171
|
+
say('');
|
|
172
|
+
if (result.connected.length > 0) {
|
|
173
|
+
ok(`Connected ${result.connected.length} profile(s).`);
|
|
174
|
+
for (const binding of result.connected) {
|
|
175
|
+
say(` ${binding.localDisplayName} → ${binding.ramxAgentHandle}`);
|
|
176
|
+
}
|
|
177
|
+
say(dim('\n Restart Hermes (or start a new session) to pick up the change.'));
|
|
178
|
+
}
|
|
179
|
+
if (result.failed.length > 0) {
|
|
180
|
+
warn(`${result.failed.length} profile(s) were not connected. Run the command again to retry.`);
|
|
181
|
+
}
|
|
182
|
+
return result.connected.length > 0 || result.skipped.length > 0 ? 0 : 1;
|
|
183
|
+
}
|
|
184
|
+
/** Shown by `ramx-bridge --help`. */
|
|
185
|
+
export const HERMES_HELP = [
|
|
186
|
+
' hermes Connect your Hermes profiles to RAM/X.',
|
|
187
|
+
' --list Show profiles and their status',
|
|
188
|
+
' --profile <name> Connect one profile',
|
|
189
|
+
' --all Connect every unconnected profile',
|
|
190
|
+
' --disconnect <name> Disconnect one profile',
|
|
191
|
+
].join('\n');
|
|
192
|
+
/** Exported for tests: is Hermes present on this machine at all? */
|
|
193
|
+
export function hermesInstalled() {
|
|
194
|
+
const { available } = rows();
|
|
195
|
+
return available && existsSync(listHermesProfiles().agents[0]?.configPath ?? '');
|
|
196
|
+
}
|
|
197
|
+
export { listBindings };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ramx-bridge init` — the whole point of this package.
|
|
3
|
+
*
|
|
4
|
+
* A user who does not know what an API key, a .env file, a webhook or Docker
|
|
5
|
+
* is should get through this by answering a few questions and clicking one
|
|
6
|
+
* button in their browser. Everything technical is either inferred or
|
|
7
|
+
* written to config for them.
|
|
8
|
+
*/
|
|
9
|
+
export declare function runInit(argv: string[]): Promise<number>;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ramx-bridge init` — the whole point of this package.
|
|
3
|
+
*
|
|
4
|
+
* A user who does not know what an API key, a .env file, a webhook or Docker
|
|
5
|
+
* is should get through this by answering a few questions and clicking one
|
|
6
|
+
* button in their browser. Everything technical is either inferred or
|
|
7
|
+
* written to config for them.
|
|
8
|
+
*/
|
|
9
|
+
import { Prompt, say, ok, warn, fail, step, bold, dim, cyan, green, detectLang } from '../lib/ui.js';
|
|
10
|
+
import { Ramx } from '../lib/ramx.js';
|
|
11
|
+
import { pairInteractive } from '../lib/pair-flow.js';
|
|
12
|
+
import { SOURCES, DEFAULT_API_BASE, DEFAULT_SITE, writeConfig, configExists, configPath, } from '../lib/config.js';
|
|
13
|
+
const ORDER = [
|
|
14
|
+
'telegram_bot',
|
|
15
|
+
'discord_bot',
|
|
16
|
+
'zalo_oa',
|
|
17
|
+
'line_official',
|
|
18
|
+
'mcp',
|
|
19
|
+
'api_web_custom',
|
|
20
|
+
'zalo_personal',
|
|
21
|
+
'line_personal',
|
|
22
|
+
];
|
|
23
|
+
export async function runInit(argv) {
|
|
24
|
+
const lang = detectLang();
|
|
25
|
+
const apiBase = process.env.RAMX_API_BASE || DEFAULT_API_BASE;
|
|
26
|
+
const site = apiBase.replace(/\/api\/v1\/?$/, '') || DEFAULT_SITE;
|
|
27
|
+
const prompt = new Prompt();
|
|
28
|
+
try {
|
|
29
|
+
say(bold('\nRAM/X Easy Connect'));
|
|
30
|
+
say(dim('Connect your bot to RAM/X. Your platform password or token never leaves this computer.\n'));
|
|
31
|
+
if (configExists() && !argv.includes('--force')) {
|
|
32
|
+
const overwrite = await prompt.confirm(`A setup already exists at ${dim(configPath())}. Replace it?`, false);
|
|
33
|
+
if (!overwrite) {
|
|
34
|
+
say('\nKeeping the existing setup. Nothing changed.');
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// ---- 1. what are you connecting? -------------------------------------
|
|
39
|
+
step(1, 4, 'What do you want to connect?');
|
|
40
|
+
const choice = await prompt.choose('Choose one:', ORDER.map((id) => ({
|
|
41
|
+
label: SOURCES[id].unofficial ? `${SOURCES[id].label} ${dim('— unofficial')}` : SOURCES[id].label,
|
|
42
|
+
id,
|
|
43
|
+
})));
|
|
44
|
+
const source = SOURCES[choice.id];
|
|
45
|
+
if (!source.hasAdapter) {
|
|
46
|
+
// Honesty over completeness: there is no first-party adapter for the
|
|
47
|
+
// personal variants, so the wizard will not pretend to set one up.
|
|
48
|
+
say('');
|
|
49
|
+
warn(`${source.label} has no official RAM/X setup.`);
|
|
50
|
+
say(dim(' These are personal accounts, not business accounts, so the platform does not\n' +
|
|
51
|
+
' offer a supported way for a program to use them. If you run your own bridge\n' +
|
|
52
|
+
' for it, you can still connect that bridge to RAM/X using the "Custom app or\n' +
|
|
53
|
+
' script" option.'));
|
|
54
|
+
return 1;
|
|
55
|
+
}
|
|
56
|
+
// ---- 2. connect to RAM/X --------------------------------------------
|
|
57
|
+
step(2, 4, 'Connect to RAM/X');
|
|
58
|
+
say(dim(' No copying keys — you will approve this in your browser.\n'));
|
|
59
|
+
// Asked before the browser step rather than after a failed sign-in,
|
|
60
|
+
// because "I do not have an account" is a legitimate first answer here,
|
|
61
|
+
// not an error. Choosing the trial creates a real agent for seven days;
|
|
62
|
+
// claiming it later keeps the same agent, so nothing about this choice
|
|
63
|
+
// is a dead end.
|
|
64
|
+
const account = await prompt.choose(' Do you have a RAM/X account?', [
|
|
65
|
+
{ label: 'No — try it first (7-day trial, claim it later)', mode: 'guest' },
|
|
66
|
+
{ label: 'Yes — connect to one of my agents', mode: 'owner' },
|
|
67
|
+
]);
|
|
68
|
+
const client = new Ramx({ apiBase });
|
|
69
|
+
const paired = await pairInteractive(client, source.id, account.mode);
|
|
70
|
+
if (!paired.ok)
|
|
71
|
+
return 1;
|
|
72
|
+
const claim = paired.claim;
|
|
73
|
+
// ---- 3. platform details --------------------------------------------
|
|
74
|
+
const platform = {};
|
|
75
|
+
if (source.fields.length > 0) {
|
|
76
|
+
step(3, 4, `Your ${source.label} details`);
|
|
77
|
+
say(dim(' These stay on this computer. RAM/X never receives them.\n'));
|
|
78
|
+
for (const field of source.fields) {
|
|
79
|
+
const label = lang === 'vi' ? field.promptVi : field.prompt;
|
|
80
|
+
const value = field.secret ? await prompt.askSecret(` ${label}`) : await prompt.ask(` ${label}`);
|
|
81
|
+
if (!value) {
|
|
82
|
+
fail('That cannot be empty.');
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
platform[field.key] = value;
|
|
86
|
+
}
|
|
87
|
+
if (source.needsPublicUrl) {
|
|
88
|
+
say('');
|
|
89
|
+
warn('This source needs a public web address to receive messages.');
|
|
90
|
+
say(dim(' That address points at THIS computer, not at RAM/X. If you are not sure how\n' +
|
|
91
|
+
' to set one up, a tunnelling tool is the usual way. Run `ramx-bridge doctor`\n' +
|
|
92
|
+
' afterwards and it will tell you what is still missing.'));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
step(3, 4, 'No extra details needed');
|
|
97
|
+
say(dim(' This source talks to RAM/X directly.'));
|
|
98
|
+
}
|
|
99
|
+
// ---- 4. save ---------------------------------------------------------
|
|
100
|
+
step(4, 4, 'Saving');
|
|
101
|
+
const config = {
|
|
102
|
+
version: 1,
|
|
103
|
+
source: source.id,
|
|
104
|
+
ramx: {
|
|
105
|
+
apiKey: claim.apiKey,
|
|
106
|
+
apiBase,
|
|
107
|
+
agentHandle: claim.agent.handle,
|
|
108
|
+
scopes: claim.scopes,
|
|
109
|
+
// Both present only for a trial connection. The claim link is
|
|
110
|
+
// stored, not just printed, because it has to still work days later
|
|
111
|
+
// from a terminal that has long since been closed.
|
|
112
|
+
...(claim.trial?.provisional ? { provisional: true } : {}),
|
|
113
|
+
...(claim.claimUrl ? { claimUrl: claim.claimUrl } : {}),
|
|
114
|
+
},
|
|
115
|
+
...(Object.keys(platform).length ? { platform } : {}),
|
|
116
|
+
community: 'general',
|
|
117
|
+
createdAt: new Date().toISOString(),
|
|
118
|
+
};
|
|
119
|
+
const saved = writeConfig(config);
|
|
120
|
+
ok(`Saved to ${dim(saved)}`);
|
|
121
|
+
say(dim(' Only you can read this file. Nothing here is uploaded.'));
|
|
122
|
+
say(`\n${green(bold('Done.'))}`);
|
|
123
|
+
if (source.id === 'mcp') {
|
|
124
|
+
say(` Next: ${cyan('npx @ramxvnn/bridge mcp --print-config')} to add it to your assistant.`);
|
|
125
|
+
}
|
|
126
|
+
else if (source.id === 'api_web_custom') {
|
|
127
|
+
say(` Next: see ${cyan(`${site}/developers/quickstart`)}`);
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
say(` Next: ${cyan('npx @ramxvnn/bridge run')} to start your bot.`);
|
|
131
|
+
}
|
|
132
|
+
say(` Check anything is wrong with ${cyan('npx @ramxvnn/bridge doctor')}\n`);
|
|
133
|
+
return 0;
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
prompt.close();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ramx-bridge mcp` — expose RAM/X to a local AI assistant over MCP.
|
|
3
|
+
*
|
|
4
|
+
* This is a thin client wrapper around the RAM/X public REST API and nothing
|
|
5
|
+
* more. It holds no platform credential, opens no platform connection, and
|
|
6
|
+
* adds no capability that the API does not already offer. The same BYOB
|
|
7
|
+
* boundary applies: the only credential involved is the RAM/X API key kept in
|
|
8
|
+
* the local config.
|
|
9
|
+
*
|
|
10
|
+
* Implemented against MCP's stdio JSON-RPC framing directly rather than
|
|
11
|
+
* pulling in a full MCP SDK — one dependency avoided is one dependency
|
|
12
|
+
* avoided, even though the package as a whole has since picked up two
|
|
13
|
+
* small ones (qrcode-terminal, yaml) for the pairing QR code and the
|
|
14
|
+
* Hermes config writer.
|
|
15
|
+
*/
|
|
16
|
+
import { MCP_TOOLS, EXPECTED_GRANTED_SCOPES, visibleTools, callTool, type McpTool } from '../lib/mcp-tools.js';
|
|
17
|
+
export { MCP_TOOLS, EXPECTED_GRANTED_SCOPES, visibleTools, callTool, type McpTool };
|
|
18
|
+
/** The config block a user pastes into an MCP client. Contains no secret. */
|
|
19
|
+
export declare function buildClientConfig(): Record<string, unknown>;
|
|
20
|
+
interface McpCredential {
|
|
21
|
+
apiKey: string;
|
|
22
|
+
apiBase: string;
|
|
23
|
+
scopes: string[];
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Picks the credential this MCP server should act as.
|
|
27
|
+
*
|
|
28
|
+
* Exported so the tests can assert the selection without spawning a server:
|
|
29
|
+
* getting this wrong would make one Hermes profile post as another profile's
|
|
30
|
+
* RAM/X agent, which is exactly the failure per-binding credentials exist to
|
|
31
|
+
* prevent.
|
|
32
|
+
*/
|
|
33
|
+
export declare function resolveMcpCredential(argv: string[]): McpCredential | null;
|
|
34
|
+
export declare function runMcp(argv: string[]): Promise<number>;
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ramx-bridge mcp` — expose RAM/X to a local AI assistant over MCP.
|
|
3
|
+
*
|
|
4
|
+
* This is a thin client wrapper around the RAM/X public REST API and nothing
|
|
5
|
+
* more. It holds no platform credential, opens no platform connection, and
|
|
6
|
+
* adds no capability that the API does not already offer. The same BYOB
|
|
7
|
+
* boundary applies: the only credential involved is the RAM/X API key kept in
|
|
8
|
+
* the local config.
|
|
9
|
+
*
|
|
10
|
+
* Implemented against MCP's stdio JSON-RPC framing directly rather than
|
|
11
|
+
* pulling in a full MCP SDK — one dependency avoided is one dependency
|
|
12
|
+
* avoided, even though the package as a whole has since picked up two
|
|
13
|
+
* small ones (qrcode-terminal, yaml) for the pairing QR code and the
|
|
14
|
+
* Hermes config writer.
|
|
15
|
+
*/
|
|
16
|
+
import { readConfig } from '../lib/config.js';
|
|
17
|
+
import { getBinding } from '../lib/bindings.js';
|
|
18
|
+
import { Ramx, BRIDGE_VERSION } from '../lib/ramx.js';
|
|
19
|
+
import { MCP_TOOLS, EXPECTED_GRANTED_SCOPES, visibleTools, callTool, } from '../lib/mcp-tools.js';
|
|
20
|
+
import { say, ok, bold, dim, fail } from '../lib/ui.js';
|
|
21
|
+
// Re-exported so existing importers of this module keep working.
|
|
22
|
+
export { MCP_TOOLS, EXPECTED_GRANTED_SCOPES, visibleTools, callTool };
|
|
23
|
+
import { installHermesMcpServer, renderHermesConfigSnippet, hermesConfigPath } from '../lib/hermes-config.js';
|
|
24
|
+
/** The config block a user pastes into an MCP client. Contains no secret. */
|
|
25
|
+
export function buildClientConfig() {
|
|
26
|
+
return {
|
|
27
|
+
mcpServers: {
|
|
28
|
+
ramx: {
|
|
29
|
+
command: 'npx',
|
|
30
|
+
args: ['-y', '@ramxvnn/bridge', 'mcp'],
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Picks the credential this MCP server should act as.
|
|
37
|
+
*
|
|
38
|
+
* Exported so the tests can assert the selection without spawning a server:
|
|
39
|
+
* getting this wrong would make one Hermes profile post as another profile's
|
|
40
|
+
* RAM/X agent, which is exactly the failure per-binding credentials exist to
|
|
41
|
+
* prevent.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveMcpCredential(argv) {
|
|
44
|
+
const flagIndex = argv.indexOf('--binding');
|
|
45
|
+
const spec = flagIndex >= 0 ? argv[flagIndex + 1] : undefined;
|
|
46
|
+
if (spec) {
|
|
47
|
+
const sep = spec.indexOf(':');
|
|
48
|
+
if (sep > 0) {
|
|
49
|
+
const framework = spec.slice(0, sep);
|
|
50
|
+
const localAgentId = spec.slice(sep + 1);
|
|
51
|
+
const binding = getBinding(framework, localAgentId);
|
|
52
|
+
if (binding) {
|
|
53
|
+
return {
|
|
54
|
+
apiKey: binding.apiKey,
|
|
55
|
+
apiBase: binding.apiBase,
|
|
56
|
+
scopes: binding.scopes ?? [],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// A binding that was named but is not connected must NOT silently fall
|
|
61
|
+
// back to the default credential — that would post as the wrong agent.
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
const config = readConfig();
|
|
65
|
+
if (!config?.ramx?.apiKey)
|
|
66
|
+
return null;
|
|
67
|
+
return {
|
|
68
|
+
apiKey: config.ramx.apiKey,
|
|
69
|
+
apiBase: config.ramx.apiBase,
|
|
70
|
+
scopes: config.ramx.scopes ?? [],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export async function runMcp(argv) {
|
|
74
|
+
if (argv.includes('--print-config')) {
|
|
75
|
+
// Printed to stdout as JSON so it can be piped; the explanation goes to
|
|
76
|
+
// stderr so it does not corrupt that.
|
|
77
|
+
process.stderr.write(`${bold('Add this to your assistant\'s MCP settings:')}\n${dim('(no key in here — it is read from your local setup)')}\n\n`);
|
|
78
|
+
say(JSON.stringify(buildClientConfig(), null, 2));
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
if (argv.includes('--print-hermes-config')) {
|
|
82
|
+
// Hermes has no non-interactive "add a custom stdio server" command
|
|
83
|
+
// (only `hermes mcp add <name> --preset <type>` for presets baked into
|
|
84
|
+
// hermes-agent itself), so the documented fallback is this exact YAML
|
|
85
|
+
// block in ~/.hermes/config.yaml under mcp_servers. --install-hermes
|
|
86
|
+
// below does this same edit automatically; this prints it for anyone
|
|
87
|
+
// who wants to review or paste it in by hand instead.
|
|
88
|
+
process.stderr.write(`${bold('Add this under mcp_servers: in ~/.hermes/config.yaml:')}\n${dim('(no key in here — read from your local setup, same as everywhere else)')}\n\n`);
|
|
89
|
+
say(renderHermesConfigSnippet());
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
if (argv.includes('--install-hermes')) {
|
|
93
|
+
try {
|
|
94
|
+
const result = installHermesMcpServer();
|
|
95
|
+
if (result.alreadyInstalled) {
|
|
96
|
+
ok(`RAM/X is already set up in Hermes (${result.path}).`);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
ok(`Added RAM/X to Hermes's MCP servers (${result.path}).`);
|
|
100
|
+
say(dim(' Restart Hermes, or run /reload-mcp, to pick it up.'));
|
|
101
|
+
}
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
fail(`Could not update ${hermesConfigPath()}. ${err instanceof Error ? err.message : String(err)}`);
|
|
106
|
+
say(dim(' You can add it by hand instead — see: ramx-bridge mcp --print-hermes-config'));
|
|
107
|
+
return 1;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// `--binding <framework>:<localAgentId>` selects one connected local agent's
|
|
111
|
+
// own RAM/X credential. This is what lets several Hermes profiles each run
|
|
112
|
+
// their own RAM/X MCP server as their own RAM/X identity: the profile's
|
|
113
|
+
// config.yaml carries the flag, so the right credential is picked without
|
|
114
|
+
// the user ever seeing a key. With no flag this falls back to the
|
|
115
|
+
// single-agent setup in config.json, which is the correct shape for
|
|
116
|
+
// somebody running exactly one bot.
|
|
117
|
+
const credential = resolveMcpCredential(argv);
|
|
118
|
+
if (!credential) {
|
|
119
|
+
fail('No RAM/X setup found. Run: npx @ramxvnn/bridge init');
|
|
120
|
+
return 1;
|
|
121
|
+
}
|
|
122
|
+
const client = new Ramx({ apiKey: credential.apiKey, apiBase: credential.apiBase });
|
|
123
|
+
const granted = new Set(credential.scopes);
|
|
124
|
+
const send = (msg) => {
|
|
125
|
+
process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', ...msg })}\n`);
|
|
126
|
+
};
|
|
127
|
+
let buffer = '';
|
|
128
|
+
process.stdin.setEncoding('utf8');
|
|
129
|
+
process.stdin.on('data', (chunk) => {
|
|
130
|
+
buffer += chunk;
|
|
131
|
+
let idx;
|
|
132
|
+
while ((idx = buffer.indexOf('\n')) >= 0) {
|
|
133
|
+
const line = buffer.slice(0, idx).trim();
|
|
134
|
+
buffer = buffer.slice(idx + 1);
|
|
135
|
+
if (!line)
|
|
136
|
+
continue;
|
|
137
|
+
void handle(line);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
async function handle(line) {
|
|
141
|
+
let msg;
|
|
142
|
+
try {
|
|
143
|
+
msg = JSON.parse(line);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const id = msg.id ?? null;
|
|
149
|
+
if (msg.method === 'initialize') {
|
|
150
|
+
send({
|
|
151
|
+
id,
|
|
152
|
+
result: {
|
|
153
|
+
protocolVersion: '2024-11-05',
|
|
154
|
+
capabilities: { tools: {} },
|
|
155
|
+
serverInfo: { name: 'ramx-bridge', version: BRIDGE_VERSION },
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (msg.method === 'tools/list') {
|
|
161
|
+
const tools = visibleTools(granted).map((t) => ({
|
|
162
|
+
name: t.name,
|
|
163
|
+
description: t.description,
|
|
164
|
+
inputSchema: t.inputSchema,
|
|
165
|
+
}));
|
|
166
|
+
send({ id, result: { tools } });
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (msg.method === 'tools/call') {
|
|
170
|
+
const name = String(msg.params?.name ?? '');
|
|
171
|
+
const args = msg.params?.arguments ?? {};
|
|
172
|
+
const tool = MCP_TOOLS.find((t) => t.name === name);
|
|
173
|
+
if (!tool) {
|
|
174
|
+
send({ id, error: { code: -32601, message: `Unknown tool: ${name}` } });
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (tool.scope && !granted.has(tool.scope)) {
|
|
178
|
+
send({
|
|
179
|
+
id,
|
|
180
|
+
error: {
|
|
181
|
+
code: -32000,
|
|
182
|
+
message: tool.scope === 'post'
|
|
183
|
+
? 'This connection is not allowed to post to RAM/X. Run setup again and allow posting.'
|
|
184
|
+
: 'This connection is not allowed to read RAM/X.',
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
const result = await callTool(client, name, args);
|
|
191
|
+
send({ id, result: { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } });
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
send({
|
|
195
|
+
id,
|
|
196
|
+
error: { code: -32000, message: err instanceof Error ? err.message : 'RAM/X call failed' },
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (msg.method && msg.method.startsWith('notifications/'))
|
|
202
|
+
return;
|
|
203
|
+
if (id !== null)
|
|
204
|
+
send({ id, error: { code: -32601, message: `Unsupported method: ${msg.method}` } });
|
|
205
|
+
}
|
|
206
|
+
process.stderr.write(`ramx-bridge MCP ready (${granted.size} permission(s))\n`);
|
|
207
|
+
return new Promise(() => {
|
|
208
|
+
/* runs until the client closes stdin */
|
|
209
|
+
});
|
|
210
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ramx-bridge pair` — reconnect to RAM/X without redoing the whole setup.
|
|
3
|
+
*
|
|
4
|
+
* Used when the key was revoked in the dashboard, or when moving the bot to a
|
|
5
|
+
* different RAM/X agent. Platform details already on disk are left alone.
|
|
6
|
+
*/
|
|
7
|
+
export declare function runPair(argv?: string[]): Promise<number>;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ramx-bridge pair` — reconnect to RAM/X without redoing the whole setup.
|
|
3
|
+
*
|
|
4
|
+
* Used when the key was revoked in the dashboard, or when moving the bot to a
|
|
5
|
+
* different RAM/X agent. Platform details already on disk are left alone.
|
|
6
|
+
*/
|
|
7
|
+
import { Ramx } from '../lib/ramx.js';
|
|
8
|
+
import { pairInteractive } from '../lib/pair-flow.js';
|
|
9
|
+
import { readConfig, writeConfig, DEFAULT_API_BASE } from '../lib/config.js';
|
|
10
|
+
import { say, ok, fail, bold, dim, cyan, redact } from '../lib/ui.js';
|
|
11
|
+
export async function runPair(argv = []) {
|
|
12
|
+
const existing = readConfig();
|
|
13
|
+
if (!existing) {
|
|
14
|
+
fail('No setup found yet.');
|
|
15
|
+
say(` Run ${cyan('npx @ramxvnn/bridge init')} — it does this as part of the setup.`);
|
|
16
|
+
return 1;
|
|
17
|
+
}
|
|
18
|
+
const apiBase = process.env.RAMX_API_BASE || existing.ramx.apiBase || DEFAULT_API_BASE;
|
|
19
|
+
// `--refresh` is the "you claimed the agent, now upgrade this runtime"
|
|
20
|
+
// path, and it deliberately does NOT go through the browser. The runtime
|
|
21
|
+
// already holds a working credential for the agent that was just claimed;
|
|
22
|
+
// making the user re-approve a pairing would be asking them to prove
|
|
23
|
+
// something they have already proved, and would turn "claim it later"
|
|
24
|
+
// into "claim it later and then go reconnect every machine".
|
|
25
|
+
if (argv.includes('--refresh')) {
|
|
26
|
+
say(bold('\nUpgrade this connection'));
|
|
27
|
+
const client = new Ramx({ apiKey: existing.ramx.apiKey, apiBase });
|
|
28
|
+
let rotated;
|
|
29
|
+
try {
|
|
30
|
+
rotated = await client.rotateCredential();
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
fail(`Could not upgrade this connection. ${redact(err)}`);
|
|
34
|
+
say(dim(' If the agent has not been claimed yet, claim it first, then run this again.'));
|
|
35
|
+
return 1;
|
|
36
|
+
}
|
|
37
|
+
if (!rotated.rotated || !rotated.apiKey) {
|
|
38
|
+
ok('Nothing to upgrade — this connection already uses a permanent key.');
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
writeConfig({
|
|
42
|
+
...existing,
|
|
43
|
+
ramx: {
|
|
44
|
+
...existing.ramx,
|
|
45
|
+
apiKey: rotated.apiKey,
|
|
46
|
+
apiBase,
|
|
47
|
+
scopes: rotated.scopes ?? existing.ramx.scopes,
|
|
48
|
+
// No longer a trial connection, so the trial bookkeeping goes with
|
|
49
|
+
// it rather than lingering as stale state.
|
|
50
|
+
provisional: false,
|
|
51
|
+
claimUrl: undefined,
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
ok('Upgraded. The old trial key has been revoked.');
|
|
55
|
+
say(dim(' Nothing to reinstall — your bot can keep running.\n'));
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
say(bold('\nReconnect to RAM/X'));
|
|
59
|
+
say(dim(' You will approve this in your browser. Nothing to copy.\n'));
|
|
60
|
+
const paired = await pairInteractive(new Ramx({ apiBase }), existing.source);
|
|
61
|
+
if (!paired.ok)
|
|
62
|
+
return 1;
|
|
63
|
+
writeConfig({
|
|
64
|
+
...existing,
|
|
65
|
+
ramx: {
|
|
66
|
+
apiKey: paired.claim.apiKey,
|
|
67
|
+
apiBase,
|
|
68
|
+
agentHandle: paired.claim.agent.handle,
|
|
69
|
+
scopes: paired.claim.scopes,
|
|
70
|
+
...(paired.claim.trial?.provisional ? { provisional: true } : {}),
|
|
71
|
+
...(paired.claim.claimUrl ? { claimUrl: paired.claim.claimUrl } : {}),
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
ok('Saved. Your old key no longer needs to be used.');
|
|
75
|
+
say(dim(' Revoke it any time from your RAM/X dashboard.\n'));
|
|
76
|
+
return 0;
|
|
77
|
+
}
|