@metrio-ai/cli 1.0.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 ADDED
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2025, Steven Shen <steven@jidou.ai>
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ 3. Neither the name of the copyright holder nor the names of its
17
+ contributors may be used to endorse or promote products derived from
18
+ this software without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package/README.md ADDED
@@ -0,0 +1,38 @@
1
+ # @metrio-ai/cli
2
+
3
+ The `metrio` command-line interface for [MetrioAI](https://metrio.ai): run prompts and workflows, and manage Metrio resources from files.
4
+
5
+ ```bash
6
+ npm i -g @metrio-ai/cli
7
+ ```
8
+
9
+ ## Run prompts and workflows (project API key)
10
+
11
+ Set `METRIOAI_API_KEY` (or `METRIO_API_KEY` / `METRIO_PROJECT_API_KEY`) in the environment or a `.env` file; `-e/--env <file>` picks another env file.
12
+
13
+ ```bash
14
+ metrio chat --messages messages.json --prompt-id 7
15
+ metrio workflow run -w my-flow --message "hello" --json
16
+ metrio workflow run-batch -w my-flow --cases cases.jsonl
17
+ metrio prompt run-batch -p 7 --cases cases.jsonl
18
+ metrio prompt create-version -p 7 -f system.md
19
+ ```
20
+
21
+ `metrio chat --help`, `metrio workflow --help` and `metrio prompt --help` list every option.
22
+
23
+ ## Manage resources (console login)
24
+
25
+ ```bash
26
+ metrio login # browser sign-in; token stored in ~/.config/metrio/credentials.json
27
+ metrio whoami
28
+ metrio tools # every console tool your login can call
29
+ metrio call list_prompts --args '{"project_slug":"my-project"}'
30
+ metrio call create_workflow_version -f args.json
31
+ metrio logout
32
+ ```
33
+
34
+ For CI set `METRIO_TOKEN` (and `METRIO_CONSOLE_URL` if not `https://app.metrio.ai`). It must be an OAuth access token, valid for about 1 hour (e.g. `access_token` from `~/.config/metrio/credentials.json`), so it suits short jobs. For long-running headless use, run `metrio login` once on that machine; the stored login refreshes itself.
35
+
36
+ Exit codes: `0` ok, `1` failure, `2` usage or validation error, `3` not logged in / not allowed. Codes 2 and 3 apply to the account/management commands (`login`, `logout`, `whoami`, `tools`, `call`); the run commands keep their original behavior (errors exit 1).
37
+
38
+ Migrating from `@metrio-ai/client`'s bundled CLI: if the old CLI was installed globally, run `npm rm -g @metrio-ai/client` before `npm i -g @metrio-ai/cli` (both install a `metrio` bin). Every command and flag is unchanged. Bare `metrio --messages …` still works but is deprecated in favor of `metrio chat --messages …`.
@@ -0,0 +1,81 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.DEFAULT_CONSOLE_URL = void 0;
37
+ exports.normalizeConsoleUrl = normalizeConsoleUrl;
38
+ exports.credentialsPath = credentialsPath;
39
+ exports.readCredentials = readCredentials;
40
+ exports.writeCredentials = writeCredentials;
41
+ exports.deleteCredentials = deleteCredentials;
42
+ const fs = __importStar(require("fs"));
43
+ const os = __importStar(require("os"));
44
+ const path = __importStar(require("path"));
45
+ exports.DEFAULT_CONSOLE_URL = 'https://app.metrio.ai';
46
+ function normalizeConsoleUrl(url) {
47
+ return url.replace(/\/+$/, '');
48
+ }
49
+ function credentialsPath() {
50
+ const dir = process.env.METRIO_CONFIG_DIR || path.join(os.homedir(), '.config', 'metrio');
51
+ return path.join(dir, 'credentials.json');
52
+ }
53
+ function readCredentials() {
54
+ try {
55
+ return JSON.parse(fs.readFileSync(credentialsPath(), 'utf8'));
56
+ }
57
+ catch (err) {
58
+ if (err.code === 'ENOENT')
59
+ return null;
60
+ throw err;
61
+ }
62
+ }
63
+ function writeCredentials(creds) {
64
+ const file = credentialsPath();
65
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
66
+ // Atomic: concurrent CLI processes never observe a half-written file.
67
+ const tmp = `${file}.${process.pid}.tmp`;
68
+ fs.writeFileSync(tmp, `${JSON.stringify(creds, null, 2)}\n`, { mode: 0o600 });
69
+ fs.renameSync(tmp, file);
70
+ }
71
+ function deleteCredentials() {
72
+ try {
73
+ fs.unlinkSync(credentialsPath());
74
+ return true;
75
+ }
76
+ catch (err) {
77
+ if (err.code === 'ENOENT')
78
+ return false;
79
+ throw err;
80
+ }
81
+ }
@@ -0,0 +1,143 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.LOGIN_SCOPE = void 0;
37
+ exports.browserCommand = browserCommand;
38
+ exports.openBrowser = openBrowser;
39
+ exports.login = login;
40
+ const child_process_1 = require("child_process");
41
+ const crypto_1 = require("crypto");
42
+ const http = __importStar(require("http"));
43
+ const errors_1 = require("../errors");
44
+ const credentials_1 = require("./credentials");
45
+ const oauth_1 = require("./oauth");
46
+ /** admin is only granted to site admins; openid lets `whoami` read /api/oauth/userinfo. */
47
+ exports.LOGIN_SCOPE = 'read write admin openid';
48
+ /** Never routes the URL through a shell (cmd.exe would split it at `&`). */
49
+ function browserCommand(url, platform = process.platform) {
50
+ if (platform === 'darwin')
51
+ return ['open', [url]];
52
+ if (platform === 'win32')
53
+ return ['rundll32', ['url.dll,FileProtocolHandler', url]];
54
+ return ['xdg-open', [url]];
55
+ }
56
+ function openBrowser(url) {
57
+ const [cmd, args] = browserCommand(url);
58
+ const child = (0, child_process_1.spawn)(cmd, args, { stdio: 'ignore', detached: true });
59
+ child.on('error', () => undefined); // the URL is printed too, so a missing opener is fine
60
+ child.unref();
61
+ }
62
+ /** Resolves with the authorization code delivered to the loopback /callback. */
63
+ function waitForCallback(server, state, timeoutMs) {
64
+ return new Promise((resolve, reject) => {
65
+ const timer = setTimeout(() => reject(new Error('Timed out waiting for the browser login.')), timeoutMs);
66
+ server.on('request', (req, res) => {
67
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
68
+ if (url.pathname !== '/callback') {
69
+ res.writeHead(404).end();
70
+ return;
71
+ }
72
+ const finish = (status, message) => res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' }).end(message);
73
+ clearTimeout(timer);
74
+ const error = url.searchParams.get('error');
75
+ if (error) {
76
+ finish(400, 'Metrio CLI login failed. You can close this tab.');
77
+ reject(new Error(`Authorization denied: ${url.searchParams.get('error_description') || error}`));
78
+ }
79
+ else if (url.searchParams.get('state') !== state) {
80
+ finish(400, 'Metrio CLI login failed (state mismatch). You can close this tab.');
81
+ reject(new Error('Login failed: state mismatch in the OAuth callback.'));
82
+ }
83
+ else {
84
+ finish(200, 'Metrio CLI login complete. You can close this tab.');
85
+ resolve(url.searchParams.get('code') ?? '');
86
+ }
87
+ });
88
+ });
89
+ }
90
+ /**
91
+ * Browser login against the console's OAuth server: register a client for this
92
+ * run's loopback port (the console matches loopback ports exactly), authorize
93
+ * with a random state, then exchange the code with the client secret.
94
+ */
95
+ async function login(consoleUrlRaw, deps) {
96
+ const consoleUrl = (0, credentials_1.normalizeConsoleUrl)(consoleUrlRaw);
97
+ const server = http.createServer();
98
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
99
+ try {
100
+ const { port } = server.address();
101
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
102
+ const regRes = await fetch(`${consoleUrl}/api/oauth/register`, {
103
+ method: 'POST',
104
+ headers: { 'Content-Type': 'application/json' },
105
+ body: JSON.stringify({
106
+ client_name: 'metrio-cli',
107
+ redirect_uris: [redirectUri],
108
+ grant_types: ['authorization_code', 'refresh_token'],
109
+ response_types: ['code'],
110
+ token_endpoint_auth_method: 'client_secret_post',
111
+ scope: exports.LOGIN_SCOPE,
112
+ }),
113
+ });
114
+ const reg = (await regRes.json().catch(() => ({})));
115
+ if (!regRes.ok) {
116
+ throw new errors_1.ConsoleApiError(reg.error_description || `Client registration failed (HTTP ${regRes.status})`, regRes.status, reg.error || 'registration_failed');
117
+ }
118
+ const state = (0, crypto_1.randomBytes)(16).toString('hex');
119
+ const authorize = new URL(`${consoleUrl}/api/oauth/authorize`);
120
+ authorize.search = new URLSearchParams({
121
+ response_type: 'code',
122
+ client_id: reg.client_id,
123
+ redirect_uri: redirectUri,
124
+ scope: exports.LOGIN_SCOPE,
125
+ state,
126
+ }).toString();
127
+ const code = waitForCallback(server, state, deps.timeoutMs ?? 5 * 60_000);
128
+ deps.log(`Opening your browser to sign in. If it does not open, visit:\n ${authorize}\n`);
129
+ deps.openBrowser(authorize.toString());
130
+ const token = (await (0, oauth_1.postForm)(`${consoleUrl}/api/oauth/token`, {
131
+ grant_type: 'authorization_code',
132
+ code: await code,
133
+ redirect_uri: redirectUri,
134
+ client_id: reg.client_id,
135
+ client_secret: reg.client_secret,
136
+ }));
137
+ return (0, oauth_1.toCredentials)(consoleUrl, reg.client_id, reg.client_secret, token);
138
+ }
139
+ finally {
140
+ server.closeAllConnections?.();
141
+ server.close();
142
+ }
143
+ }
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.postForm = postForm;
4
+ exports.toCredentials = toCredentials;
5
+ exports.refreshCredentials = refreshCredentials;
6
+ const errors_1 = require("../errors");
7
+ const credentials_1 = require("./credentials");
8
+ /** POSTs an x-www-form-urlencoded body (OAuth token/revoke endpoints). */
9
+ async function postForm(url, params) {
10
+ const res = await fetch(url, {
11
+ method: 'POST',
12
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
13
+ body: new URLSearchParams(params).toString(),
14
+ });
15
+ const body = (await res.json().catch(() => ({})));
16
+ if (!res.ok) {
17
+ const message = String(body.error_description ?? body.error ?? `HTTP ${res.status}`);
18
+ throw new errors_1.ConsoleApiError(message, res.status, String(body.error ?? 'oauth_error'));
19
+ }
20
+ return body;
21
+ }
22
+ function toCredentials(consoleUrl, clientId, clientSecret, token, now = Date.now()) {
23
+ return {
24
+ console_url: (0, credentials_1.normalizeConsoleUrl)(consoleUrl),
25
+ client_id: clientId,
26
+ client_secret: clientSecret,
27
+ access_token: token.access_token,
28
+ refresh_token: token.refresh_token,
29
+ expires_at: new Date(now + token.expires_in * 1000).toISOString(),
30
+ scope: token.scope ?? '',
31
+ };
32
+ }
33
+ async function refreshCredentials(creds) {
34
+ const token = (await postForm(`${creds.console_url}/api/oauth/token`, {
35
+ grant_type: 'refresh_token',
36
+ refresh_token: creds.refresh_token,
37
+ client_id: creds.client_id,
38
+ client_secret: creds.client_secret,
39
+ }));
40
+ return toCredentials(creds.console_url, creds.client_id, creds.client_secret, {
41
+ ...token,
42
+ scope: token.scope ?? creds.scope,
43
+ });
44
+ }
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runLoginCommand = runLoginCommand;
4
+ exports.runLogoutCommand = runLogoutCommand;
5
+ exports.runWhoamiCommand = runWhoamiCommand;
6
+ const credentials_1 = require("../auth/credentials");
7
+ const login_1 = require("../auth/login");
8
+ const oauth_1 = require("../auth/oauth");
9
+ const client_1 = require("../console/client");
10
+ const errors_1 = require("../errors");
11
+ const LOGIN_HELP = `metrio login - sign in to the Metrio console in your browser
12
+
13
+ Usage: metrio login [--console-url <url>]
14
+
15
+ --console-url <url> Console to sign in to (default: METRIO_CONSOLE_URL or ${credentials_1.DEFAULT_CONSOLE_URL})
16
+
17
+ The login is stored in ~/.config/metrio/credentials.json (override the directory
18
+ with METRIO_CONFIG_DIR). For CI or headless agents, set METRIO_TOKEN instead.
19
+ `;
20
+ async function runLoginCommand(argv, deps) {
21
+ let consoleUrl = process.env.METRIO_CONSOLE_URL || credentials_1.DEFAULT_CONSOLE_URL;
22
+ for (let i = 0; i < argv.length; i += 1) {
23
+ const arg = argv[i];
24
+ if (arg === '-h' || arg === '--help') {
25
+ process.stdout.write(LOGIN_HELP);
26
+ return;
27
+ }
28
+ if (arg === '--console-url' && argv[i + 1]) {
29
+ consoleUrl = argv[(i += 1)];
30
+ }
31
+ else {
32
+ throw new errors_1.CliError(`Unknown argument: ${arg}`);
33
+ }
34
+ }
35
+ const creds = await (0, login_1.login)(consoleUrl, deps ?? { openBrowser: login_1.openBrowser, log: (m) => process.stderr.write(m) });
36
+ (0, credentials_1.writeCredentials)(creds);
37
+ process.stdout.write(`Logged in to ${creds.console_url} (scopes: ${creds.scope})\n`);
38
+ }
39
+ async function runLogoutCommand(argv) {
40
+ if (argv.length > 0)
41
+ throw new errors_1.CliError(`Unknown argument: ${argv[0]}`);
42
+ const creds = (0, credentials_1.readCredentials)();
43
+ if (!creds) {
44
+ process.stdout.write('Not logged in.\n');
45
+ return;
46
+ }
47
+ // Best effort, each independently: an unreachable console must not keep tokens on disk.
48
+ // Revoking the refresh token also drops its access token; the access-token call covers
49
+ // the reverse and is harmless if already gone.
50
+ const revoke = (token, hint) => (0, oauth_1.postForm)(`${creds.console_url}/api/oauth/revoke`, {
51
+ token,
52
+ token_type_hint: hint,
53
+ client_id: creds.client_id,
54
+ client_secret: creds.client_secret,
55
+ }).catch(() => undefined);
56
+ await revoke(creds.refresh_token, 'refresh_token');
57
+ await revoke(creds.access_token, 'access_token');
58
+ (0, credentials_1.deleteCredentials)();
59
+ process.stdout.write(`Logged out of ${creds.console_url}\n`);
60
+ }
61
+ async function runWhoamiCommand(argv) {
62
+ const asJson = argv.includes('--json');
63
+ const unknown = argv.find((a) => a !== '--json');
64
+ if (unknown)
65
+ throw new errors_1.CliError(`Unknown argument: ${unknown}`);
66
+ const auth = await (0, client_1.resolveConsoleAuth)();
67
+ const info = (await new client_1.ConsoleClient(auth).getJson('/api/oauth/userinfo'));
68
+ if (asJson) {
69
+ process.stdout.write(`${JSON.stringify({ ...info, console_url: auth.consoleUrl }, null, 2)}\n`);
70
+ return;
71
+ }
72
+ const who = info.email ? `${info.name} <${info.email}>` : String(info.name ?? info.sub);
73
+ process.stdout.write(`${who}\nconsole: ${auth.consoleUrl}\nscopes: ${info.scope}\n`);
74
+ }
@@ -0,0 +1,235 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.runChatCommand = runChatCommand;
37
+ const client_1 = require("@metrio-ai/client");
38
+ const shared_1 = require("./shared");
39
+ const path = __importStar(require("path"));
40
+ const HELP = `metrio - MetrioAI CLI
41
+
42
+ Commands:
43
+ metrio chat [options] Run a chat completion
44
+ metrio workflow run [options] Run a workflow completion
45
+ (see: metrio workflow --help)
46
+ metrio prompt run-batch Run several prompt cases in one process
47
+ (see: metrio prompt --help)
48
+ metrio prompt create-version Create a prompt version from a file
49
+
50
+ Chat completion usage:
51
+ metrio chat --messages <file> [--prompt-id <id>] [options]
52
+
53
+ Required:
54
+ -m, --messages <file> Path to the messages JSON file
55
+
56
+ Options:
57
+ -p, --prompt-id <id> Prompt ID to run (integer; or set METRIO_PROMPT_ID)
58
+ -s, --system <text> System message prepended as the first message
59
+ -e, --env <file> Env file to load (default: .env in the current dir)
60
+ -t, --tag <tag> Version tag to pin (defaults to active version)
61
+ --var <name=value> Template variable (repeatable)
62
+ --request-tag <tag> Request-level tag recorded on the run, e.g. "test"
63
+ (repeatable; max 5; each 1-32 chars, lowercase
64
+ alphanumeric start, may contain "-" and "_")
65
+ --timezone <tz> IANA timezone, e.g. "Asia/Taipei"
66
+ --text Print only the response text (default: full JSON)
67
+ --stream Stream tokens to stderr live while running
68
+ --api-key <key> API key (overrides env file / environment)
69
+ --base-url <url> Base API URL (overrides env file / environment)
70
+ -h, --help Show this help
71
+
72
+ Environment (read from the env file or the shell; flags take precedence):
73
+ METRIOAI_API_KEY / METRIO_API_KEY / METRIO_PROJECT_API_KEY API key
74
+ METRIO_PROMPT_ID / METRIOAI_PROMPT_ID Prompt ID
75
+ METRIOAI_BASE_URL Base URL
76
+
77
+ Use -e/--env to keep one env file per project (e.g. project-a.env) and run
78
+ the same messages file against different projects for testing.
79
+
80
+ Messages file format (a JSON array):
81
+ [
82
+ { "role": "system", "content": "You are a helpful assistant." },
83
+ { "role": "user", "content": "Describe this image", "file": "./cat.png" },
84
+ { "role": "user", "file": "./report.pdf" }
85
+ ]
86
+
87
+ A message with both "content" and "file" is automatically split into two
88
+ messages (text first, then the file) because the API allows a single content
89
+ block per message.
90
+ `;
91
+ /** Parses argv into structured CLI options. Throws on malformed input. */
92
+ function parseArgs(argv) {
93
+ const options = {
94
+ variables: [],
95
+ tags: [],
96
+ text: false,
97
+ stream: false,
98
+ help: false,
99
+ };
100
+ const next = (i, flag) => {
101
+ const value = argv[i + 1];
102
+ if (value === undefined) {
103
+ throw new Error(`Missing value for ${flag}`);
104
+ }
105
+ return value;
106
+ };
107
+ for (let i = 0; i < argv.length; i++) {
108
+ const arg = argv[i];
109
+ switch (arg) {
110
+ case '-p':
111
+ case '--prompt-id': {
112
+ const raw = next(i++, arg);
113
+ const id = Number(raw);
114
+ if (!Number.isInteger(id) || id <= 0) {
115
+ throw new Error(`--prompt-id must be a positive integer, got "${raw}"`);
116
+ }
117
+ options.promptId = id;
118
+ break;
119
+ }
120
+ case '-m':
121
+ case '--messages':
122
+ options.messagesFile = next(i++, arg);
123
+ break;
124
+ case '-s':
125
+ case '--system':
126
+ options.system = next(i++, arg);
127
+ break;
128
+ case '-e':
129
+ case '--env':
130
+ options.envFile = next(i++, arg);
131
+ break;
132
+ case '-t':
133
+ case '--tag':
134
+ options.tag = next(i++, arg);
135
+ break;
136
+ case '--timezone':
137
+ options.timezone = next(i++, arg);
138
+ break;
139
+ case '--var': {
140
+ const raw = next(i++, arg);
141
+ const eq = raw.indexOf('=');
142
+ if (eq === -1) {
143
+ throw new Error(`--var must be in the form name=value, got "${raw}"`);
144
+ }
145
+ const name = raw.slice(0, eq).trim();
146
+ if (!name) {
147
+ throw new Error(`--var name cannot be empty, got "${raw}"`);
148
+ }
149
+ options.variables.push({ name, value: raw.slice(eq + 1) });
150
+ break;
151
+ }
152
+ case '--request-tag': {
153
+ const tag = next(i++, arg).trim();
154
+ if (!tag) {
155
+ throw new Error('--request-tag cannot be empty');
156
+ }
157
+ options.tags.push(tag);
158
+ break;
159
+ }
160
+ case '--api-key':
161
+ options.apiKey = next(i++, arg);
162
+ break;
163
+ case '--base-url':
164
+ options.baseUrl = next(i++, arg);
165
+ break;
166
+ case '--text':
167
+ options.text = true;
168
+ break;
169
+ case '--stream':
170
+ options.stream = true;
171
+ break;
172
+ case '-h':
173
+ case '--help':
174
+ options.help = true;
175
+ break;
176
+ default:
177
+ throw new Error(`Unknown argument: ${arg}`);
178
+ }
179
+ }
180
+ return options;
181
+ }
182
+ /** The default command: run a chat completion. */
183
+ async function runChatCommand(argv) {
184
+ const options = parseArgs(argv);
185
+ if (options.help) {
186
+ process.stdout.write(HELP);
187
+ return;
188
+ }
189
+ if (!options.messagesFile) {
190
+ throw new Error('Missing required option: --messages');
191
+ }
192
+ (0, shared_1.loadEnv)(options.envFile);
193
+ // Prompt ID may come from the flag or the env file; the flag wins.
194
+ const promptId = (0, shared_1.resolvePromptId)(options.promptId);
195
+ const baseDir = path.dirname(path.resolve(options.messagesFile));
196
+ const messages = (0, shared_1.buildMessages)((0, shared_1.readMessagesFile)(options.messagesFile), baseDir);
197
+ if (messages.length === 0) {
198
+ throw new Error('Messages file cannot be empty');
199
+ }
200
+ // A --system message is prepended as the first message in the conversation.
201
+ if (options.system !== undefined) {
202
+ const systemPrompt = options.system.trim();
203
+ if (systemPrompt === '') {
204
+ throw new Error('--system message cannot be empty');
205
+ }
206
+ messages.unshift({ role: 'system', content: { type: 'text', text: systemPrompt } });
207
+ }
208
+ const client = new client_1.MetrioAI({
209
+ apiKey: (0, shared_1.resolveApiKey)(options.apiKey),
210
+ baseUrl: (0, shared_1.resolveBaseUrl)(options.baseUrl),
211
+ });
212
+ const params = {
213
+ promptId,
214
+ messages,
215
+ tag: options.tag,
216
+ timezone: options.timezone,
217
+ variables: options.variables.length ? options.variables : undefined,
218
+ tags: options.tags.length ? options.tags : undefined,
219
+ stream: options.stream,
220
+ };
221
+ const response = options.stream
222
+ ? await client.chatCompletion(params, (chunk) => {
223
+ if (chunk.chunk)
224
+ process.stderr.write(chunk.chunk);
225
+ })
226
+ : await client.chatCompletion(params);
227
+ if (options.stream)
228
+ process.stderr.write('\n');
229
+ if (options.text) {
230
+ process.stdout.write(`${response.response}\n`);
231
+ }
232
+ else {
233
+ process.stdout.write(`${JSON.stringify(response, null, 2)}\n`);
234
+ }
235
+ }