modelmux-cli 0.0.1 → 0.3.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.
Files changed (46) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +23 -1
  3. package/dist/_shared/providers/claude.json +34 -0
  4. package/dist/_shared/providers/codex.json +35 -0
  5. package/dist/_shared/release.json +4 -0
  6. package/dist/_shared/schema/provider.schema.json +81 -0
  7. package/dist/_shared/templates/compose-service.json +12 -0
  8. package/dist/_shared/templates/config/curl.json +8 -0
  9. package/dist/_shared/templates/config/env.json +8 -0
  10. package/dist/_shared/templates/config/langchain.json +8 -0
  11. package/dist/_shared/templates/config/litellm.json +8 -0
  12. package/dist/_shared/templates/config/openai-python.json +8 -0
  13. package/dist/args.js +422 -0
  14. package/dist/bin.js +4 -0
  15. package/dist/browser.js +51 -0
  16. package/dist/cli.js +60 -0
  17. package/dist/clientconfig.js +89 -0
  18. package/dist/commands/basic.js +72 -0
  19. package/dist/commands/configCmd.js +62 -0
  20. package/dist/commands/doctor.js +194 -0
  21. package/dist/commands/login.js +185 -0
  22. package/dist/commands/logout.js +57 -0
  23. package/dist/commands/up.js +204 -0
  24. package/dist/commands/upgrade.js +46 -0
  25. package/dist/config.js +97 -0
  26. package/dist/console.js +93 -0
  27. package/dist/context.js +36 -0
  28. package/dist/docker.js +294 -0
  29. package/dist/errors.js +49 -0
  30. package/dist/files.js +45 -0
  31. package/dist/health.js +78 -0
  32. package/dist/interrupt.js +43 -0
  33. package/dist/main.js +85 -0
  34. package/dist/paths.js +51 -0
  35. package/dist/ports.js +25 -0
  36. package/dist/prompt.js +69 -0
  37. package/dist/providers.js +178 -0
  38. package/dist/redact.js +33 -0
  39. package/dist/release.js +52 -0
  40. package/dist/secretsStore.js +61 -0
  41. package/dist/stack.js +220 -0
  42. package/dist/terminal.js +46 -0
  43. package/dist/text.js +40 -0
  44. package/dist/updates.js +59 -0
  45. package/dist/version.js +15 -0
  46. package/package.json +34 -2
