@beunec/rivine 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/dist/index.js ADDED
@@ -0,0 +1,300 @@
1
+ #!/usr/bin/env node
2
+ /*!
3
+ * Rivine Code CLI
4
+ * Licensed and owned by Beunec Technologies, Inc.
5
+ *
6
+ * https://aselius.beunec.com
7
+ *
8
+ * This software is proprietary. Use is governed by the licence agreement
9
+ * accompanying it; it is not open source and is not licensed for
10
+ * redistribution or derivative works.
11
+ */
12
+ import { readFileSync } from 'node:fs';
13
+ import { dirname, join, resolve as resolvePath } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { render } from 'ink';
16
+ import React from 'react';
17
+ import { Command } from 'commander';
18
+ import { App } from './ui/App.js';
19
+ import { runHeadless } from './runHeadless.js';
20
+ import { isInteractiveTerminal, installTeardown } from './ui/headless.js';
21
+ import { login, logout, status } from './auth/session.js';
22
+ import { openBrowser } from './auth/pkce.js';
23
+ import { apiUrl, siteUrl } from './config.js';
24
+ import { describeServer, loadServers, removeServer, saveServer } from './mcp/config.js';
25
+ import { McpManager } from './mcp/manager.js';
26
+ import { loginToServer } from './mcp/oauth.js';
27
+ import { clearSession, mcpAccount } from './auth/keychain.js';
28
+ import { PERMISSION_MODES } from './protocol/permissions.js';
29
+ const program = new Command();
30
+ function readPackageVersion() {
31
+ try {
32
+ const here = dirname(fileURLToPath(import.meta.url));
33
+ const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8'));
34
+ return String(pkg.version ?? '0.0.0');
35
+ }
36
+ catch {
37
+ return '0.0.0';
38
+ }
39
+ }
40
+ program
41
+ .name('rivine')
42
+ .description('Rivine Code in your terminal — edits the files you already have open.')
43
+ .version(readPackageVersion());
44
+ function resolveMode(options) {
45
+ if (options.bypass)
46
+ return 'bypass';
47
+ if (options.plan)
48
+ return 'plan';
49
+ const requested = options.mode;
50
+ if (requested && PERMISSION_MODES.includes(requested))
51
+ return requested;
52
+ return 'default';
53
+ }
54
+ program
55
+ .command('login', { isDefault: false })
56
+ .description('Sign in to Beunec')
57
+ .option('--device', 'use a device code instead of a browser redirect (headless machines)')
58
+ .action(async (options) => {
59
+ try {
60
+ await login({
61
+ device: options.device,
62
+ onUrl: (url) => {
63
+ console.log('\n Opening your browser to sign in.');
64
+ console.log(` If it did not open, visit:\n ${url}\n`);
65
+ },
66
+ onDeviceCode: (code, url) => {
67
+ console.log(`\n Visit ${url}`);
68
+ console.log(` and enter the code: ${code}\n`);
69
+ },
70
+ onNoRefreshToken: (hours) => {
71
+ console.log(` Note: this session cannot renew itself and ends in ~${hours}h.\n` +
72
+ ` (This deployment does not issue refresh tokens.)\n`);
73
+ },
74
+ });
75
+ console.log(' Signed in. Run `rivine` in a project to start.\n');
76
+ }
77
+ catch (err) {
78
+ console.error(`\n ${err.message}\n`);
79
+ process.exitCode = 1;
80
+ }
81
+ });
82
+ program
83
+ .command('logout')
84
+ .description('Remove stored credentials from this machine')
85
+ .action(async () => {
86
+ await logout();
87
+ console.log(' Signed out.\n');
88
+ });
89
+ program
90
+ .command('status')
91
+ .description('Show sign-in state and where this client points')
92
+ .action(async () => {
93
+ const state = await status();
94
+ const where = state.storage === 'environment'
95
+ ? 'RIVINE_TOKEN environment variable'
96
+ : state.storage === 'keychain'
97
+ ? 'your OS keychain'
98
+ : '~/.rivine/credentials.json (file, mode 600)';
99
+ console.log('');
100
+ console.log(` Signed in: ${state.signedIn ? 'yes' : 'no'}`);
101
+ console.log(` Stored in: ${where}`);
102
+ if (state.expiresAt) {
103
+ const minutes = Math.round((state.expiresAt - Date.now()) / 60000);
104
+ const life = minutes > 0
105
+ ? `valid for ${minutes} min`
106
+ : state.canRenew
107
+ ? 'expired (renews on next use)'
108
+ : 'expired — run `rivine login`';
109
+ console.log(` Token: ${life}`);
110
+ console.log(` Renews: ${state.canRenew ? 'yes' : 'no — sign in again when it expires'}`);
111
+ }
112
+ console.log(` API: ${apiUrl()}`);
113
+ console.log(` Site: ${siteUrl()}`);
114
+ console.log('');
115
+ });
116
+ program
117
+ .command('credits')
118
+ .description('Open the credits page in your browser')
119
+ .action(() => {
120
+ const url = `${siteUrl()}/credits`;
121
+ openBrowser(url);
122
+ console.log(` Opened ${url}\n`);
123
+ });
124
+ const mcp = program.command('mcp').description('manage MCP servers this terminal hosts');
125
+ mcp
126
+ .command('add <name> [commandAndArgs...]')
127
+ .description('add an MCP server (stdio by default, or --transport http|sse with --url)')
128
+ .option('-t, --transport <kind>', 'stdio | http | sse', 'stdio')
129
+ .option('-u, --url <url>', 'endpoint for http/sse transports')
130
+ .option('-e, --env <KEY=VALUE...>', 'environment for a stdio server; use $VAR to reference, never a literal secret', collect, [])
131
+ .option('--token-env <VAR>', 'name of the env var holding a bearer token (http/sse)')
132
+ .option('--user', 'save to ~/.rivine/mcp.json instead of this project')
133
+ .action(async (name, commandAndArgs, options) => {
134
+ const scope = options.user ? 'user' : 'project';
135
+ const transport = String(options.transport);
136
+ const config = { transport: transport };
137
+ if (transport === 'stdio') {
138
+ if (commandAndArgs.length === 0) {
139
+ console.error('\n A stdio server needs a command, e.g.\n rivine mcp add playwright npx @playwright/mcp@latest\n');
140
+ process.exitCode = 1;
141
+ return;
142
+ }
143
+ config.command = commandAndArgs[0];
144
+ config.args = commandAndArgs.slice(1);
145
+ const env = {};
146
+ for (const pair of options.env) {
147
+ const at = pair.indexOf('=');
148
+ if (at > 0)
149
+ env[pair.slice(0, at)] = pair.slice(at + 1);
150
+ }
151
+ if (Object.keys(env).length)
152
+ config.env = env;
153
+ }
154
+ else {
155
+ if (!options.url) {
156
+ console.error(`\n A ${transport} server needs --url.\n`);
157
+ process.exitCode = 1;
158
+ return;
159
+ }
160
+ config.url = String(options.url);
161
+ if (options.tokenEnv)
162
+ config.bearerTokenEnvVar = String(options.tokenEnv);
163
+ }
164
+ const path = await saveServer(scope, process.cwd(), name, config);
165
+ console.log(`\n Added "${name}" to ${path}`);
166
+ console.log(` Check it works: rivine mcp test\n`);
167
+ });
168
+ mcp
169
+ .command('list')
170
+ .description('show configured servers and where they came from')
171
+ .action(async () => {
172
+ const servers = await loadServers(process.cwd());
173
+ if (servers.length === 0) {
174
+ console.log('\n No MCP servers configured.');
175
+ console.log(' Add one: rivine mcp add playwright npx @playwright/mcp@latest\n');
176
+ return;
177
+ }
178
+ console.log('');
179
+ for (const s of servers) {
180
+ console.log(` ${s.name.padEnd(18)} ${s.transport.padEnd(6)} ${s.scope.padEnd(8)} ${describeServer(s)}`);
181
+ }
182
+ console.log('');
183
+ });
184
+ mcp
185
+ .command('remove <name>')
186
+ .description('remove a server')
187
+ .option('--user', 'remove from ~/.rivine/mcp.json instead of this project')
188
+ .action(async (name, options) => {
189
+ const scope = options.user ? 'user' : 'project';
190
+ const removed = await removeServer(scope, process.cwd(), name);
191
+ console.log(removed ? `\n Removed "${name}".\n` : `\n No ${scope} server named "${name}".\n`);
192
+ });
193
+ mcp
194
+ .command('test')
195
+ .description('connect to every server and list the tools it offers')
196
+ .action(async () => {
197
+ const manager = new McpManager();
198
+ console.log('\n Connecting…\n');
199
+ const reports = await manager.connectAll(process.cwd());
200
+ if (reports.length === 0) {
201
+ console.log(' No MCP servers configured.\n');
202
+ return;
203
+ }
204
+ for (const r of reports) {
205
+ const mark = r.ok ? 'ok ' : 'FAIL';
206
+ console.log(` ${mark} ${r.name.padEnd(18)} ${r.ok ? `${r.toolCount} tool(s)` : r.error}`);
207
+ }
208
+ const tools = manager.listTools();
209
+ if (tools.length) {
210
+ console.log('\n Tools Rivine will be offered:');
211
+ for (const t of tools.slice(0, 40)) {
212
+ console.log(` ${t.name}`);
213
+ }
214
+ if (tools.length > 40)
215
+ console.log(` …and ${tools.length - 40} more`);
216
+ }
217
+ console.log('');
218
+ await manager.closeAll();
219
+ });
220
+ mcp
221
+ .command('login <name>')
222
+ .description('sign in to a remote MCP server in your browser')
223
+ .action(async (name) => {
224
+ const servers = await loadServers(process.cwd());
225
+ const server = servers.find((s) => s.name === name);
226
+ if (!server) {
227
+ console.error(`\n No MCP server named "${name}". Run: rivine mcp list\n`);
228
+ process.exitCode = 1;
229
+ return;
230
+ }
231
+ if (server.transport === 'stdio' || !server.url) {
232
+ console.error(`\n ${name} is a local (stdio) server — it runs as your own user and needs no sign-in.\n`);
233
+ process.exitCode = 1;
234
+ return;
235
+ }
236
+ try {
237
+ await loginToServer(name, server.url, (url) => {
238
+ console.log(`\n If your browser did not open:\n ${url}\n`);
239
+ });
240
+ console.log(` Signed in to ${name}. The token is in your keychain, not in any config file.\n`);
241
+ }
242
+ catch (err) {
243
+ console.error(`\n ${err.message}\n`);
244
+ process.exitCode = 1;
245
+ }
246
+ });
247
+ mcp
248
+ .command('logout <name>')
249
+ .description('forget the stored credentials for a remote MCP server')
250
+ .action(async (name) => {
251
+ await clearSession(mcpAccount(name));
252
+ console.log(`\n Forgot the stored credentials for ${name}.\n`);
253
+ });
254
+ function collect(value, previous) {
255
+ return [...previous, value];
256
+ }
257
+ program
258
+ .argument('[prompt...]', 'send one prompt and exit')
259
+ .option('-p, --print <prompt>', 'send one prompt, print the answer, and exit')
260
+ .option('-m, --mode <mode>', `permission mode: ${PERMISSION_MODES.join(' | ')}`)
261
+ .option('--plan', 'start in plan mode (reads only)')
262
+ .option('--bypass', 'never ask for approval — for CI only, on a machine you are willing to have changed')
263
+ .option('-C, --cwd <dir>', 'run against a different directory')
264
+ .option('--output-format <format>', 'text | json — json puts one object on stdout and everything else on stderr', 'text')
265
+ .action(async (prompt, options) => {
266
+ const cwd = options.cwd ? resolvePath(options.cwd) : process.cwd();
267
+ const oneShot = options.print ?? (prompt.length ? prompt.join(' ') : undefined);
268
+ const mode = resolveMode(options);
269
+ const format = options.outputFormat === 'json' ? 'json' : 'text';
270
+ if (!isInteractiveTerminal()) {
271
+ if (!oneShot) {
272
+ console.error('\n No interactive terminal detected.\n' +
273
+ ' Pass a prompt to run once: rivine -p "add tests for auth.ts"\n');
274
+ process.exitCode = 2;
275
+ return;
276
+ }
277
+ process.exitCode = await runHeadless({ cwd, mode, prompt: oneShot, format });
278
+ return;
279
+ }
280
+ if (format === 'json' && oneShot) {
281
+ process.exitCode = await runHeadless({ cwd, mode, prompt: oneShot, format });
282
+ return;
283
+ }
284
+ const { waitUntilExit } = render(React.createElement(App, {
285
+ cwd,
286
+ initialMode: mode,
287
+ oneShot,
288
+ }), { exitOnCtrlC: false });
289
+ const removeTeardown = installTeardown(() => { });
290
+ try {
291
+ await waitUntilExit();
292
+ }
293
+ finally {
294
+ removeTeardown();
295
+ }
296
+ });
297
+ program.parseAsync(process.argv).catch((err) => {
298
+ console.error(`\n ${err.message}\n`);
299
+ process.exitCode = 1;
300
+ });
@@ -0,0 +1,73 @@
1
+ /*!
2
+ * Rivine Code CLI
3
+ * Licensed and owned by Beunec Technologies, Inc.
4
+ *
5
+ * https://aselius.beunec.com
6
+ *
7
+ * This software is proprietary. Use is governed by the licence agreement
8
+ * accompanying it; it is not open source and is not licensed for
9
+ * redistribution or derivative works.
10
+ */
11
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
12
+ import { homedir } from 'node:os';
13
+ import { dirname, join } from 'node:path';
14
+ export const userConfigPath = () => join(homedir(), '.rivine', 'mcp.json');
15
+ export const projectConfigPath = (root) => join(root, '.rivine', 'mcp.json');
16
+ async function readConfig(path) {
17
+ const raw = await readFile(path, 'utf8').catch(() => null);
18
+ if (!raw)
19
+ return {};
20
+ try {
21
+ return JSON.parse(raw);
22
+ }
23
+ catch (err) {
24
+ throw new Error(`${path} is not valid JSON: ${err.message}`);
25
+ }
26
+ }
27
+ export async function loadServers(root) {
28
+ const [user, project] = await Promise.all([
29
+ readConfig(userConfigPath()),
30
+ readConfig(projectConfigPath(root)),
31
+ ]);
32
+ const merged = new Map();
33
+ for (const [name, config] of Object.entries(user.mcpServers ?? {})) {
34
+ merged.set(name, { ...config, name, scope: 'user' });
35
+ }
36
+ for (const [name, config] of Object.entries(project.mcpServers ?? {})) {
37
+ merged.set(name, { ...config, name, scope: 'project' });
38
+ }
39
+ return [...merged.values()].filter((s) => s.enabled !== false);
40
+ }
41
+ export async function saveServer(scope, root, name, config) {
42
+ const path = scope === 'user' ? userConfigPath() : projectConfigPath(root);
43
+ const existing = await readConfig(path);
44
+ const next = {
45
+ ...existing,
46
+ mcpServers: { ...(existing.mcpServers ?? {}), [name]: config },
47
+ };
48
+ await mkdir(dirname(path), { recursive: true });
49
+ await writeFile(path, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
50
+ return path;
51
+ }
52
+ export async function removeServer(scope, root, name) {
53
+ const path = scope === 'user' ? userConfigPath() : projectConfigPath(root);
54
+ const existing = await readConfig(path);
55
+ if (!existing.mcpServers?.[name])
56
+ return false;
57
+ delete existing.mcpServers[name];
58
+ await writeFile(path, `${JSON.stringify(existing, null, 2)}\n`, 'utf8');
59
+ return true;
60
+ }
61
+ export function resolveEnv(env) {
62
+ const out = {};
63
+ for (const [key, value] of Object.entries(env ?? {})) {
64
+ out[key] = value.replace(/\$\{?([A-Z0-9_]+)\}?/gi, (_, name) => process.env[name] ?? '');
65
+ }
66
+ return out;
67
+ }
68
+ export function describeServer(server) {
69
+ if (server.transport === 'stdio') {
70
+ return `${server.command ?? '?'} ${(server.args ?? []).join(' ')}`.trim();
71
+ }
72
+ return server.url ?? '?';
73
+ }
@@ -0,0 +1,154 @@
1
+ /*!
2
+ * Rivine Code CLI
3
+ * Licensed and owned by Beunec Technologies, Inc.
4
+ *
5
+ * https://aselius.beunec.com
6
+ *
7
+ * This software is proprietary. Use is governed by the licence agreement
8
+ * accompanying it; it is not open source and is not licensed for
9
+ * redistribution or derivative works.
10
+ */
11
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
12
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
13
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
14
+ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
15
+ import { loadServers, resolveEnv } from './config.js';
16
+ import { tokenFor } from './oauth.js';
17
+ const CONNECT_TIMEOUT_MS = 20_000;
18
+ const CALL_TIMEOUT_MS = 120_000;
19
+ export function toolName(server, tool) {
20
+ return `mcp__${server}__${tool}`;
21
+ }
22
+ function parseToolName(qualified) {
23
+ const match = /^mcp__([^_]+(?:_[^_]+)*?)__(.+)$/.exec(qualified);
24
+ return match ? { server: match[1], tool: match[2] } : null;
25
+ }
26
+ export class McpManager {
27
+ clients = new Map();
28
+ tools = [];
29
+ reports = [];
30
+ async connectAll(root) {
31
+ let servers;
32
+ try {
33
+ servers = await loadServers(root);
34
+ }
35
+ catch (err) {
36
+ this.reports.push({ name: 'config', scope: '-', ok: false, toolCount: 0, error: err.message });
37
+ return this.reports;
38
+ }
39
+ await Promise.all(servers.map((server) => this.connectOne(server)));
40
+ return this.reports;
41
+ }
42
+ async connectOne(server) {
43
+ const client = new Client({ name: 'rivine-cli', version: '1.0.0' }, { capabilities: {} });
44
+ try {
45
+ const transport = await this.buildTransport(server);
46
+ await withTimeout(client.connect(transport), CONNECT_TIMEOUT_MS, 'connect');
47
+ const listed = await withTimeout(client.listTools(), CONNECT_TIMEOUT_MS, 'listTools');
48
+ for (const tool of listed.tools ?? []) {
49
+ this.tools.push({
50
+ name: toolName(server.name, tool.name),
51
+ description: String(tool.description ?? tool.name).slice(0, 1024),
52
+ input_schema: tool.inputSchema ?? {
53
+ type: 'object',
54
+ properties: {},
55
+ },
56
+ server: server.name,
57
+ });
58
+ }
59
+ this.clients.set(server.name, client);
60
+ this.reports.push({
61
+ name: server.name,
62
+ scope: server.scope,
63
+ ok: true,
64
+ toolCount: listed.tools?.length ?? 0,
65
+ });
66
+ }
67
+ catch (err) {
68
+ await client.close().catch(() => { });
69
+ this.reports.push({
70
+ name: server.name,
71
+ scope: server.scope,
72
+ ok: false,
73
+ toolCount: 0,
74
+ error: err.message,
75
+ });
76
+ }
77
+ }
78
+ async buildTransport(server) {
79
+ if (server.transport === 'stdio') {
80
+ if (!server.command)
81
+ throw new Error('stdio server has no `command`');
82
+ return new StdioClientTransport({
83
+ command: server.command,
84
+ args: server.args ?? [],
85
+ env: { ...process.env, ...resolveEnv(server.env) },
86
+ });
87
+ }
88
+ if (!server.url)
89
+ throw new Error(`${server.transport} server has no \`url\``);
90
+ const url = new URL(server.url);
91
+ const token = server.bearerTokenEnvVar
92
+ ? process.env[server.bearerTokenEnvVar]
93
+ : ((await tokenFor(server.name)) ?? undefined);
94
+ const requestInit = token ? { headers: { authorization: `Bearer ${token}` } } : undefined;
95
+ return server.transport === 'sse'
96
+ ? new SSEClientTransport(url, requestInit ? { requestInit } : undefined)
97
+ : new StreamableHTTPClientTransport(url, requestInit ? { requestInit } : undefined);
98
+ }
99
+ listTools() {
100
+ return [...this.tools];
101
+ }
102
+ getReports() {
103
+ return [...this.reports];
104
+ }
105
+ hasTool(qualified) {
106
+ return this.tools.some((t) => t.name === qualified);
107
+ }
108
+ async call(qualified, args) {
109
+ const parsed = parseToolName(qualified);
110
+ if (!parsed)
111
+ return `Error: ${qualified} is not an MCP tool name.`;
112
+ const client = this.clients.get(parsed.server);
113
+ if (!client)
114
+ return `Error: MCP server "${parsed.server}" is not connected.`;
115
+ try {
116
+ const result = await withTimeout(client.callTool({ name: parsed.tool, arguments: args }), CALL_TIMEOUT_MS, parsed.tool);
117
+ return renderToolResult(result);
118
+ }
119
+ catch (err) {
120
+ return `Error calling ${qualified}: ${err.message}`;
121
+ }
122
+ }
123
+ async closeAll() {
124
+ await Promise.allSettled([...this.clients.values()].map((c) => c.close()));
125
+ this.clients.clear();
126
+ }
127
+ }
128
+ function renderToolResult(result) {
129
+ const content = result?.content;
130
+ if (!Array.isArray(content))
131
+ return JSON.stringify(result ?? {}).slice(0, 20_000);
132
+ const parts = [];
133
+ for (const item of content) {
134
+ const block = item;
135
+ if (block.type === 'text' && typeof block.text === 'string')
136
+ parts.push(block.text);
137
+ else if (block.type === 'image')
138
+ parts.push(`[image: ${block.mimeType ?? 'unknown'}]`);
139
+ else if (block.type === 'resource')
140
+ parts.push(`[resource: ${JSON.stringify(block.resource).slice(0, 500)}]`);
141
+ else
142
+ parts.push(JSON.stringify(block).slice(0, 2000));
143
+ }
144
+ const text = parts.join('\n').trim();
145
+ return text.length > 40_000
146
+ ? `${text.slice(0, 40_000)}\n…[${text.length - 40_000} more characters]`
147
+ : text || '(empty result)';
148
+ }
149
+ function withTimeout(promise, ms, what) {
150
+ return Promise.race([
151
+ promise,
152
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`${what} timed out after ${ms / 1000}s`)), ms).unref?.()),
153
+ ]);
154
+ }