@metrio-ai/cli 1.0.1 → 1.2.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 CHANGED
@@ -25,14 +25,45 @@ metrio prompt create-version -p 7 -f system.md
25
25
  ```bash
26
26
  metrio login # browser sign-in; token stored in ~/.config/metrio/credentials.json
27
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
28
+ export METRIO_PROJECT=my-project # or --project, or "project: my-project" in metrio.yaml
29
+
30
+ metrio prompt list
31
+ metrio version get 34 -o system.md # the system prompt as a file
32
+ metrio version create 12 -f system.md --model-name gpt-5
33
+ metrio version get 34 -o v.yaml && metrio version update 34 -f v.yaml # round-trip
34
+ metrio skill get 5 -o skill.md # frontmatter (name, description, references) + body
35
+ metrio skill update 5 -f skill.md
36
+ metrio workflow version create intake -f definition.json --activate
37
+ metrio version create-batch -f versions.jsonl
38
+ metrio prompt delete 7 --yes
39
+ metrio run workflow attachment <task-id> <key> -o original.jpg
32
40
  ```
33
41
 
42
+ Every command lists its options with `--help` (`metrio version --help`, `metrio version create --help`). Any input field can be passed as `--<field-name>` (kebab-case); text fields accept `@file` (start with `@@` for a literal `@`), object/array fields accept JSON or `@file`. `-f` takes `.json`, `.yaml`, `.jsonl`, `.md`/`.txt` (text field) or `-` (stdin). Input is validated locally before anything is sent. Output is a table or YAML by default, `--json` for scripts, `-o file` to save (`.json`, `.yaml`, `.md`).
43
+
44
+ `metrio tools` lists every console tool your login can call; `metrio call <tool> -f args.json` calls any of them directly.
45
+
34
46
  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
47
 
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).
48
+ 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 and resource-management commands (`login`, `logout`, `whoami`, `tools`, `call`, and every `metrio <group> ...` command); the run commands (`chat`, `workflow run` / `run-batch`, `prompt run-batch` / `create-version`) keep their original behavior (errors exit 1). A workflow DAG the console rejects exits 2; a partially failed `version create-batch` exits 1.
37
49
 
