@ronana/mcp 0.0.0-stage → 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/README.md +115 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +286 -0
- package/dist/client.d.ts +61 -0
- package/dist/client.js +128 -0
- package/dist/input.d.ts +39 -0
- package/dist/input.js +166 -0
- package/dist/oauth.d.ts +11 -0
- package/dist/oauth.js +211 -0
- package/dist/remote.d.ts +20 -0
- package/dist/remote.js +86 -0
- package/dist/schemas.d.ts +101 -0
- package/dist/schemas.js +55 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +83 -0
- package/dist/types.d.ts +173 -0
- package/dist/types.js +11 -0
- package/package.json +26 -4
package/README.md
CHANGED
|
@@ -1,3 +1,116 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Ronana CLI and MCP
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
NOTE: MCP and the command line are in admin preview. At launch they will
|
|
4
|
+
require a paid plan.
|
|
5
|
+
|
|
6
|
+
WARNING: Generations spend your account credits.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
Requires Node.js 20 or later.
|
|
11
|
+
|
|
12
|
+
```text
|
|
13
|
+
npm install -g @ronana/mcp
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
This installs two commands: `ronana`, the command line, and `ronana-mcp`, a
|
|
17
|
+
local MCP server for AI apps.
|
|
18
|
+
|
|
19
|
+
## Sign in
|
|
20
|
+
|
|
21
|
+
```text
|
|
22
|
+
ronana auth login
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Ronana opens in your browser. Sign in and approve the access. You do not need
|
|
26
|
+
an API key. The sign-in is stored in your system keychain and stays until you
|
|
27
|
+
run `ronana auth logout`, which also revokes it.
|
|
28
|
+
|
|
29
|
+
## Command line
|
|
30
|
+
|
|
31
|
+
```text
|
|
32
|
+
ronana auth login | status | logout
|
|
33
|
+
ronana account
|
|
34
|
+
ronana model list [filter] [--kind image|video]
|
|
35
|
+
ronana model get google/nano-banana-2-lite/edit
|
|
36
|
+
ronana upload ./photo.png # prints a ref_ id
|
|
37
|
+
ronana generate cost google/nano-banana-2-lite/edit --prompt "make it dusk" --image ./photo.png
|
|
38
|
+
ronana generate create google/nano-banana-2-lite/edit --prompt "make it dusk" --image ./photo.png --out ./renders
|
|
39
|
+
ronana generate get <id> | wait <id> [--out dir]
|
|
40
|
+
ronana generate list
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Every field from `model get` is a flag (`--aspect_ratio 16:9`, `--seed 7`).
|
|
44
|
+
Repeat a flag to fill a list. `--image`, `--video` and `--audio` fill the
|
|
45
|
+
model's media field, and accept a local file, an https URL, or a Ronana id.
|
|
46
|
+
Local files upload first. `--input '{...}'` (or `--input @body.json`) sets the
|
|
47
|
+
whole body for nested fields. `--wait` prints the result link; `--out` also
|
|
48
|
+
downloads it. Add `--json` to any command for scripts. Run `ronana --help` for
|
|
49
|
+
the full list.
|
|
50
|
+
|
|
51
|
+
`generate create` spends credits. Each run sends its own idempotency key, so
|
|
52
|
+
the same command run twice makes two generations. Pass `--idempotency-key` to
|
|
53
|
+
make a retry bill once.
|
|
54
|
+
|
|
55
|
+
## Use Ronana from an AI app
|
|
56
|
+
|
|
57
|
+
Most AI apps connect to Ronana's remote MCP server directly. Add this URL as a
|
|
58
|
+
remote MCP server or custom connector, then sign in when the app asks:
|
|
59
|
+
|
|
60
|
+
```text
|
|
61
|
+
https://ronana.ai/mcp
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Open **Account > Connections** on ronana.ai to see or disconnect an app.
|
|
65
|
+
|
|
66
|
+
### Local MCP server
|
|
67
|
+
|
|
68
|
+
Use `ronana-mcp` for an app that only runs local MCP servers, or to let your AI
|
|
69
|
+
upload files from your computer. Sign in once with `ronana auth login`, then
|
|
70
|
+
add it to the app's MCP settings:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"mcpServers": {
|
|
75
|
+
"ronana": {
|
|
76
|
+
"command": "npx",
|
|
77
|
+
"args": ["-y", "-p", "@ronana/mcp", "ronana-mcp"],
|
|
78
|
+
"env": { "RONANA_MCP_UPLOAD_ROOT": "/path/to/your/media" }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The local server adds one tool, `upload_local_reference`, which uploads a file
|
|
85
|
+
from inside `RONANA_MCP_UPLOAD_ROOT`. Set it to the folder that holds your
|
|
86
|
+
media, never a home directory or a drive root. Files larger than 3 MB stream
|
|
87
|
+
straight to storage, and Ronana checks each file before it adds it to your
|
|
88
|
+
library.
|
|
89
|
+
|
|
90
|
+
## Tools
|
|
91
|
+
|
|
92
|
+
| Tool | Function |
|
|
93
|
+
| --- | --- |
|
|
94
|
+
| `list_models` | List the available models. |
|
|
95
|
+
| `get_model` | Get one model's contract: its input schema. |
|
|
96
|
+
| `estimate_cost` | Estimate the credit cost of a generation. Takes the same `model` and `input` as `generate`. |
|
|
97
|
+
| `generate` | Start an image or video generation. |
|
|
98
|
+
| `get_generation` | Get one generation and its download link. |
|
|
99
|
+
| `list_generations` | List recent generations. |
|
|
100
|
+
| `list_references` | List library references. |
|
|
101
|
+
| `upload_reference` | Add a file to the library from a URL (up to 25 MB) or from base64 (under 3 MB). |
|
|
102
|
+
| `create_upload` | Get a private upload link for a larger local file (up to 200 MB). |
|
|
103
|
+
| `finish_upload` | Add a file uploaded with `create_upload` to the library. |
|
|
104
|
+
| `get_account` | Get the plan and credit balance. |
|
|
105
|
+
|
|
106
|
+
A generate call takes a `model` and an `input`, exactly as `get_model` shows it
|
|
107
|
+
in `input_schema`. A media field takes a public https URL or a Ronana id
|
|
108
|
+
(`ref_…`, `img_…`, `gen_…`). Generation tools return an id immediately. Poll
|
|
109
|
+
`get_generation` until the status is `completed` or `failed`.
|
|
110
|
+
|
|
111
|
+
```json
|
|
112
|
+
{ "model": "google/nano-banana-2/edit", "input": { "prompt": "a lantern shop at dusk", "images": ["https://example.com/ref.png"] } }
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Generations and uploads appear in your Ronana library. Your plan's storage
|
|
116
|
+
limits and retention apply.
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `ronana`: the command line for Ronana. A thin client over the same remote MCP
|
|
3
|
+
// tools an agent uses, so every request runs the server's own validation,
|
|
4
|
+
// pricing, credit gates and rate limits. Nothing here prices or bills.
|
|
5
|
+
//
|
|
6
|
+
// ronana auth login | logout | status
|
|
7
|
+
// ronana account
|
|
8
|
+
// ronana model list [filter] [--kind image|video]
|
|
9
|
+
// ronana model get <model>
|
|
10
|
+
// ronana upload <file | https-url>
|
|
11
|
+
// ronana generate create <model> --prompt "…" [--<field> value …] [--wait] [--out dir]
|
|
12
|
+
// ronana generate cost <model> [same flags as create]
|
|
13
|
+
// ronana generate get | wait <id> [--out dir]
|
|
14
|
+
// ronana generate list [--limit n]
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
import { createWriteStream } from "node:fs";
|
|
17
|
+
import { mkdir } from "node:fs/promises";
|
|
18
|
+
import { basename, extname, join, resolve } from "node:path";
|
|
19
|
+
import { Readable } from "node:stream";
|
|
20
|
+
import { pipeline } from "node:stream/promises";
|
|
21
|
+
import { login, logout, readCredentials } from "./oauth.js";
|
|
22
|
+
import { callTool, remoteClient, ToolError, uploadFile } from "./remote.js";
|
|
23
|
+
import { buildInput, flag, has, parseArgs, replaceLocalMedia, typeOf } from "./input.js";
|
|
24
|
+
const HELP = `Usage: ronana <command>
|
|
25
|
+
|
|
26
|
+
auth login Sign in with your browser (--base-url, --no-browser)
|
|
27
|
+
auth logout Sign out and revoke the token
|
|
28
|
+
auth status Show the signed-in server
|
|
29
|
+
account Plan and credit balance
|
|
30
|
+
model list [filter] Models you can run (--kind image|video)
|
|
31
|
+
model get <model> A model's fields, media inputs and priced fields
|
|
32
|
+
upload <file|https-url> Add a reference to your library, print its id
|
|
33
|
+
generate create <model> Start a generation (costs credits)
|
|
34
|
+
generate cost <model> Credits a generation would cost (free)
|
|
35
|
+
generate get <id> Status and download links
|
|
36
|
+
generate wait <id> Wait for a generation to finish
|
|
37
|
+
generate list Recent generations (--limit n)
|
|
38
|
+
|
|
39
|
+
Generate flags:
|
|
40
|
+
--<field> <value> Any field from \`model get\`. Repeat for lists.
|
|
41
|
+
--image/--video/--audio <v> A file, https URL or ref_/img_/gen_ id
|
|
42
|
+
--input <json|@file.json> The whole input body (flags override it)
|
|
43
|
+
--wait Wait for the result and print its link
|
|
44
|
+
--out <dir> Wait, then download the outputs into <dir>
|
|
45
|
+
--wait-timeout <sec> Default 600
|
|
46
|
+
--idempotency-key <key> Resend safely: same key bills once
|
|
47
|
+
|
|
48
|
+
Global: --json for machine-readable output, --help.
|
|
49
|
+
`;
|
|
50
|
+
// ── output ───────────────────────────────────────────────────────────────────
|
|
51
|
+
let JSON_OUT = false;
|
|
52
|
+
const print = (line = "") => process.stdout.write(`${line}\n`);
|
|
53
|
+
const note = (line) => { if (!JSON_OUT)
|
|
54
|
+
process.stderr.write(`${line}\n`); };
|
|
55
|
+
const emit = (data, human) => JSON_OUT ? print(JSON.stringify(data, null, 2)) : human();
|
|
56
|
+
function describeField(name, schema, required) {
|
|
57
|
+
const type = typeOf(schema);
|
|
58
|
+
const parts = [type === "array" ? `${typeOf(schema.items ?? {})}[]` : type];
|
|
59
|
+
const choices = schema.enum ?? schema.items?.enum;
|
|
60
|
+
if (choices)
|
|
61
|
+
parts.push(choices.map(String).join("|"));
|
|
62
|
+
if (schema.minimum !== undefined || schema.maximum !== undefined)
|
|
63
|
+
parts.push(`${schema.minimum ?? ""}..${schema.maximum ?? ""}`);
|
|
64
|
+
if (schema.default !== undefined)
|
|
65
|
+
parts.push(`default ${JSON.stringify(schema.default)}`);
|
|
66
|
+
return ` --${name.padEnd(22)} ${parts.join(" ")}${required ? " (required)" : ""}`;
|
|
67
|
+
}
|
|
68
|
+
const TERMINAL = new Set(["completed", "failed", "canceled"]);
|
|
69
|
+
function seconds(args, name, fallback) {
|
|
70
|
+
const raw = flag(args, name);
|
|
71
|
+
const value = raw === undefined ? fallback : Number(raw);
|
|
72
|
+
if (!Number.isFinite(value) || value <= 0)
|
|
73
|
+
throw new Error(`--${name.replaceAll("_", "-")} must be a positive number of seconds.`);
|
|
74
|
+
return value;
|
|
75
|
+
}
|
|
76
|
+
async function waitFor(remote, id, args) {
|
|
77
|
+
const timeoutMs = seconds(args, "wait_timeout", 600) * 1000;
|
|
78
|
+
const intervalMs = Math.max(1, seconds(args, "wait_interval", 3)) * 1000;
|
|
79
|
+
const deadline = Date.now() + timeoutMs;
|
|
80
|
+
let last = "";
|
|
81
|
+
for (;;) {
|
|
82
|
+
let gen;
|
|
83
|
+
try {
|
|
84
|
+
gen = await callTool(remote, "get_generation", { id });
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
// A server answer (not found, no access) is final; anything else is the
|
|
88
|
+
// connection, and the generation carries on without us.
|
|
89
|
+
if (error instanceof ToolError)
|
|
90
|
+
throw error;
|
|
91
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}
|
|
92
|
+
${id} is still running. Resume with \`ronana generate wait ${id}\`.`);
|
|
93
|
+
}
|
|
94
|
+
if (gen.status !== last) {
|
|
95
|
+
note(`${id}: ${gen.status}`);
|
|
96
|
+
last = gen.status;
|
|
97
|
+
}
|
|
98
|
+
if (TERMINAL.has(gen.status))
|
|
99
|
+
return gen;
|
|
100
|
+
if (Date.now() > deadline)
|
|
101
|
+
throw new Error(`Still ${gen.status} after ${timeoutMs / 1000}s. Check later with \`ronana generate wait ${id}\`.`);
|
|
102
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const EXT_BY_TYPE = {
|
|
106
|
+
"image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp", "image/gif": ".gif",
|
|
107
|
+
"video/mp4": ".mp4", "video/webm": ".webm", "video/quicktime": ".mov",
|
|
108
|
+
};
|
|
109
|
+
async function download(gen, dir) {
|
|
110
|
+
await mkdir(dir, { recursive: true });
|
|
111
|
+
const saved = [];
|
|
112
|
+
for (const output of gen.outputs) {
|
|
113
|
+
if (!output.url)
|
|
114
|
+
continue;
|
|
115
|
+
const response = await fetch(output.url);
|
|
116
|
+
if (!response.ok || !response.body)
|
|
117
|
+
throw new Error(`Download of ${output.id} failed (${response.status}).`);
|
|
118
|
+
const type = response.headers.get("content-type")?.split(";")[0] ?? "";
|
|
119
|
+
const urlExt = extname(new URL(output.url).pathname).toLowerCase();
|
|
120
|
+
const ext = EXT_BY_TYPE[type] ?? (/^\.[a-z0-9]{1,5}$/.test(urlExt) ? urlExt : "");
|
|
121
|
+
// The name comes from server data: keep it a plain file name inside `dir`.
|
|
122
|
+
const file = join(dir, `${output.id.replace(/[^A-Za-z0-9_-]/g, "_")}${ext}`);
|
|
123
|
+
await pipeline(Readable.fromWeb(response.body), createWriteStream(file));
|
|
124
|
+
saved.push(file);
|
|
125
|
+
}
|
|
126
|
+
return saved;
|
|
127
|
+
}
|
|
128
|
+
async function finish(gen, args) {
|
|
129
|
+
const out = flag(args, "out");
|
|
130
|
+
const files = gen.status === "completed" && out ? await download(gen, out) : [];
|
|
131
|
+
emit({ ...gen, ...(out ? { files } : {}) }, () => {
|
|
132
|
+
if (gen.status === "completed") {
|
|
133
|
+
if (files.length)
|
|
134
|
+
files.forEach((f) => print(f));
|
|
135
|
+
else
|
|
136
|
+
gen.outputs.forEach((o) => o.url && print(o.url));
|
|
137
|
+
}
|
|
138
|
+
else if (gen.status === "failed") {
|
|
139
|
+
print(`Failed: ${gen.error?.message ?? gen.error?.reason ?? "unknown error"}`);
|
|
140
|
+
print(gen.charged ? "Credits were kept (the provider billed this attempt)." : "Credits were refunded.");
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
print(`${gen.id}: ${gen.status}`);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
if (gen.status === "failed" || gen.status === "canceled")
|
|
147
|
+
process.exitCode = 1;
|
|
148
|
+
}
|
|
149
|
+
// ── commands ─────────────────────────────────────────────────────────────────
|
|
150
|
+
async function withRemote(fn) {
|
|
151
|
+
const remote = await remoteClient();
|
|
152
|
+
try {
|
|
153
|
+
return await fn(remote);
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
await remote.close().catch(() => null);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
async function generateCommand(sub, args) {
|
|
160
|
+
const [target] = args.positional;
|
|
161
|
+
if (sub === "list") {
|
|
162
|
+
const limit = Number(flag(args, "limit") ?? 25);
|
|
163
|
+
const rows = await withRemote((r) => callTool(r, "list_generations", { limit }));
|
|
164
|
+
return emit(rows, () => rows.forEach((g) => print(`${g.id.padEnd(28)} ${g.status.padEnd(11)} ${g.created_at}`)));
|
|
165
|
+
}
|
|
166
|
+
if (!target)
|
|
167
|
+
throw new Error(`Usage: ronana generate ${sub ?? "create"} <${sub === "get" || sub === "wait" ? "id" : "model"}>`);
|
|
168
|
+
if (sub === "get") {
|
|
169
|
+
const gen = await withRemote((r) => callTool(r, "get_generation", { id: target }));
|
|
170
|
+
return finish(gen, args);
|
|
171
|
+
}
|
|
172
|
+
if (sub === "wait")
|
|
173
|
+
return withRemote(async (r) => finish(await waitFor(r, target, args), args));
|
|
174
|
+
if (sub !== "create" && sub !== "cost")
|
|
175
|
+
throw new Error("Use generate create | cost | get | wait | list.");
|
|
176
|
+
await withRemote(async (remote) => {
|
|
177
|
+
const contract = await callTool(remote, "get_model", { id: target });
|
|
178
|
+
const input = await buildInput(contract, args);
|
|
179
|
+
await replaceLocalMedia(contract, input, async (path) => {
|
|
180
|
+
note(`Uploading ${basename(path)}…`);
|
|
181
|
+
return (await uploadFile(remote, path)).id;
|
|
182
|
+
});
|
|
183
|
+
if (sub === "cost") {
|
|
184
|
+
const quote = await callTool(remote, "estimate_cost", { model: contract.id, input });
|
|
185
|
+
return emit(quote, () => print(quote.available === false ? "This request cannot be priced." : `${quote.credits} credits`));
|
|
186
|
+
}
|
|
187
|
+
/* Always send a key. The server otherwise derives one from the JSON-RPC
|
|
188
|
+
id, which restarts at the same value in every CLI process, so the same
|
|
189
|
+
command run twice in ten minutes would return the first generation. */
|
|
190
|
+
const idempotency_key = flag(args, "idempotency_key") ?? randomUUID();
|
|
191
|
+
const queued = await callTool(remote, "generate", { model: contract.id, input, idempotency_key });
|
|
192
|
+
if (!has(args, "wait") && !has(args, "out")) {
|
|
193
|
+
return emit(queued, () => { print(queued.id); note(`Check it with \`ronana generate wait ${queued.id}\`.`); });
|
|
194
|
+
}
|
|
195
|
+
note(`Queued ${queued.id}.`);
|
|
196
|
+
await finish(await waitFor(remote, queued.id, args), args);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
async function modelCommand(sub, args) {
|
|
200
|
+
if (sub === "list" || sub === undefined) {
|
|
201
|
+
const kind = flag(args, "kind");
|
|
202
|
+
const filter = args.positional[0]?.toLowerCase();
|
|
203
|
+
const models = (await withRemote((r) => callTool(r, "list_models", kind ? { kind } : {})))
|
|
204
|
+
.filter((m) => !filter || m.id.toLowerCase().includes(filter) || m.name.toLowerCase().includes(filter));
|
|
205
|
+
return emit(models, () => models.forEach((m) => print(`${m.id.padEnd(52)} ${m.kind.padEnd(6)} ${m.name}`)));
|
|
206
|
+
}
|
|
207
|
+
if (sub !== "get")
|
|
208
|
+
throw new Error("Use model list | get.");
|
|
209
|
+
const id = args.positional[0];
|
|
210
|
+
if (!id)
|
|
211
|
+
throw new Error("Usage: ronana model get <model>");
|
|
212
|
+
const contract = await withRemote((r) => callTool(r, "get_model", { id }));
|
|
213
|
+
emit(contract, () => {
|
|
214
|
+
print(`${contract.name} (${contract.kind})`);
|
|
215
|
+
print(contract.id);
|
|
216
|
+
print();
|
|
217
|
+
const required = new Set(contract.input_schema.required ?? []);
|
|
218
|
+
for (const [name, schema] of Object.entries(contract.input_schema.properties ?? {}))
|
|
219
|
+
print(describeField(name, schema, required.has(name)));
|
|
220
|
+
if (contract.media_fields.length) {
|
|
221
|
+
print();
|
|
222
|
+
print(`Media (file, https URL or id): ${contract.media_fields.map((m) => `${m.field} [${m.kind}]`).join(", ")}`);
|
|
223
|
+
}
|
|
224
|
+
if (contract.priced_fields.length)
|
|
225
|
+
print(`Priced by: ${contract.priced_fields.join(", ")}`);
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
async function main() {
|
|
229
|
+
const [command, sub, ...rest] = process.argv.slice(2);
|
|
230
|
+
const takesSub = command === "auth" || command === "model" || command === "generate";
|
|
231
|
+
const args = parseArgs(takesSub ? rest : [sub, ...rest].filter((v) => v !== undefined));
|
|
232
|
+
JSON_OUT = has(args, "json");
|
|
233
|
+
if (!command || ["help", "--help", "-h"].includes(command) || has(args, "help"))
|
|
234
|
+
return void process.stdout.write(HELP);
|
|
235
|
+
switch (command) {
|
|
236
|
+
case "auth":
|
|
237
|
+
case "login":
|
|
238
|
+
case "logout":
|
|
239
|
+
case "status": {
|
|
240
|
+
const action = command === "auth" ? sub : command;
|
|
241
|
+
if (action === "login") {
|
|
242
|
+
await login(flag(args, "base_url") ?? process.env.RONANA_BASE_URL, !has(args, "no_browser"));
|
|
243
|
+
return print(`Signed in to ${readCredentials().baseUrl}.`);
|
|
244
|
+
}
|
|
245
|
+
if (action === "logout") {
|
|
246
|
+
await logout();
|
|
247
|
+
return print("Signed out.");
|
|
248
|
+
}
|
|
249
|
+
if (action === "status") {
|
|
250
|
+
const credentials = readCredentials();
|
|
251
|
+
return emit({ signed_in: !!credentials, base_url: credentials?.baseUrl ?? null }, () => print(credentials ? `Signed in to ${credentials.baseUrl}.` : "Not signed in."));
|
|
252
|
+
}
|
|
253
|
+
throw new Error("Use auth login | logout | status.");
|
|
254
|
+
}
|
|
255
|
+
case "account": {
|
|
256
|
+
const account = await withRemote((r) => callTool(r, "get_account"));
|
|
257
|
+
return emit(account, () => print(`${account.credits} credits · ${account.tier} plan`));
|
|
258
|
+
}
|
|
259
|
+
case "model":
|
|
260
|
+
case "models": return modelCommand(command === "models" ? "list" : sub, args);
|
|
261
|
+
case "upload": {
|
|
262
|
+
const source = args.positional[0];
|
|
263
|
+
if (!source)
|
|
264
|
+
throw new Error("Usage: ronana upload <file | https-url>");
|
|
265
|
+
const ref = await withRemote((r) => /^https:\/\//i.test(source)
|
|
266
|
+
? callTool(r, "upload_reference", { url: source, filename: basename(new URL(source).pathname) || "upload" })
|
|
267
|
+
: uploadFile(r, resolve(source)));
|
|
268
|
+
return emit(ref, () => print(ref.id));
|
|
269
|
+
}
|
|
270
|
+
case "generate": return generateCommand(sub, args);
|
|
271
|
+
default: throw new Error(`Unknown command "${command}". Run \`ronana --help\`.`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
main().catch((error) => {
|
|
275
|
+
if (error instanceof ToolError) {
|
|
276
|
+
const { code, message, url, billing } = error.error;
|
|
277
|
+
if (JSON_OUT)
|
|
278
|
+
process.stdout.write(`${JSON.stringify({ ok: false, error: error.error }, null, 2)}\n`);
|
|
279
|
+
else
|
|
280
|
+
process.stderr.write(`${code ? `${code}: ` : ""}${message}${billing?.url ?? url ? `\n${billing?.url ?? url}` : ""}\n`);
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
284
|
+
}
|
|
285
|
+
process.exitCode = 1;
|
|
286
|
+
});
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ApiErrorBody, ApiPublicModel, GenerationState } from "./types.js";
|
|
2
|
+
export declare class RonanaApiError extends Error {
|
|
3
|
+
readonly code: string;
|
|
4
|
+
readonly status: number;
|
|
5
|
+
readonly body: ApiErrorBody["error"] | null;
|
|
6
|
+
constructor(code: string, message: string, status: number, body: ApiErrorBody["error"] | null);
|
|
7
|
+
/** A sentence a person or an agent can act on. The `action` field is the
|
|
8
|
+
* server's own decision about which door to open, so this never guesses. */
|
|
9
|
+
get guidance(): string;
|
|
10
|
+
}
|
|
11
|
+
export interface ClientOptions {
|
|
12
|
+
apiKey: string;
|
|
13
|
+
baseUrl: string;
|
|
14
|
+
}
|
|
15
|
+
/** Read the configuration from the environment. Throws with a clear message
|
|
16
|
+
* when the key is missing, because that is the one mistake every new user
|
|
17
|
+
* makes. */
|
|
18
|
+
export declare function clientOptionsFromEnv(env?: NodeJS.ProcessEnv): ClientOptions;
|
|
19
|
+
export declare class RonanaClient {
|
|
20
|
+
private readonly options;
|
|
21
|
+
constructor(options: ClientOptions);
|
|
22
|
+
private request;
|
|
23
|
+
listModels(): Promise<{
|
|
24
|
+
models: ApiPublicModel[];
|
|
25
|
+
tier: string;
|
|
26
|
+
}>;
|
|
27
|
+
account(): Promise<unknown>;
|
|
28
|
+
quote(input: Record<string, unknown>): Promise<{
|
|
29
|
+
available: boolean;
|
|
30
|
+
credits: number;
|
|
31
|
+
}>;
|
|
32
|
+
submitVideo(body: Record<string, unknown>): Promise<{
|
|
33
|
+
id: string;
|
|
34
|
+
status: string;
|
|
35
|
+
}>;
|
|
36
|
+
submitImage(body: Record<string, unknown>): Promise<{
|
|
37
|
+
id: string;
|
|
38
|
+
status: string;
|
|
39
|
+
}>;
|
|
40
|
+
generation(id: string): Promise<GenerationState>;
|
|
41
|
+
/** Upload a file inline. Use this up to 4 MB; larger files need the presigned
|
|
42
|
+
* flow below, because a serverless function body cannot exceed 4.5 MB. */
|
|
43
|
+
uploadInline(bytes: Uint8Array, filename: string): Promise<{
|
|
44
|
+
ref: {
|
|
45
|
+
id: string;
|
|
46
|
+
};
|
|
47
|
+
reused?: boolean;
|
|
48
|
+
}>;
|
|
49
|
+
/** Upload a file larger than 4 MB: sign, PUT straight to storage, finalize.
|
|
50
|
+
*
|
|
51
|
+
* CAUTION: The PUT must send exactly the content type and the content length
|
|
52
|
+
* that were signed. Any other value fails the storage signature check. */
|
|
53
|
+
uploadPresigned(bytes: Uint8Array, filename: string, contentType: string): Promise<{
|
|
54
|
+
ref: {
|
|
55
|
+
id: string;
|
|
56
|
+
};
|
|
57
|
+
reused?: boolean;
|
|
58
|
+
}>;
|
|
59
|
+
}
|
|
60
|
+
/** The most bytes an inline upload may carry. Above this, use the signed flow. */
|
|
61
|
+
export declare const INLINE_UPLOAD_MAX_BYTES: number;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// The HTTP client for the RonanaLive public API.
|
|
2
|
+
//
|
|
3
|
+
// THE LOAD-BEARING RULE OF THIS PACKAGE: it is a thin HTTP client of
|
|
4
|
+
// `/api/v1/*`. It imports no database, no credit module, and no ORM. If it ever
|
|
5
|
+
// imported the application's database layer, somebody would eventually run this
|
|
6
|
+
// package against production.
|
|
7
|
+
//
|
|
8
|
+
// Everything it needs is two environment variables:
|
|
9
|
+
// RONANA_API_KEY the key, `rk_…`, minted at /developers/keys
|
|
10
|
+
// RONANA_BASE_URL the origin, default https://ronana.ai
|
|
11
|
+
export class RonanaApiError extends Error {
|
|
12
|
+
code;
|
|
13
|
+
status;
|
|
14
|
+
body;
|
|
15
|
+
constructor(code, message, status, body) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.code = code;
|
|
18
|
+
this.status = status;
|
|
19
|
+
this.body = body;
|
|
20
|
+
this.name = "RonanaApiError";
|
|
21
|
+
}
|
|
22
|
+
/** A sentence a person or an agent can act on. The `action` field is the
|
|
23
|
+
* server's own decision about which door to open, so this never guesses. */
|
|
24
|
+
get guidance() {
|
|
25
|
+
const billing = this.body?.billing;
|
|
26
|
+
if (!billing)
|
|
27
|
+
return this.message;
|
|
28
|
+
switch (billing.action) {
|
|
29
|
+
case "buy_credits": return `${this.message} Buy more credits at ${billing.url}.`;
|
|
30
|
+
case "upgrade_plan": return `${this.message} Move to a larger plan at ${billing.url}.`;
|
|
31
|
+
case "either": return `${this.message} Buy credits or move to a larger plan at ${billing.url}.`;
|
|
32
|
+
case "verify_email": return `${this.message} Verify the account email at ${billing.url}.`;
|
|
33
|
+
case "wait": return `${this.message} Wait, then try again.`;
|
|
34
|
+
case "contact_support": return `${this.message} Contact support.`;
|
|
35
|
+
default: return this.message;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** Read the configuration from the environment. Throws with a clear message
|
|
40
|
+
* when the key is missing, because that is the one mistake every new user
|
|
41
|
+
* makes. */
|
|
42
|
+
export function clientOptionsFromEnv(env = process.env) {
|
|
43
|
+
const apiKey = env.RONANA_API_KEY?.trim();
|
|
44
|
+
if (!apiKey) {
|
|
45
|
+
throw new Error("RONANA_API_KEY is not set. Create a key at https://ronana.ai/developers/keys, " +
|
|
46
|
+
"then put it in your MCP client configuration as RONANA_API_KEY.");
|
|
47
|
+
}
|
|
48
|
+
const baseUrl = (env.RONANA_BASE_URL?.trim() || "https://ronana.ai").replace(/\/+$/, "");
|
|
49
|
+
return { apiKey, baseUrl };
|
|
50
|
+
}
|
|
51
|
+
export class RonanaClient {
|
|
52
|
+
options;
|
|
53
|
+
constructor(options) {
|
|
54
|
+
this.options = options;
|
|
55
|
+
}
|
|
56
|
+
async request(method, path, body) {
|
|
57
|
+
const res = await fetch(`${this.options.baseUrl}${path}`, {
|
|
58
|
+
method,
|
|
59
|
+
headers: {
|
|
60
|
+
authorization: `Bearer ${this.options.apiKey}`,
|
|
61
|
+
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
62
|
+
},
|
|
63
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
64
|
+
});
|
|
65
|
+
const text = await res.text();
|
|
66
|
+
const parsed = text ? JSON.parse(text) : null;
|
|
67
|
+
if (!res.ok) {
|
|
68
|
+
const envelope = parsed?.error ?? null;
|
|
69
|
+
throw new RonanaApiError(envelope?.code ?? "UNKNOWN", envelope?.message ?? `Request failed with status ${res.status}.`, res.status, envelope);
|
|
70
|
+
}
|
|
71
|
+
return parsed;
|
|
72
|
+
}
|
|
73
|
+
listModels() {
|
|
74
|
+
return this.request("GET", "/api/v1/models");
|
|
75
|
+
}
|
|
76
|
+
account() {
|
|
77
|
+
return this.request("GET", "/api/v1/me");
|
|
78
|
+
}
|
|
79
|
+
quote(input) {
|
|
80
|
+
return this.request("POST", "/api/v1/pricing/quote", input);
|
|
81
|
+
}
|
|
82
|
+
submitVideo(body) {
|
|
83
|
+
return this.request("POST", "/api/v1/generations", body);
|
|
84
|
+
}
|
|
85
|
+
submitImage(body) {
|
|
86
|
+
return this.request("POST", "/api/v1/images", body);
|
|
87
|
+
}
|
|
88
|
+
generation(id) {
|
|
89
|
+
return this.request("GET", `/api/v1/generations/${encodeURIComponent(id)}`);
|
|
90
|
+
}
|
|
91
|
+
/** Upload a file inline. Use this up to 4 MB; larger files need the presigned
|
|
92
|
+
* flow below, because a serverless function body cannot exceed 4.5 MB. */
|
|
93
|
+
async uploadInline(bytes, filename) {
|
|
94
|
+
const res = await fetch(`${this.options.baseUrl}/api/v1/refs`, {
|
|
95
|
+
method: "POST",
|
|
96
|
+
headers: {
|
|
97
|
+
authorization: `Bearer ${this.options.apiKey}`,
|
|
98
|
+
"content-type": "application/octet-stream",
|
|
99
|
+
"x-filename": encodeURIComponent(filename),
|
|
100
|
+
},
|
|
101
|
+
body: bytes,
|
|
102
|
+
});
|
|
103
|
+
const text = await res.text();
|
|
104
|
+
const parsed = text ? JSON.parse(text) : null;
|
|
105
|
+
if (!res.ok) {
|
|
106
|
+
const envelope = parsed?.error ?? null;
|
|
107
|
+
throw new RonanaApiError(envelope?.code ?? "UNKNOWN", envelope?.message ?? "Upload failed.", res.status, envelope);
|
|
108
|
+
}
|
|
109
|
+
return parsed;
|
|
110
|
+
}
|
|
111
|
+
/** Upload a file larger than 4 MB: sign, PUT straight to storage, finalize.
|
|
112
|
+
*
|
|
113
|
+
* CAUTION: The PUT must send exactly the content type and the content length
|
|
114
|
+
* that were signed. Any other value fails the storage signature check. */
|
|
115
|
+
async uploadPresigned(bytes, filename, contentType) {
|
|
116
|
+
const signed = await this.request("POST", "/api/v1/refs/upload-url", { filename, contentType, size: bytes.byteLength });
|
|
117
|
+
const put = await fetch(signed.uploadUrl, {
|
|
118
|
+
method: "PUT",
|
|
119
|
+
headers: { "content-type": signed.contentType, "content-length": String(bytes.byteLength) },
|
|
120
|
+
body: bytes,
|
|
121
|
+
});
|
|
122
|
+
if (!put.ok)
|
|
123
|
+
throw new Error(`The upload to storage failed with status ${put.status}.`);
|
|
124
|
+
return this.request("POST", "/api/v1/refs", { tmpKey: signed.tmpKey, filename });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** The most bytes an inline upload may carry. Above this, use the signed flow. */
|
|
128
|
+
export const INLINE_UPLOAD_MAX_BYTES = 4 * 1024 * 1024;
|
package/dist/input.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export interface Args {
|
|
2
|
+
positional: string[];
|
|
3
|
+
flags: Map<string, string[]>;
|
|
4
|
+
}
|
|
5
|
+
export declare function parseArgs(argv: string[]): Args;
|
|
6
|
+
export declare const flag: (args: Args, name: string) => string | undefined;
|
|
7
|
+
export declare const has: (args: Args, name: string) => boolean;
|
|
8
|
+
export declare const takeFlag: (args: Args, name: string) => string | undefined;
|
|
9
|
+
export interface JsonSchema {
|
|
10
|
+
type?: string | string[];
|
|
11
|
+
enum?: unknown[];
|
|
12
|
+
default?: unknown;
|
|
13
|
+
description?: string;
|
|
14
|
+
items?: JsonSchema;
|
|
15
|
+
properties?: Record<string, JsonSchema>;
|
|
16
|
+
required?: string[];
|
|
17
|
+
minimum?: number;
|
|
18
|
+
maximum?: number;
|
|
19
|
+
}
|
|
20
|
+
export interface ModelContract {
|
|
21
|
+
id: string;
|
|
22
|
+
kind: "image" | "video";
|
|
23
|
+
name: string;
|
|
24
|
+
input_schema: JsonSchema;
|
|
25
|
+
media_fields: {
|
|
26
|
+
field: string;
|
|
27
|
+
kind: string;
|
|
28
|
+
mask?: boolean;
|
|
29
|
+
}[];
|
|
30
|
+
priced_fields: string[];
|
|
31
|
+
}
|
|
32
|
+
export declare const typeOf: (schema: JsonSchema) => string;
|
|
33
|
+
/** A URL (the server says which schemes it accepts) or a Ronana id. */
|
|
34
|
+
export declare const isRemoteValue: (v: string) => boolean;
|
|
35
|
+
export declare function buildInput(contract: ModelContract, args: Args): Promise<Record<string, unknown>>;
|
|
36
|
+
/** Replace every local file named in a media field with an uploaded ref id.
|
|
37
|
+
* Only media fields are looked at, so a prompt that happens to match a file
|
|
38
|
+
* name is never uploaded. A file named twice uploads once. */
|
|
39
|
+
export declare function replaceLocalMedia(contract: ModelContract, input: Record<string, unknown>, upload: (path: string) => Promise<string>): Promise<void>;
|