package/dist/args.js ADDED
@@ -0,0 +1,422 @@
1
+ /**
2
+ * A small command-line parser that behaves like Python's argparse, so the
3
+ * Node CLI accepts exactly the same command lines as the Python one and
4
+ * answers with the same help, usage and error text (exit code 2).
5
+ *
6
+ * Supported: subcommands, store_true / store / append options, --version,
7
+ * -h/--help, choices, int values, positionals with nargs 1, "?" and "*",
8
+ * unique prefixes of long options (--verb -> --verbose), --opt=value,
9
+ * clusters of short flags (-fv), "--", and negative numbers as values.
10
+ */
11
+ import { repr } from "./text.js";
12
+ /** Raised for `-h` and `--version`: print `text` to stdout and exit 0. */
13
+ export class ParserExit extends Error {
14
+ status;
15
+ text;
16
+ constructor(status, text) {
17
+ super(text);
18
+ this.status = status;
19
+ this.text = text;
20
+ }
21
+ }
22
+ /** A usage error: `text` (usage + "prog: error: ...") goes to stderr, exit 2. */
23
+ export class ParserError extends Error {
24
+ status = 2;
25
+ text;
26
+ constructor(text) {
27
+ super(text);
28
+ this.text = text;
29
+ }
30
+ }
31
+ /** Python's `shutil.get_terminal_size().columns`. */
32
+ function terminalColumns() {
33
+ const env = Number.parseInt(process.env.COLUMNS ?? "", 10);
34
+ if (env > 0)
35
+ return env;
36
+ return process.stdout.columns && process.stdout.columns > 0 ? process.stdout.columns : 80;
37
+ }
38
+ /** Python's `textwrap.wrap` after argparse collapses whitespace. */
39
+ function wrap(text, width) {
40
+ const words = text.trim().split(/\s+/).filter(Boolean);
41
+ const lines = [];
42
+ let line = "";
43
+ for (const word of words) {
44
+ if (line && line.length + 1 + word.length > width) {
45
+ lines.push(line);
46
+ line = word;
47
+ }
48
+ else {
49
+ line = line ? `${line} ${word}` : word;
50
+ }
51
+ }
52
+ if (line)
53
+ lines.push(line);
54
+ return lines;
55
+ }
56
+ const INT = /^\s*[+-]?\d+(_\d+)*\s*$/;
57
+ const NEGATIVE_NUMBER = /^-\d+$|^-\d*\.\d+$/;
58
+ export class Parser {
59
+ prog;
60
+ description;
61
+ epilog;
62
+ options = [];
63
+ positionals = [];
64
+ sub = null;
65
+ defaults = {};
66
+ constructor(prog, options = {}) {
67
+ this.prog = prog;
68
+ this.description = options.description ?? null;
69
+ this.epilog = options.epilog ?? null;
70
+ this.addOption({ flags: ["-h", "--help"], action: "help", help: "show this help message and exit" });
71
+ }
72
+ addOption(spec) {
73
+ const long = spec.flags.find((f) => f.startsWith("--")) ?? spec.flags[0];
74
+ const dest = spec.dest ?? long.replace(/^-+/, "").replaceAll("-", "_");
75
+ const action = spec.action ?? "store";
76
+ this.options.push({ ...spec, dest, action });
77
+ if (action === "store_true")
78
+ this.defaults[dest] = spec.default ?? false;
79
+ else if (action === "store" || action === "append")
80
+ this.defaults[dest] = spec.default ?? null;
81
+ return this;
82
+ }
83
+ addPositional(spec) {
84
+ this.positionals.push(spec);
85
+ if (spec.nargs === "*")
86
+ this.defaults[spec.dest] = [];
87
+ else if (spec.nargs === "?")
88
+ this.defaults[spec.dest] = null;
89
+ return this;
90
+ }
91
+ addSubcommands(dest, metavar) {
92
+ this.sub = { dest, metavar, commands: [] };
93
+ return this;
94
+ }
95
+ addCommand(name, help, configure = () => undefined) {
96
+ if (this.sub === null)
97
+ throw new Error("no subcommands");
98
+ const parser = new Parser(`${this.prog} ${name}`);
99
+ configure(parser);
100
+ this.sub.commands.push({ name, help, parser });
101
+ return parser;
102
+ }
103
+ setDefaults(values) {
104
+ Object.assign(this.defaults, values);
105
+ return this;
106
+ }
107
+ // ---------------------------------------------------------------- parsing
108
+ /** Parse `argv`. Throws `ParserExit` (help/version) or `ParserError`. */
109
+ parse(argv) {
110
+ const namespace = {};
111
+ const extras = this.parseInto(argv, namespace);
112
+ if (extras.length)
113
+ this.error(`unrecognized arguments: ${extras.join(" ")}`);
114
+ return namespace;
115
+ }
116
+ looksLikeOption(arg) {
117
+ return arg.startsWith("-") && arg !== "-" && !NEGATIVE_NUMBER.test(arg);
118
+ }
119
+ /** Find the option an argument names: exact, `--opt=value`, or a unique long prefix. */
120
+ match(arg) {
121
+ const [name, inline] = arg.includes("=") && arg.startsWith("--")
122
+ ? [arg.slice(0, arg.indexOf("=")), arg.slice(arg.indexOf("=") + 1)]
123
+ : [arg, null];
124
+ const exact = this.options.find((o) => o.flags.includes(name));
125
+ if (exact)
126
+ return { option: exact, inline };
127
+ if (name.startsWith("--")) {
128
+ const hits = [];
129
+ for (const option of this.options) {
130
+ for (const flag of option.flags) {
131
+ if (flag.startsWith("--") && flag.startsWith(name))
132
+ hits.push([option, flag]);
133
+ }
134
+ }
135
+ if (hits.length > 1) {
136
+ const names = hits.map(([, flag]) => flag).join(", ");
137
+ this.error(`ambiguous option: ${name} could match ${names}`);
138
+ }
139
+ if (hits.length === 1)
140
+ return { option: hits[0][0], inline };
141
+ }
142
+ return null;
143
+ }
144
+ convert(option, value) {
145
+ if (option.type === "int") {
146
+ if (!INT.test(value))
147
+ this.error(`argument ${option.flags.join("/")}: invalid int value: ${repr(value)}`);
148
+ return Number.parseInt(value.replaceAll("_", "").trim(), 10);
149
+ }
150
+ if (option.choices && !option.choices.includes(value)) {
151
+ this.error(`argument ${option.flags.join("/")}: invalid choice: ${repr(value)} ` +
152
+ `(choose from ${option.choices.map(repr).join(", ")})`);
153
+ }
154
+ return value;
155
+ }
156
+ apply(option, value, namespace) {
157
+ switch (option.action) {
158
+ case "help":
159
+ throw new ParserExit(0, this.formatHelp());
160
+ case "version":
161
+ throw new ParserExit(0, `${option.version ?? ""}\n`);
162
+ case "store_true":
163
+ namespace[option.dest] = true;
164
+ return;
165
+ case "store":
166
+ namespace[option.dest] = this.convert(option, value);
167
+ return;
168
+ case "append": {
169
+ const list = Array.isArray(namespace[option.dest]) ? namespace[option.dest] : [];
170
+ namespace[option.dest] = [...list, this.convert(option, value)];
171
+ return;
172
+ }
173
+ }
174
+ }
175
+ positionalName(spec) {
176
+ return spec.metavar ?? spec.dest;
177
+ }
178
+ checkChoice(spec, value) {
179
+ if (spec.choices && !spec.choices.includes(value)) {
180
+ this.error(`argument ${this.positionalName(spec)}: invalid choice: ${repr(value)} ` +
181
+ `(choose from ${spec.choices.map(repr).join(", ")})`);
182
+ }
183
+ return value;
184
+ }
185
+ /** Parse into `namespace`; returns arguments nobody recognised. */
186
+ parseInto(argv, namespace) {
187
+ for (const [key, value] of Object.entries(this.defaults)) {
188
+ if (!(key in namespace))
189
+ namespace[key] = Array.isArray(value) ? [...value] : value;
190
+ }
191
+ const extras = [];
192
+ const values = [];
193
+ let i = 0;
194
+ let onlyPositionals = false;
195
+ let subcommandAt = -1;
196
+ while (i < argv.length) {
197
+ const arg = argv[i];
198
+ if (!onlyPositionals && arg === "--") {
199
+ onlyPositionals = true;
200
+ i++;
201
+ continue;
202
+ }
203
+ if (onlyPositionals || !this.looksLikeOption(arg)) {
204
+ if (this.sub !== null && values.length === this.positionals.length) {
205
+ subcommandAt = i;
206
+ break;
207
+ }
208
+ values.push(arg);
209
+ i++;
210
+ continue;
211
+ }
212
+ const found = this.match(arg);
213
+ if (found !== null) {
214
+ const { option, inline } = found;
215
+ if (option.action === "store" || option.action === "append") {
216
+ let value = inline;
217
+ if (value === null) {
218
+ const next = argv[i + 1];
219
+ if (next === undefined || this.looksLikeOption(next)) {
220
+ this.error(`argument ${option.flags.join("/")}: expected one argument`);
221
+ }
222
+ value = next;
223
+ i++;
224
+ }
225
+ this.apply(option, value, namespace);
226
+ }
227
+ else {
228
+ if (inline !== null) {
229
+ this.error(`argument ${option.flags.join("/")}: ignored explicit argument ${repr(inline)}`);
230
+ }
231
+ this.apply(option, null, namespace);
232
+ }
233
+ i++;
234
+ continue;
235
+ }
236
+ // A cluster of short flags, e.g. -fv.
237
+ if (/^-[A-Za-z]{2,}$/.test(arg)) {
238
+ const flags = [...arg.slice(1)].map((ch) => this.options.find((o) => o.flags.includes(`-${ch}`)));
239
+ if (flags.every((o) => o !== undefined && o.action !== "store" && o.action !== "append")) {
240
+ for (const option of flags)
241
+ this.apply(option, null, namespace);
242
+ i++;
243
+ continue;
244
+ }
245
+ }
246
+ extras.push(arg);
247
+ i++;
248
+ }
249
+ this.assignPositionals(values, namespace, extras);
250
+ if (this.sub !== null) {
251
+ if (subcommandAt < 0) {
252
+ this.error(`the following arguments are required: ${this.sub.metavar}`);
253
+ }
254
+ const name = argv[subcommandAt];
255
+ const command = this.sub.commands.find((c) => c.name === name);
256
+ if (command === undefined) {
257
+ this.error(`argument ${this.sub.metavar}: invalid choice: ${repr(name)} ` +
258
+ `(choose from ${this.sub.commands.map((c) => repr(c.name)).join(", ")})`);
259
+ }
260
+ namespace[this.sub.dest] = name;
261
+ extras.push(...command.parser.parseInto(argv.slice(subcommandAt + 1), namespace));
262
+ }
263
+ return extras;
264
+ }
265
+ assignPositionals(values, namespace, extras) {
266
+ const queue = [...values];
267
+ const missing = [];
268
+ for (const spec of this.positionals) {
269
+ if (spec.nargs === "*") {
270
+ namespace[spec.dest] = queue.splice(0).map((v) => this.checkChoice(spec, v));
271
+ }
272
+ else if (spec.nargs === "?") {
273
+ const value = queue.shift();
274
+ namespace[spec.dest] = value === undefined ? null : this.checkChoice(spec, value);
275
+ }
276
+ else {
277
+ const value = queue.shift();
278
+ if (value === undefined)
279
+ missing.push(this.positionalName(spec));
280
+ else
281
+ namespace[spec.dest] = this.checkChoice(spec, value);
282
+ }
283
+ }
284
+ if (missing.length)
285
+ this.error(`the following arguments are required: ${missing.join(", ")}`);
286
+ extras.push(...queue);
287
+ }
288
+ error(message) {
289
+ throw new ParserError(`${this.formatUsage()}${this.prog}: error: ${message}\n`);
290
+ }
291
+ // ---------------------------------------------------------------- help
292
+ width() {
293
+ return terminalColumns() - 2;
294
+ }
295
+ optionUsage(option) {
296
+ const flag = option.flags[0];
297
+ if (option.action === "store" || option.action === "append") {
298
+ return `[${flag} ${this.metavar(option)}]`;
299
+ }
300
+ return `[${flag}]`;
301
+ }
302
+ metavar(option) {
303
+ if (option.metavar)
304
+ return option.metavar;
305
+ if (option.choices)
306
+ return `{${option.choices.join(",")}}`;
307
+ return option.dest.toUpperCase();
308
+ }
309
+ positionalUsage(spec) {
310
+ const name = spec.metavar ?? (spec.choices ? `{${spec.choices.join(",")}}` : spec.dest);
311
+ if (spec.nargs === "?")
312
+ return `[${name}]`;
313
+ if (spec.nargs === "*")
314
+ return `[${name} ...]`;
315
+ return name;
316
+ }
317
+ positionalInvocation(spec) {
318
+ return spec.metavar ?? (spec.choices ? `{${spec.choices.join(",")}}` : spec.dest);
319
+ }
320
+ formatUsage() {
321
+ const prefix = "usage: ";
322
+ const optParts = this.options.map((o) => this.optionUsage(o));
323
+ const posParts = this.positionals.map((p) => this.positionalUsage(p));
324
+ if (this.sub !== null)
325
+ posParts.push(`${this.sub.metavar} ...`);
326
+ const textWidth = this.width();
327
+ const full = [this.prog, ...optParts, ...posParts].join(" ");
328
+ if (prefix.length + full.length <= textWidth)
329
+ return `${prefix}${full}\n`;
330
+ const getLines = (parts, indent, first = null) => {
331
+ const lines = [];
332
+ let line = [];
333
+ let length = first !== null ? first.length - 1 : indent.length - 1;
334
+ for (const part of parts) {
335
+ if (length + 1 + part.length > textWidth && line.length) {
336
+ lines.push(indent + line.join(" "));
337
+ line = [];
338
+ length = indent.length - 1;
339
+ }
340
+ line.push(part);
341
+ length += part.length + 1;
342
+ }
343
+ if (line.length)
344
+ lines.push(indent + line.join(" "));
345
+ if (first !== null && lines.length)
346
+ lines[0] = lines[0].slice(indent.length);
347
+ return lines;
348
+ };
349
+ let lines;
350
+ if (prefix.length + this.prog.length <= 0.75 * textWidth) {
351
+ const indent = " ".repeat(prefix.length + this.prog.length + 1);
352
+ if (optParts.length) {
353
+ lines = getLines([this.prog, ...optParts], indent, prefix);
354
+ lines.push(...getLines(posParts, indent));
355
+ }
356
+ else if (posParts.length) {
357
+ lines = getLines([this.prog, ...posParts], indent, prefix);
358
+ }
359
+ else {
360
+ lines = [this.prog];
361
+ }
362
+ }
363
+ else {
364
+ const indent = " ".repeat(prefix.length);
365
+ lines = [this.prog, ...getLines([...optParts, ...posParts], indent)];
366
+ }
367
+ return `${prefix}${lines.join("\n")}\n`;
368
+ }
369
+ formatHelp() {
370
+ const width = this.width();
371
+ const maxHelpPosition = Math.min(24, Math.max(width - 20, 4));
372
+ const positionalRows = this.positionals.map((p) => ({
373
+ invocation: this.positionalInvocation(p),
374
+ help: p.help,
375
+ indent: 2,
376
+ }));
377
+ if (this.sub !== null) {
378
+ positionalRows.push({ invocation: this.sub.metavar, help: undefined, indent: 2 });
379
+ for (const command of this.sub.commands) {
380
+ positionalRows.push({ invocation: command.name, help: command.help, indent: 4 });
381
+ }
382
+ }
383
+ const optionRows = this.options.map((o) => ({
384
+ invocation: o.action === "store" || o.action === "append"
385
+ ? o.flags.map((f) => `${f} ${this.metavar(o)}`).join(", ")
386
+ : o.flags.join(", "),
387
+ help: o.help,
388
+ indent: 2,
389
+ }));
390
+ const all = [...positionalRows, ...optionRows];
391
+ const maxLength = Math.max(...all.map((r) => r.invocation.length + r.indent));
392
+ const helpPosition = Math.min(maxLength + 2, maxHelpPosition);
393
+ const helpWidth = Math.max(width - helpPosition, 11);
394
+ const formatRow = (row) => {
395
+ const actionWidth = helpPosition - row.indent - 2;
396
+ const head = " ".repeat(row.indent) + row.invocation;
397
+ if (!row.help)
398
+ return `${head}\n`;
399
+ const helpLines = wrap(row.help, helpWidth);
400
+ let out;
401
+ if (row.invocation.length <= actionWidth) {
402
+ out = head.padEnd(helpPosition) + helpLines[0] + "\n";
403
+ }
404
+ else {
405
+ out = `${head}\n${" ".repeat(helpPosition)}${helpLines[0]}\n`;
406
+ }
407
+ for (const line of helpLines.slice(1))
408
+ out += `${" ".repeat(helpPosition)}${line}\n`;
409
+ return out;
410
+ };
411
+ const sections = [this.formatUsage()];
412
+ if (this.description)
413
+ sections.push(wrap(this.description, width).join("\n") + "\n");
414
+ if (positionalRows.length) {
415
+ sections.push("positional arguments:\n" + positionalRows.map(formatRow).join(""));
416
+ }
417
+ sections.push("options:\n" + optionRows.map(formatRow).join(""));
418
+ if (this.epilog)
419
+ sections.push(wrap(this.epilog, width).join("\n") + "\n");
420
+ return sections.join("\n");
421
+ }
422
+ }
package/dist/bin.js ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ /** The `modelmux` executable. */
3
+ import { cli } from "./main.js";
4
+ await cli();
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Open a link in the user's browser (Python's `webbrowser.open`).
3
+ *
4
+ * The second and last module that starts programs (besides docker.ts): the
5
+ * system's own opener, as an argument list without a shell, detached so it
6
+ * never holds the login up. Resolves false when there is no usable browser
7
+ * here (no desktop session, opener missing), so the caller can say so.
8
+ */
9
+ import { spawn } from "node:child_process";
10
+ import { which } from "./docker.js";
11
+ /** The command to run for `url`, or null when no browser can be used here. */
12
+ export function browserCommand(url, env = process.env, platform = process.platform) {
13
+ // $BROWSER: a list of commands; "%s" marks where the link goes.
14
+ for (const entry of (env.BROWSER ?? "").split(platform === "win32" ? ";" : ":")) {
15
+ const parts = entry.trim().split(/\s+/).filter(Boolean);
16
+ if (parts.length && which(parts[0], env, platform)) {
17
+ return parts.some((p) => p.includes("%s"))
18
+ ? parts.map((p) => p.replaceAll("%s", url))
19
+ : [...parts, url];
20
+ }
21
+ }
22
+ if (platform === "darwin")
23
+ return ["open", url];
24
+ if (platform === "win32")
25
+ return ["explorer.exe", url];
26
+ if (!env.DISPLAY && !env.WAYLAND_DISPLAY)
27
+ return null; // no desktop session
28
+ for (const opener of ["xdg-open", "gio", "sensible-browser", "x-www-browser"]) {
29
+ if (which(opener, env, platform))
30
+ return opener === "gio" ? [opener, "open", url] : [opener, url];
31
+ }
32
+ return null;
33
+ }
34
+ export function openBrowser(url) {
35
+ const command = browserCommand(url);
36
+ if (command === null)
37
+ return Promise.resolve(false);
38
+ return new Promise((resolve) => {
39
+ try {
40
+ const child = spawn(command[0], command.slice(1), { stdio: "ignore", detached: true });
41
+ child.once("error", () => resolve(false));
42
+ child.once("spawn", () => {
43
+ child.unref();
44
+ resolve(true);
45
+ });
46
+ }
47
+ catch {
48
+ resolve(false);
49
+ }
50
+ });
51
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,60 @@
1
+ /** The command line: the same commands, options and help as the Python CLI. */
2
+ import { Parser } from "./args.js";
3
+ import { TARGETS } from "./clientconfig.js";
4
+ import { loadProviders } from "./providers.js";
5
+ import { VERSION } from "./version.js";
6
+ export function buildParser() {
7
+ const providers = [...loadProviders().keys()];
8
+ const parser = new Parser("modelmux", {
9
+ description: "Run AI coding CLIs as a secure, OpenAI-compatible API.",
10
+ epilog: "Start with: modelmux up, then modelmux login <provider>.",
11
+ });
12
+ parser.addOption({ flags: ["--version"], action: "version", version: `modelmux ${VERSION}`,
13
+ help: "show program's version number and exit" });
14
+ parser.addOption({ flags: ["-v", "--verbose"], action: "store_true", help: "show details and commands" });
15
+ parser.addOption({ flags: ["--no-color"], action: "store_true", help: "disable coloured output" });
16
+ parser.addSubcommands("command", "<command>");
17
+ parser.addCommand("up", "start ModelMux (logged-in providers)", (up) => {
18
+ up.addPositional({ dest: "providers", nargs: "*", metavar: "provider",
19
+ help: `any of: ${providers.join(", ")}` });
20
+ up.addOption({ flags: ["--image"], metavar: "REF", help: "server image to run (remembered)" });
21
+ up.addOption({ flags: ["--port"], action: "append", metavar: "PROVIDER=PORT",
22
+ help: "local port (remembered)" });
23
+ });
24
+ parser.addCommand("down", "stop ModelMux (logins are kept)");
25
+ parser.addCommand("logs", "show server logs", (logs) => {
26
+ logs.addPositional({ dest: "provider", nargs: "?", choices: providers });
27
+ logs.addOption({ flags: ["-f", "--follow"], action: "store_true", help: "keep streaming new lines" });
28
+ logs.addOption({ flags: ["--tail"], type: "int", default: 100, metavar: "N",
29
+ help: "lines to show (100)" });
30
+ });
31
+ parser.addCommand("status", "what is running, logged in and healthy");
32
+ parser.addCommand("login", "log a provider in (guided)", (login) => {
33
+ login.addPositional({ dest: "provider", choices: providers });
34
+ login.addOption({ flags: ["--method"], help: "login method (see the provider's options)" });
35
+ login.addOption({ flags: ["--no-browser"], action: "store_true", help: "do not open a browser" });
36
+ login.addOption({ flags: ["--raw"], action: "store_true", help: "show the provider's raw output" });
37
+ login.addOption({ flags: ["--force"], action: "store_true", help: "log in again even if logged in" });
38
+ });
39
+ parser.addCommand("logout", "remove a provider's saved login", (logout) => {
40
+ logout.addPositional({ dest: "provider", choices: providers });
41
+ logout.addOption({ flags: ["-y", "--yes"], action: "store_true", help: "do not ask for confirmation" });
42
+ });
43
+ parser.addCommand("config", "print ready-to-paste client config", (config) => {
44
+ config.addPositional({ dest: "target", choices: TARGETS });
45
+ config.addOption({ flags: ["--provider"], choices: providers });
46
+ config.addOption({ flags: ["--reveal-key"], action: "store_true", help: "include the real API key" });
47
+ });
48
+ parser.addCommand("doctor", "diagnose common problems");
49
+ parser.addCommand("upgrade", "run the image matching this CLI version", (upgrade) => {
50
+ upgrade.addOption({ flags: ["--image"], metavar: "REF", help: "use a different image (advanced)" });
51
+ });
52
+ parser.addCommand("key", "the API key clients use to call ModelMux", (key) => {
53
+ key.addSubcommands("key_command", "<action>");
54
+ key.addCommand("show", "print the API key");
55
+ });
56
+ return parser;
57
+ }
58
+ export function asArgs(namespace) {
59
+ return namespace;
60
+ }
@@ -0,0 +1,89 @@
1
+ /** Render client config snippets from `shared/templates/config/<target>.json`. */
2
+ import { readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { CliError } from "./errors.js";
5
+ import { sharedDir } from "./providers.js";
6
+ import { repr } from "./text.js";
7
+ export const KEY_ENV = "MODELMUX_API_KEY";
8
+ export const TARGETS = ["litellm", "openai-python", "langchain", "curl", "env"];
9
+ /** Python's `json.dumps()` of a string (the key is URL-safe, so no escaping differs). */
10
+ const jsonString = (key) => JSON.stringify(key);
11
+ // How each target refers to the key: [by reference, when revealed].
12
+ const KEY_STYLES = {
13
+ litellm: [`os.environ/${KEY_ENV}`, (key) => key],
14
+ python: [`os.environ["${KEY_ENV}"]`, jsonString],
15
+ shell: [`$${KEY_ENV}`, (key) => key],
16
+ env: ["", (key) => key],
17
+ };
18
+ const PLACEHOLDER = /\{\{(\w+)\}\}/g;
19
+ export function loadTemplate(name) {
20
+ const file = path.join(sharedDir(), "templates", "config", `${name}.json`);
21
+ let template;
22
+ try {
23
+ const data = JSON.parse(readFileSync(file, "utf8"));
24
+ const field = (key) => {
25
+ if (!(key in data))
26
+ throw new TypeError(key);
27
+ return data[key];
28
+ };
29
+ template = {
30
+ name,
31
+ description: field("description"),
32
+ per: field("per"),
33
+ keyStyle: field("key_style"),
34
+ header: field("header"),
35
+ entry: field("entry"),
36
+ footer: field("footer"),
37
+ };
38
+ }
39
+ catch {
40
+ throw new CliError(`The ${repr(name)} config template is missing or invalid.`, {
41
+ hint: "Please report this bug.",
42
+ });
43
+ }
44
+ if (!["provider", "model"].includes(template.per) || !(template.keyStyle in KEY_STYLES)) {
45
+ throw new CliError(`The ${repr(name)} config template is invalid.`, {
46
+ hint: "Please report this bug.",
47
+ });
48
+ }
49
+ for (const key of ["header", "entry", "footer"]) {
50
+ if (typeof template[key] !== "string") {
51
+ throw new CliError(`The ${repr(name)} config template is missing or invalid.`, {
52
+ hint: "Please report this bug.",
53
+ });
54
+ }
55
+ }
56
+ return template;
57
+ }
58
+ function fill(text, values) {
59
+ return text.replace(PLACEHOLDER, (match, key) => {
60
+ if (!Object.hasOwn(values, key)) {
61
+ throw new CliError(`Unknown placeholder ${match} in a config template.`, {
62
+ hint: "Please report this bug.",
63
+ });
64
+ }
65
+ return values[key];
66
+ });
67
+ }
68
+ /** `apiKey` null means: refer to $MODELMUX_API_KEY instead of the value. */
69
+ export function render(template, targets, apiKey) {
70
+ const [reference, literal] = KEY_STYLES[template.keyStyle];
71
+ const key = apiKey === null ? reference : literal(apiKey);
72
+ const parts = [fill(template.header, { key })];
73
+ for (const target of targets) {
74
+ const models = template.per === "model" ? target.models : target.models.slice(0, 1);
75
+ for (const model of models) {
76
+ parts.push(fill(template.entry, {
77
+ provider: target.provider,
78
+ PROVIDER: target.provider.toUpperCase().replaceAll("-", "_"),
79
+ provider_var: target.provider.replaceAll("-", "_"),
80
+ display_name: target.displayName,
81
+ model,
82
+ base_url: target.baseUrl,
83
+ key,
84
+ }));
85
+ }
86
+ }
87
+ parts.push(fill(template.footer, { key }));
88
+ return parts.join("").replace(/\n+$/, "") + "\n";
89
+ }