38
50
  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 …`.
51
+
52
+ ## Sync a project as files
53
+
54
+ ```bash
55
+ mkdir my-project && cd my-project
56
+ metrio pull --project my-project # metrio.yaml, prompts/, skills/, workflows/, .metrio/state.json
57
+ $EDITOR prompts/pick-products/system.md
58
+ metrio diff # modified / remote-changed / conflict / new … with unified diffs
59
+ metrio push -m "tighter wording" # new prompt version (not live)
60
+ metrio push --activate # …or make it live right away
61
+ metrio version list pick-products # prompt directories work wherever a prompt id is expected
62
+ ```
63
+
64
+ `push` creates new versions and never overwrites changes made in the console since your last `pull` (use `--force` deliberately: local wins, bindings and MCP lists are replaced, and resources changed in the console may be pruned). Workflow pushes create draft versions (`--publish`, `--activate`). Deleting files deletes nothing remotely unless you pass `--prune --yes`. Commit `.metrio/state.json` with the files. Run `metrio pull --help` for every option.
65
+
66
+ - A second plain `metrio push` does not create duplicates ("already pushed as version N"); `metrio push --activate` (prompts) or `--publish` / `--activate` (workflows) makes the pending pushed version live.
67
+ - `pull` adopts untracked local files that are identical to the console. `pull` and `push` report per-resource failures, exit 1, and still save state for the resources that succeeded.
68
+ - Renaming a prompt or skill directory is detected by content (status `renamed`); pull/push move the state entry. Workflow directories are named by workflow id and cannot be renamed locally; push refuses it.
69
+ - Known limitation: renaming AND editing a prompt/skill directory in one go looks like a delete plus a new resource (push would create a second one; `--prune --yes` would delete the old one). Rename first, sync, then edit.
@@ -0,0 +1,488 @@
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.SYNC_HELP = void 0;
37
+ exports.parseSyncArgs = parseSyncArgs;
38
+ exports.openWorkspace = openWorkspace;
39
+ exports.filterEntries = filterEntries;
40
+ exports.loadEntries = loadEntries;
41
+ exports.runDiffCommand = runDiffCommand;
42
+ exports.runPullCommand = runPullCommand;
43
+ exports.runPushCommand = runPushCommand;
44
+ const path = __importStar(require("path"));
45
+ const diff_1 = require("diff");
46
+ const client_1 = require("../console/client");
47
+ const errors_1 = require("../errors");
48
+ const push_1 = require("../sync/push");
49
+ const remote_1 = require("../sync/remote");
50
+ const status_1 = require("../sync/status");
51
+ const workspace_1 = require("../sync/workspace");
52
+ const ONLY = { prompts: 'prompt', skills: 'skill', workflows: 'workflow' };
53
+ function parseSyncArgs(argv) {
54
+ const o = {
55
+ paths: [], kinds: ['prompt', 'skill', 'workflow'], force: false, activate: false, publish: false,
56
+ prune: false, yes: false, dryRun: false, json: false,
57
+ };
58
+ for (let i = 0; i < argv.length; i += 1) {
59
+ const arg = argv[i];
60
+ const value = () => {
61
+ const v = argv[i + 1];
62
+ if (v === undefined)
63
+ throw new errors_1.CliError(`${arg} needs a value`);
64
+ i += 1;
65
+ return v;
66
+ };
67
+ if (arg === '--only') {
68
+ const kind = ONLY[value()];
69
+ if (!kind)
70
+ throw new errors_1.CliError('--only must be prompts, skills or workflows');
71
+ o.kinds = [kind];
72
+ }
73
+ else if (arg === '-m' || arg === '--message')
74
+ o.message = value();
75
+ else if (arg === '--project')
76
+ o.project = value();
77
+ else if (arg === '--force')
78
+ o.force = true;
79
+ else if (arg === '--activate')
80
+ o.activate = true;
81
+ else if (arg === '--publish')
82
+ o.publish = true;
83
+ else if (arg === '--prune')
84
+ o.prune = true;
85
+ else if (arg === '--yes')
86
+ o.yes = true;
87
+ else if (arg === '--dry-run')
88
+ o.dryRun = true;
89
+ else if (arg === '--json')
90
+ o.json = true;
91
+ else if (arg.startsWith('-'))
92
+ throw new errors_1.CliError(`Unknown option ${arg}`);
93
+ else
94
+ o.paths.push(arg);
95
+ }
96
+ return o;
97
+ }
98
+ async function openWorkspace() {
99
+ const root = (0, workspace_1.findRoot)();
100
+ if (!root)
101
+ throw new errors_1.CliError('Not in a metrio workspace (no metrio.yaml). Run "metrio pull --project <slug>" first.');
102
+ return { root, project: (0, workspace_1.readProject)(root), client: new client_1.ConsoleClient(await (0, client_1.resolveConsoleAuth)()) };
103
+ }
104
+ /** Entries whose key equals or sits under one of the given paths (relative to the workspace root). */
105
+ function filterEntries(entries, root, paths) {
106
+ if (!paths.length)
107
+ return entries;
108
+ const wanted = paths.map((p) => {
109
+ const rel = path.relative(root, path.resolve(p));
110
+ if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
111
+ throw new errors_1.CliError(`${p} is outside the metrio workspace`);
112
+ }
113
+ return rel.split(path.sep).join('/').replace(/\/+$/, '');
114
+ });
115
+ const matches = (e, w) => w === '' || e.key === w || e.key.startsWith(`${w}/`) || w.startsWith(`${e.key}/`);
116
+ wanted.forEach((w, i) => {
117
+ if (!entries.some((e) => matches(e, w)))
118
+ throw new errors_1.CliError(`${paths[i]} matches nothing in this workspace`);
119
+ });
120
+ return entries.filter((e) => wanted.some((w) => matches(e, w)));
121
+ }
122
+ async function loadEntries(ctx, opts) {
123
+ const state = (0, workspace_1.readState)(ctx.root, ctx.project);
124
+ const remote = await (0, remote_1.fetchRemote)(ctx.client, ctx.project, opts.kinds);
125
+ return filterEntries((0, status_1.computeEntries)(ctx.root, state, remote, opts.kinds), ctx.root, opts.paths);
126
+ }
127
+ /** Console-sourced text must not carry terminal control sequences. */
128
+ function safe(text) {
129
+ return text.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`);
130
+ }
131
+ exports.SYNC_HELP = `metrio pull | diff | push - sync prompts, skills and workflows with local files
132
+
133
+ metrio pull --project <slug> First pull: creates metrio.yaml here and writes:
134
+ prompts/<name>/prompt.yaml + system.md
135
+ skills/<name>.md
136
+ workflows/<id>/workflow.yaml + definition.json
137
+ metrio pull [paths…] Update files from the console (keeps local edits unless --force)
138
+ metrio diff [paths…] Show what differs: modified, remote-changed, conflict, new, …
139
+ metrio push [paths…] Send local edits: new prompt version (not live unless --activate),
140
+ skill update, workflow version (draft; --publish / --activate)
141
+
142
+ Options:
143
+ --only prompts|skills|workflows Limit to one kind
144
+ --force pull: overwrite local edits; push: push over console changes
145
+ (local wins — bindings and MCP list are replaced), allow
146
+ pruning resources changed in the console, and allow a prompt
147
+ version that drops the live messages/agent_description
148
+ -m, --message <text> push: description of the new versions
149
+ --activate push: make new prompt versions head / workflow versions live
150
+ --publish push: publish new workflow versions without activating
151
+ --prune --yes push: delete console resources whose local files were removed
152
+ --dry-run Print the plan without changing anything
153
+ --json diff: machine-readable statuses
154
+
155
+ State lives in .metrio/state.json (commit it with the files).
156
+ `;
157
+ const wantsHelp = (argv) => argv.includes('-h') || argv.includes('--help');
158
+ function patch(e) {
159
+ const local = e.local ?? {};
160
+ const remote = e.remote?.files ?? {};
161
+ return [...new Set([...Object.keys(local), ...Object.keys(remote)])]
162
+ .sort()
163
+ .filter((f) => local[f] !== remote[f])
164
+ .map((f) => (0, diff_1.createTwoFilesPatch)(`remote/${e.key}/${f}`, `local/${e.key}/${f}`, remote[f] ?? '', local[f] ?? ''))
165
+ .join('');
166
+ }
167
+ async function runDiffCommand(argv) {
168
+ if (wantsHelp(argv)) {
169
+ process.stdout.write(exports.SYNC_HELP);
170
+ return;
171
+ }
172
+ const opts = parseSyncArgs(argv);
173
+ const ctx = await openWorkspace();
174
+ const entries = await loadEntries(ctx, opts);
175
+ if (opts.json) {
176
+ const rows = entries.map((e) => ({ key: e.key, kind: e.kind, status: e.status, ...(e.from ? { from: e.from } : {}) }));
177
+ process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`);
178
+ return;
179
+ }
180
+ let unchanged = 0;
181
+ for (const e of entries) {
182
+ if (e.status === 'unchanged') {
183
+ unchanged += 1;
184
+ continue;
185
+ }
186
+ process.stdout.write(safe(`${e.status.padEnd(15)}${e.key}${e.from ? ` (from ${e.from})` : ''}\n`));
187
+ if (e.status === 'modified' || e.status === 'conflict' || e.status === 'remote-changed')
188
+ process.stdout.write(safe(patch(e)));
189
+ }
190
+ process.stdout.write(`${unchanged} unchanged\n`);
191
+ }
192
+ async function runPullCommand(argv) {
193
+ if (wantsHelp(argv)) {
194
+ process.stdout.write(exports.SYNC_HELP);
195
+ return;
196
+ }
197
+ const opts = parseSyncArgs(argv);
198
+ const found = (0, workspace_1.findRoot)();
199
+ let root = found;
200
+ if (!root) {
201
+ if (!opts.project)
202
+ throw new errors_1.CliError('Not in a metrio workspace. Run "metrio pull --project <slug>" to start one here.');
203
+ root = process.cwd(); // metrio.yaml is only created once the console fetch succeeded
204
+ }
205
+ else if (opts.project && opts.project !== (0, workspace_1.readProject)(root)) {
206
+ (0, workspace_1.initRoot)(root, opts.project); // throws: metrio.yaml names another project
207
+ }
208
+ const ctx = { root, project: found ? (0, workspace_1.readProject)(root) : opts.project, client: new client_1.ConsoleClient(await (0, client_1.resolveConsoleAuth)()) };
209
+ // A fresh workspace is locked once metrio.yaml exists (below).
210
+ let release = found && !opts.dryRun ? (0, workspace_1.acquireLock)(root) : undefined;
211
+ try {
212
+ const state = (0, workspace_1.readState)(ctx.root, ctx.project);
213
+ const entries = await loadEntries(ctx, opts);
214
+ if (!found && !opts.dryRun) {
215
+ (0, workspace_1.initRoot)(root, ctx.project);
216
+ release = (0, workspace_1.acquireLock)(root);
217
+ }
218
+ let unchanged = 0;
219
+ let failed = 0;
220
+ const record = (e, files) => {
221
+ const h = (0, workspace_1.hashFiles)(files);
222
+ state.resources[e.key] = { ...(state.resources[e.key] ?? {}), kind: e.kind, id: e.remote.id, base: h, remote: h };
223
+ };
224
+ const take = (e, verb) => {
225
+ if (!opts.dryRun) {
226
+ (0, workspace_1.writeLocal)(ctx.root, e.key, e.kind, e.remote.files);
227
+ record(e, e.remote.files);
228
+ }
229
+ process.stdout.write(safe(`${opts.dryRun ? 'would pull' : verb} ${e.key}\n`));
230
+ };
231
+ for (const e of entries) {
232
+ try {
233
+ switch (e.status) {
234
+ case 'unchanged':
235
+ if (e.refresh && !opts.dryRun)
236
+ record(e, e.remote.files);
237
+ if (e.refresh && !e.state)
238
+ process.stdout.write(safe(`${opts.dryRun ? 'would track' : 'tracked'} ${e.key}\n`));
239
+ else
240
+ unchanged += 1;
241
+ break;
242
+ case 'remote-new':
243
+ case 'remote-changed':
244
+ take(e, 'pulled');
245
+ break;
246
+ case 'deleted':
247
+ take(e, 'restored');
248
+ break;
249
+ case 'modified':
250
+ if (opts.force)
251
+ take(e, 'pulled');
252
+ else
253
+ process.stderr.write(safe(`skipped ${e.key}: local changes (use --force to overwrite)\n`));
254
+ break;
255
+ case 'conflict':
256
+ if (!e.state && e.kind === 'prompt' && e.remote.data.version === null) {
257
+ // A console prompt without a version has nothing to pull: track it as if its empty render
258
+ // had been pulled, so the local files show as edits and push creates the first version.
259
+ if (!opts.dryRun) {
260
+ const h = (0, workspace_1.hashFiles)(e.remote.files);
261
+ state.resources[e.key] = { kind: e.kind, id: e.remote.id, base: h, remote: h };
262
+ }
263
+ process.stdout.write(safe(`${opts.dryRun ? 'would track' : 'tracked'} ${e.key} (the console prompt has no version yet; push to create one)\n`));
264
+ }
265
+ else if (opts.force)
266
+ take(e, 'pulled');
267
+ else
268
+ process.stderr.write(safe(`skipped ${e.key}: local changes (use --force to overwrite)\n`));
269
+ break;
270
+ case 'renamed': {
271
+ const old = state.resources[e.from];
272
+ if (!opts.dryRun) {
273
+ state.resources[e.key] = old;
274
+ delete state.resources[e.from];
275
+ }
276
+ process.stdout.write(safe(`${opts.dryRun ? 'would rename' : 'renamed'} ${e.from} -> ${e.key}\n`));
277
+ if ((0, workspace_1.hashFiles)(e.remote.files) !== old.remote)
278
+ take(e, 'pulled');
279
+ break;
280
+ }
281
+ case 'remote-deleted':
282
+ if (!opts.dryRun)
283
+ delete state.resources[e.key];
284
+ process.stderr.write(safe(`${e.key}: deleted in the console; local files kept (push to recreate, or delete them)\n`));
285
+ break;
286
+ case 'new':
287
+ break;
288
+ }
289
+ }
290
+ catch (error) {
291
+ failed += 1;
292
+ process.stderr.write(safe(`failed ${e.key}: ${error instanceof Error ? error.message : String(error)}\n`));
293
+ }
294
+ }
295
+ if (!opts.dryRun)
296
+ (0, workspace_1.writeState)(ctx.root, state);
297
+ process.stdout.write(`${unchanged} unchanged\n`);
298
+ if (failed > 0)
299
+ throw new errors_1.CliError(`${failed} resources failed`, 1);
300
+ }
301
+ finally {
302
+ release?.();
303
+ }
304
+ }
305
+ function remoteContent(r) {
306
+ try {
307
+ return (0, push_1.versionContent)(r.kind, r.files, r.kind);
308
+ }
309
+ catch {
310
+ return undefined;
311
+ }
312
+ }
313
+ const remoteMoved = (e) => (0, workspace_1.hashFiles)(e.remote.files) !== e.state.remote;
314
+ function planPush(entries, opts, options) {
315
+ const actions = [];
316
+ for (const entry of entries) {
317
+ switch (entry.status) {
318
+ case 'unchanged':
319
+ if ((0, push_1.hasPendingVersion)(entry, options))
320
+ actions.push({ entry, op: 'update' });
321
+ break;
322
+ case 'modified':
323
+ actions.push({ entry, op: 'update' });
324
+ break;
325
+ case 'new':
326
+ actions.push({ entry, op: 'create' });
327
+ break;
328
+ case 'renamed':
329
+ if (entry.kind === 'workflow') {
330
+ actions.push({ entry, skip: 'workflow directories are named by workflow_id; rename it in the console instead', failure: true });
331
+ }
332
+ else if (remoteMoved(entry))
333
+ actions.push({ entry, skip: 'remote changed; run "metrio pull" first', failure: true });
334
+ else
335
+ actions.push({ entry, op: 'rename' });
336
+ break;
337
+ case 'conflict':
338
+ if (!entry.state) {
339
+ const reason = `exists in the console but is not tracked here. "metrio pull --force ${entry.key}" would OVERWRITE your local files; ` +
340
+ 'to keep them, rename the local directory (it will be pushed as new) or delete the console copy first';
341
+ actions.push({ entry, skip: reason, failure: true });
342
+ }
343
+ else if (opts.force)
344
+ actions.push({ entry, op: 'update' });
345
+ else
346
+ actions.push({ entry, skip: 'conflict: changed locally and in the console (pull, merge, then push; or --force)', failure: true });
347
+ break;
348
+ case 'remote-changed':
349
+ actions.push({ entry, skip: 'remote changed; run "metrio pull" first', failure: true });
350
+ break;
351
+ case 'remote-deleted':
352
+ actions.push({ entry, skip: 'deleted in the console; run "metrio pull" to drop it, then push to recreate', failure: true });
353
+ break;
354
+ case 'deleted':
355
+ if (opts.prune && opts.yes) {
356
+ if (remoteMoved(entry) && !opts.force) {
357
+ actions.push({ entry, skip: 'changed in the console since your last pull; use --force to delete anyway', failure: true });
358
+ }
359
+ else
360
+ actions.push({ entry, op: 'delete' });
361
+ }
362
+ else if (opts.prune)
363
+ actions.push({ entry, skip: 'use --prune --yes to delete it in the console', failure: true });
364
+ else
365
+ actions.push({ entry, skip: 'deleted locally (use --prune --yes to delete it in the console)', failure: false });
366
+ break;
367
+ default:
368
+ break;
369
+ }
370
+ }
371
+ return sortForExecution(actions);
372
+ }
373
+ const KIND_RANK = { skill: 0, prompt: 1, workflow: 2 };
374
+ const DELETE_RANK = { prompt: 3, workflow: 3, skill: 4 };
375
+ /** Skills first (prompts may name new ones), then prompts, workflows; deletes last, skills after what uses them. */
376
+ function sortForExecution(actions) {
377
+ const rank = (a) => ('op' in a && a.op === 'delete' ? DELETE_RANK : KIND_RANK)[a.entry.kind];
378
+ return [...actions].sort((a, b) => rank(a) - rank(b));
379
+ }
380
+ const PAST = { create: 'created', update: 'updated', delete: 'deleted', rename: 'renamed' };
381
+ const describeOp = (op, e) => (op === 'rename' ? `${e.from} -> ${e.key}` : e.key);
382
+ async function runPushCommand(argv) {
383
+ if (wantsHelp(argv)) {
384
+ process.stdout.write(exports.SYNC_HELP);
385
+ return;
386
+ }
387
+ const opts = parseSyncArgs(argv);
388
+ const ctx = await openWorkspace();
389
+ const release = opts.dryRun ? undefined : (0, workspace_1.acquireLock)(ctx.root);
390
+ try {
391
+ const state = (0, workspace_1.readState)(ctx.root, ctx.project);
392
+ const options = { activate: opts.activate, publish: opts.publish, message: opts.message, force: opts.force };
393
+ const entries = await loadEntries(ctx, opts);
394
+ const actions = planPush(entries, opts, options);
395
+ if (opts.dryRun) {
396
+ for (const a of actions) {
397
+ process.stdout.write(safe('op' in a ? `would ${a.op} ${describeOp(a.op, a.entry)}\n` : `skip ${a.entry.key}: ${a.skip}\n`));
398
+ }
399
+ return;
400
+ }
401
+ const pushCtx = { client: ctx.client, project: ctx.project, options };
402
+ const done = [];
403
+ let failed = 0;
404
+ for (const a of actions) {
405
+ if (!('op' in a)) {
406
+ if (a.failure) {
407
+ failed += 1;
408
+ process.stderr.write(safe(`skip ${a.entry.key}: ${a.skip}\n`));
409
+ }
410
+ else
411
+ process.stderr.write(safe(`${a.entry.key}: ${a.skip}\n`));
412
+ continue;
413
+ }
414
+ try {
415
+ let result;
416
+ if (a.op === 'delete')
417
+ await (0, push_1.deleteEntry)(pushCtx, a.entry);
418
+ else if (a.op !== 'rename')
419
+ result = await (0, push_1.pushEntry)(pushCtx, a.entry);
420
+ done.push({ entry: a.entry, op: a.op, result });
421
+ process.stdout.write(safe(`${PAST[a.op]} ${describeOp(a.op, a.entry)}${result ? ` (${result.summary})` : ''}\n`));
422
+ }
423
+ catch (error) {
424
+ failed += 1;
425
+ process.stderr.write(safe(`failed ${a.entry.key}: ${error instanceof Error ? error.message : String(error)}\n`));
426
+ }
427
+ }
428
+ // Converged resources (same content on both sides, stale state): record them as synced.
429
+ const converged = entries.filter((e) => e.refresh && !done.some((d) => d.entry === e));
430
+ for (const e of converged) {
431
+ const h = (0, workspace_1.hashFiles)(e.local);
432
+ state.resources[e.key] = { ...(state.resources[e.key] ?? {}), kind: e.kind, id: e.remote.id, base: h, remote: h };
433
+ }
434
+ if (converged.length && !done.length)
435
+ (0, workspace_1.writeState)(ctx.root, state);
436
+ let refetchFailed = false;
437
+ if (done.length) {
438
+ const pushed = done.filter((d) => d.result);
439
+ let refreshed;
440
+ if (pushed.length) {
441
+ try {
442
+ refreshed = await (0, remote_1.fetchRemote)(ctx.client, ctx.project, [...new Set(pushed.map((d) => d.entry.kind))]);
443
+ }
444
+ catch (error) {
445
+ refetchFailed = true;
446
+ process.stderr.write(safe(`could not re-read the console after pushing: ${error instanceof Error ? error.message : String(error)}; run "metrio diff" to check\n`));
447
+ }
448
+ }
449
+ for (const d of done) {
450
+ const key = d.entry.key;
451
+ if (d.op === 'delete') {
452
+ delete state.resources[key];
453
+ }
454
+ else if (d.op === 'rename') {
455
+ state.resources[key] = state.resources[d.entry.from];
456
+ delete state.resources[d.entry.from];
457
+ }
458
+ else {
459
+ const r = d.result;
460
+ const previous = state.resources[key];
461
+ const remote = refreshed?.find((x) => x.kind === d.entry.kind && x.id === r.id);
462
+ state.resources[key] = {
463
+ ...(previous ?? {}),
464
+ kind: d.entry.kind,
465
+ id: r.id,
466
+ base: (0, workspace_1.hashFiles)(d.entry.local),
467
+ // Without a re-read, keep the last known remote hash: the next diff then shows what really changed.
468
+ remote: refreshed ? (remote ? (0, workspace_1.hashFiles)(remote.files) : '') : (previous?.remote ?? ''),
469
+ ...(r.pushed_version_id !== undefined ? { pushed_version_id: r.pushed_version_id, pushed_serial: r.pushed_serial } : {}),
470
+ ...(r.pushed_workflow_version !== undefined ? { pushed_workflow_version: r.pushed_workflow_version } : {}),
471
+ ...(r.pushed_content !== undefined ? { pushed_content: r.pushed_content } : {}),
472
+ // What the console made of a new/activated version (defaults filled, prompt_refs pinned): see push.ts.
473
+ ...(refreshed && r.versioned ? { remote_content: remote ? remoteContent(remote) : undefined } : {}),
474
+ };
475
+ }
476
+ }
477
+ (0, workspace_1.writeState)(ctx.root, state);
478
+ }
479
+ process.stdout.write(`${done.length} pushed, ${failed} failed\n`);
480
+ if (failed)
481
+ throw new errors_1.CliError(`${failed} of ${done.length + failed} resources failed`, 1);
482
+ if (refetchFailed)
483
+ throw new errors_1.CliError('pushed, but could not re-read the console; run "metrio diff" to check', 1);
484
+ }
485
+ finally {
486
+ release?.();
487
+ }
488
+ }
@@ -63,14 +63,32 @@ async function resolveConsoleAuth() {
63
63
  }
64
64
  class ConsoleClient {
65
65
  auth;
66
- constructor(auth) {
66
+ retryDelayMs;
67
+ constructor(auth, retryDelayMs = 300) {
67
68
  this.auth = auth;
69
+ this.retryDelayMs = retryDelayMs;
68
70
  }
69
71
  async listTools() {
70
72
  return (await this.request('GET', '/api/v1/tools'));
71
73
  }
72
- async callTool(name, args) {
73
- return this.request('POST', `/api/v1/tools/${encodeURIComponent(name)}`, args ?? {});
74
+ /**
75
+ * POST /api/v1/tools/{name}. With `retry` (read-scope tools only), network errors and 5xx are
76
+ * retried up to 3 attempts with exponential backoff; writes are never retried because the first
77
+ * attempt may have succeeded server-side.
78
+ */
79
+ async callTool(name, args, opts = {}) {
80
+ const attempts = opts.retry ? 3 : 1;
81
+ for (let attempt = 0;; attempt += 1) {
82
+ try {
83
+ return await this.request('POST', `/api/v1/tools/${encodeURIComponent(name)}`, args ?? {});
84
+ }
85
+ catch (err) {
86
+ const retryable = err instanceof errors_1.ConsoleApiError ? err.status >= 500 : !(err instanceof errors_1.CliError);
87
+ if (!retryable || attempt + 1 >= attempts)
88
+ throw err;
89
+ await new Promise((resolve) => setTimeout(resolve, this.retryDelayMs * 3 ** attempt));
90
+ }
91
+ }
74
92
  }
75
93
  /** GET a console endpoint that answers with a bare JSON body (e.g. /api/oauth/userinfo). */
76
94
  async getJson(path) {
package/dist/index.js CHANGED
@@ -18,6 +18,9 @@ const chat_1 = require("./commands/chat");
18
18
  const prompt_1 = require("./commands/prompt");
19
19
  const workflow_1 = require("./commands/workflow");
20
20
  const tools_1 = require("./commands/tools");
21
+ const run_1 = require("./manage/run");
22
+ const sync_1 = require("./commands/sync");
23
+ const registry_1 = require("./manage/registry");
21
24
  exports.HELP = `metrio - MetrioAI CLI
22
25
 
23
26
  Usage: metrio <command> [options]
@@ -34,13 +37,34 @@ Account (console OAuth):
34
37
  logout Revoke and delete the stored login
35
38
  whoami Show the signed-in user and scopes
36
39
 
37
- Manage (console OAuth):
40
+ Manage (console OAuth; run "metrio <group> --help"):
41
+ project | prompt | version | skill | workflow | schedule | mcp | run | admin | model-tiers
42
+ e.g. metrio version create 12 -f system.md
43
+ metrio skill get 5 -o skill.md
44
+ pull | diff | push Sync prompts, skills and workflows with local files ("metrio pull --help")
38
45
  tools List the console tools your login can call
39
- call <tool> Call a console tool with JSON args (-f file | --args json)
46
+ call <tool> Call any console tool with JSON args (-f file | --args json)
40
47
 
41
48
  -h, --help Show this help
42
49
  -v, --version Show the CLI version
43
50
  `;
51
+ /** Actions of `prompt` / `workflow` that belong to the original execution commands. */
52
+ const EXECUTION_ACTIONS = {
53
+ prompt: ['run-batch', 'create-version'],
54
+ workflow: ['run', 'run-batch'],
55
+ };
56
+ async function runMixedGroup(group, argv) {
57
+ const action = argv[1];
58
+ const runExecution = group === 'prompt' ? prompt_1.runPromptCommand : workflow_1.runWorkflowCommand;
59
+ if (action !== undefined && EXECUTION_ACTIONS[group].includes(action))
60
+ return runExecution(argv.slice(1));
61
+ if (action === undefined || action === '-h' || action === '--help') {
62
+ await runExecution(['--help']);
63
+ process.stdout.write(`\n${(0, registry_1.groupHelp)(group)}`);
64
+ return;
65
+ }
66
+ return (0, run_1.runManageCommand)(argv);
67
+ }
44
68
  async function main(argv) {
45
69
  const [command] = argv;
46
70
  switch (command) {
@@ -56,9 +80,15 @@ async function main(argv) {
56
80
  case 'chat':
57
81
  return (0, chat_1.runChatCommand)(argv.slice(1));
58
82
  case 'workflow':
59
- return (0, workflow_1.runWorkflowCommand)(argv.slice(1));
83
+ return runMixedGroup('workflow', argv);
60
84
  case 'prompt':
61
- return (0, prompt_1.runPromptCommand)(argv.slice(1));
85
+ return runMixedGroup('prompt', argv);
86
+ case 'pull':
87
+ return (0, sync_1.runPullCommand)(argv.slice(1));
88
+ case 'diff':
89
+ return (0, sync_1.runDiffCommand)(argv.slice(1));
90
+ case 'push':
91
+ return (0, sync_1.runPushCommand)(argv.slice(1));
62
92
  case 'login':
63
93
  return (0, auth_1.runLoginCommand)(argv.slice(1));
64
94
  case 'logout':
@@ -70,6 +100,8 @@ async function main(argv) {
70
100
  case 'call':
71
101
  return (0, tools_1.runCallCommand)(argv.slice(1));
72
102
  default:
103
+ if (registry_1.MANAGE_GROUPS.includes(command))
104
+ return (0, run_1.runManageCommand)(argv);
73
105
  if (command.startsWith('-')) {
74
106
  process.stderr.write('metrio: running chat without a subcommand is deprecated; use "metrio chat".\n');
75
107
  return (0, chat_1.runChatCommand)(argv);