@metrio-ai/cli 1.3.1 → 1.5.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
@@ -31,6 +31,8 @@ metrio prompt list
31
31
  metrio version get 34 -o system.md # the system prompt as a file
32
32
  metrio version create 12 -f system.md --model-provider openai --model-name gpt-5
33
33
  metrio version get 34 -o v.yaml && metrio version update 34 -f v.yaml # round-trip
34
+ metrio version get 34 -o greeter.md --frontmatter # settings as YAML frontmatter + the prompt verbatim
35
+ metrio version create 12 -f greeter.md --frontmatter # one file carries the whole version
34
36
  metrio skill get 5 -o skill.md # frontmatter (name, description, references) + body
35
37
  metrio skill update 5 -f skill.md
36
38
  metrio workflow version create intake -f definition.json --activate
@@ -68,7 +70,9 @@ metrio version list pick-products # prompt directories work wherever a promp
68
70
  - 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
71
  - 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.
70
72
 
71
- ## Verify prompts and workflows
73
+ Metrio agents sync too: `agents/<dir>/agent.yaml` (entry prompt and version, sub-agents with version pins, tool policies), `--only agents`, with a sub-agent pin bumped automatically when its entry prompt gets a new head version (`push --activate`, or a new prompt's first version; `--no-bump-pins` to opt out). Manage them with `metrio agent ...`. This needs a console that has the agent tools (metrioai-console IMP-3). Details: `docs/cli.md` in the repository.
74
+
75
+ ## Verify prompts, workflows and agents
72
76
 
73
77
  Put a `cases.yaml` next to a synced resource (`prompts/<name>/cases.yaml`, `workflows/<id>/cases.yaml`) and run it with the project API key (`METRIOAI_API_KEY` in `.env`, or `--api-key`; it must belong to the workspace's project):
74
78
 
@@ -93,4 +97,6 @@ metrio push --activate # make it live once the cases pass
93
97
  metrio verify -p 12 --cases cases.yaml --junit report.xml # outside a workspace
94
98
  ```
95
99
 
100
+ Agents work the same way (`agents/<dir>/cases.yaml` or `-a <id|dir>`), with the extra assertion `stop_reason` (`final`, `terminal_tool`, `max_turns`, `interrupted`, `cancelled`); they always run live.
101
+
96
102
  Every `cases.yaml` is validated before the first request (a typo in an assertion is exit 2). A failed case prints each failing assertion with expected vs actual and exits 1. `--json` prints machine-readable results, `--junit <file>` writes JUnit XML, `--fail-fast` stops starting new cases after the first failure. Run `metrio verify --help` for the full format.
@@ -47,14 +47,15 @@ const diff_1 = require("diff");
47
47
  const client_1 = require("../console/client");
48
48
  const errors_1 = require("../errors");
49
49
  const push_1 = require("../sync/push");
50
+ const push_agents_1 = require("../sync/push-agents");
50
51
  const remote_1 = require("../sync/remote");
51
52
  const status_1 = require("../sync/status");
52
53
  const workspace_1 = require("../sync/workspace");
53
- const ONLY = { prompts: 'prompt', skills: 'skill', workflows: 'workflow' };
54
+ const ONLY = { prompts: 'prompt', skills: 'skill', workflows: 'workflow', agents: 'agent' };
54
55
  function parseSyncArgs(argv) {
55
56
  const o = {
56
- paths: [], kinds: ['prompt', 'skill', 'workflow'], force: false, activate: false, publish: false,
57
- prune: false, yes: false, dryRun: false, json: false,
57
+ paths: [], kinds: ['prompt', 'skill', 'workflow', 'agent'], force: false, activate: false, publish: false,
58
+ prune: false, yes: false, bumpPins: true, dryRun: false, json: false,
58
59
  };
59
60
  for (let i = 0; i < argv.length; i += 1) {
60
61
  const arg = argv[i];
@@ -68,7 +69,7 @@ function parseSyncArgs(argv) {
68
69
  if (arg === '--only') {
69
70
  const kind = ONLY[value()];
70
71
  if (!kind)
71
- throw new errors_1.CliError('--only must be prompts, skills or workflows');
72
+ throw new errors_1.CliError('--only must be prompts, skills, workflows or agents');
72
73
  o.kinds = [kind];
73
74
  }
74
75
  else if (arg === '-m' || arg === '--message')
@@ -85,6 +86,8 @@ function parseSyncArgs(argv) {
85
86
  o.prune = true;
86
87
  else if (arg === '--yes')
87
88
  o.yes = true;
89
+ else if (arg === '--no-bump-pins')
90
+ o.bumpPins = false;
88
91
  else if (arg === '--dry-run')
89
92
  o.dryRun = true;
90
93
  else if (arg === '--json')
@@ -123,25 +126,37 @@ function filterEntries(entries, root, paths) {
123
126
  async function loadEntries(ctx, opts) {
124
127
  const state = (0, workspace_1.readState)(ctx.root, ctx.project);
125
128
  const remote = await (0, remote_1.fetchRemote)(ctx.client, ctx.project, opts.kinds);
126
- return filterEntries((0, status_1.computeEntries)(ctx.root, state, remote, opts.kinds), ctx.root, opts.paths);
129
+ if (remote.agentsUnavailable) {
130
+ process.stderr.write('agents skipped: this console has no agent tools (list_agents)\n');
131
+ opts.kinds = opts.kinds.filter((k) => k !== 'agent'); // later re-reads in this run leave agents out too
132
+ }
133
+ for (const a of remote.skippedAgents) {
134
+ process.stderr.write(safe(`agent ${a.id} "${a.name}" skipped: its entry prompt was deleted (fix it with "metrio agent update")\n`));
135
+ }
136
+ // A tracked agent left out is kept as it is, not shown as deleted in the console.
137
+ const skipped = new Set(remote.skippedAgents.map((a) => a.id));
138
+ const entries = (0, status_1.computeEntries)(ctx.root, state, remote.resources, opts.kinds).filter((e) => !(e.kind === 'agent' && e.state && skipped.has(e.state.id)));
139
+ return filterEntries(entries, ctx.root, opts.paths);
127
140
  }
128
141
  /** Console-sourced text must not carry terminal control sequences. */
129
142
  function safe(text) {
130
143
  return text.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`);
131
144
  }
132
- exports.SYNC_HELP = `metrio pull | diff | push - sync prompts, skills and workflows with local files
145
+ exports.SYNC_HELP = `metrio pull | diff | push - sync prompts, skills, workflows and agents with local files
133
146
 
134
147
  metrio pull --project <slug> First pull: creates metrio.yaml here and writes:
135
148
  prompts/<name>/prompt.yaml + system.md
136
149
  skills/<name>.md
137
150
  workflows/<id>/workflow.yaml + definition.json
151
+ agents/<name>/agent.yaml
138
152
  metrio pull [paths…] Update files from the console (keeps local edits unless --force)
139
153
  metrio diff [paths…] Show what differs: modified, remote-changed, conflict, new, …
140
154
  metrio push [paths…] Send local edits: new prompt version (not live unless --activate),
141
- skill update, workflow version (draft; --publish / --activate)
155
+ skill update, workflow version (draft; --publish / --activate),
156
+ agent create/update (sub-agents before their parents)
142
157
 
143
158
  Options:
144
- --only prompts|skills|workflows Limit to one kind
159
+ --only prompts|skills|workflows|agents Limit to one kind
145
160
  --force pull: overwrite local edits; push: push over console changes
146
161
  (local wins — bindings and MCP list are replaced), allow
147
162
  pruning resources changed in the console, and allow a prompt
@@ -150,6 +165,9 @@ Options:
150
165
  --activate push: make new prompt versions head / workflow versions live
151
166
  --publish push: publish new workflow versions without activating
152
167
  --prune --yes push: delete console resources whose local files were removed
168
+ --no-bump-pins push: keep sub-agent pins; by default a pin moves to the head
169
+ version this push creates for the sub-agent's entry prompt
170
+ (--activate, or a new prompt's first version)
153
171
  --dry-run Print the plan without changing anything
154
172
  --json diff: machine-readable statuses
155
173
 
@@ -312,7 +330,7 @@ function remoteContent(r) {
312
330
  }
313
331
  }
314
332
  const remoteMoved = (e) => (0, workspace_1.hashFiles)(e.remote.files) !== e.state.remote;
315
- function planPush(entries, opts, options) {
333
+ function planPush(entries, state, opts, options) {
316
334
  const actions = [];
317
335
  for (const entry of entries) {
318
336
  switch (entry.status) {
@@ -369,14 +387,47 @@ function planPush(entries, opts, options) {
369
387
  break;
370
388
  }
371
389
  }
372
- return sortForExecution(actions);
390
+ const withOp = (op) => actions.flatMap((a) => ('op' in a && a.op === op ? [a.entry] : []));
391
+ (0, push_agents_1.checkAgentRefs)(entries.filter((e) => e.kind === 'agent' && e.local), new Set([...withOp('create'), ...withOp('rename')].map((e) => e.key)), withOp('delete'), withOp('rename'), entries.filter((e) => e.status === 'remote-deleted'), state);
392
+ return sortForExecution(actions, entries);
373
393
  }
374
- const KIND_RANK = { skill: 0, prompt: 1, workflow: 2 };
375
- const DELETE_RANK = { prompt: 3, workflow: 3, skill: 4 };
376
- /** Skills first (prompts may name new ones), then prompts, workflows; deletes last, skills after what uses them. */
377
- function sortForExecution(actions) {
394
+ const isAgentWrite = (a) => a.entry.kind === 'agent' && 'op' in a && (a.op === 'create' || a.op === 'update');
395
+ const isAgentDelete = (a) => a.entry.kind === 'agent' && 'op' in a && a.op === 'delete';
396
+ const KIND_RANK = { skill: 0, prompt: 1, workflow: 2, agent: 3 };
397
+ const DELETE_RANK = { agent: 4, prompt: 5, workflow: 5, skill: 6 };
398
+ /**
399
+ * Skills first (prompts may name new ones), then prompts, workflows, and agents (sub-agents before
400
+ * their parents; a local cycle fails before any write). Deletes come after every create/update:
401
+ * agents first (parents before children, so a parent drops a link before its child goes), then
402
+ * prompts and workflows, skills after what uses them.
403
+ */
404
+ function sortForExecution(actions, entries) {
378
405
  const rank = (a) => ('op' in a && a.op === 'delete' ? DELETE_RANK : KIND_RANK)[a.entry.kind];
379
- return [...actions].sort((a, b) => rank(a) - rank(b));
406
+ const sorted = [...actions].sort((a, b) => rank(a) - rank(b));
407
+ const written = new Set(sorted.filter(isAgentWrite).map((a) => a.entry.key));
408
+ const writeOrder = (0, push_agents_1.agentWriteOrder)(entries, written);
409
+ const deleteOrder = (0, push_agents_1.agentDeleteOrder)(sorted.filter(isAgentDelete).map((a) => a.entry), entries);
410
+ const reorder = (list, pick, order) => {
411
+ const picked = list.filter(pick).sort((a, b) => order.indexOf(a.entry.key) - order.indexOf(b.entry.key));
412
+ let i = 0;
413
+ return list.map((a) => (pick(a) ? picked[i++] : a));
414
+ };
415
+ return reorder(reorder(sorted, isAgentWrite, writeOrder), isAgentDelete, deleteOrder);
416
+ }
417
+ /** The keys and ids state will hold once `done` is recorded (what agent references render against). */
418
+ function stateAfter(state, done) {
419
+ const resources = { ...state.resources };
420
+ for (const d of done) {
421
+ if (d.op === 'delete')
422
+ delete resources[d.entry.key];
423
+ else if (d.op === 'rename') {
424
+ resources[d.entry.key] = resources[d.entry.from];
425
+ delete resources[d.entry.from];
426
+ }
427
+ else
428
+ resources[d.entry.key] = { ...(resources[d.entry.key] ?? {}), kind: d.entry.kind, id: d.result.id };
429
+ }
430
+ return { ...state, resources };
380
431
  }
381
432
  const PAST = { create: 'created', update: 'updated', delete: 'deleted', rename: 'renamed' };
382
433
  const describeOp = (op, e) => (op === 'rename' ? `${e.from} -> ${e.key}` : e.key);
@@ -392,14 +443,21 @@ async function runPushCommand(argv) {
392
443
  const state = (0, workspace_1.readState)(ctx.root, ctx.project);
393
444
  const options = { activate: opts.activate, publish: opts.publish, message: opts.message, force: opts.force };
394
445
  const entries = await loadEntries(ctx, opts);
395
- const actions = planPush(entries, opts, options);
446
+ const actions = planPush(entries, state, opts, options);
396
447
  if (opts.dryRun) {
397
448
  for (const a of actions) {
398
449
  process.stdout.write(safe('op' in a ? `would ${a.op} ${describeOp(a.op, a.entry)}\n` : `skip ${a.entry.key}: ${a.skip}\n`));
399
450
  }
400
451
  return;
401
452
  }
402
- const pushCtx = { client: ctx.client, project: ctx.project, options };
453
+ const agents = {
454
+ root: ctx.root,
455
+ ids: new Map(Object.entries(state.resources).map(([key, s]) => [key, s.id])),
456
+ promptSerials: new Map(),
457
+ entries,
458
+ bumpPins: opts.bumpPins,
459
+ };
460
+ const pushCtx = { client: ctx.client, project: ctx.project, options, agents };
403
461
  const done = [];
404
462
  let failed = 0;
405
463
  for (const a of actions) {
@@ -418,6 +476,15 @@ async function runPushCommand(argv) {
418
476
  await (0, push_1.deleteEntry)(pushCtx, a.entry);
419
477
  else if (a.op !== 'rename')
420
478
  result = await (0, push_1.pushEntry)(pushCtx, a.entry);
479
+ // Later agents resolve references to what this push created or renamed.
480
+ if (result)
481
+ agents.ids.set(a.entry.key, result.id);
482
+ if (a.op === 'rename' && state.resources[a.entry.from])
483
+ agents.ids.set(a.entry.key, state.resources[a.entry.from].id);
484
+ // Only a version that went live moves pins: a non-head one is still to be verified.
485
+ if (a.entry.kind === 'prompt' && result?.pushed_head && result.pushed_serial !== undefined) {
486
+ agents.promptSerials.set(result.id, result.pushed_serial);
487
+ }
421
488
  done.push({ entry: a.entry, op: a.op, result });
422
489
  process.stdout.write(safe(`${PAST[a.op]} ${describeOp(a.op, a.entry)}${result ? ` (${result.summary})` : ''}\n`));
423
490
  }
@@ -426,6 +493,21 @@ async function runPushCommand(argv) {
426
493
  process.stderr.write(safe(`failed ${a.entry.key}: ${error instanceof Error ? error.message : String(error)}\n`));
427
494
  }
428
495
  }
496
+ // Unchanged agents whose sub-agent's entry prompt got a new version: move their pins.
497
+ try {
498
+ await (0, push_agents_1.bumpUnchangedPins)(pushCtx, entries.filter((e) => !done.some((d) => d.entry === e)), (entry, result) => {
499
+ done.push({ entry, op: 'update', result });
500
+ process.stdout.write(safe(`updated ${entry.key} (${result.summary})\n`));
501
+ });
502
+ }
503
+ catch (error) {
504
+ failed += 1;
505
+ process.stderr.write(safe(`failed: ${error instanceof Error ? error.message : String(error)}\n`));
506
+ }
507
+ const unbumped = (0, push_agents_1.parentsOutOfScope)(agents);
508
+ if (unbumped.length) {
509
+ process.stderr.write(safe(`sub-agent pins not bumped for ${unbumped.join(', ')}: not in this push (run "metrio push" or "metrio agent bump-pins")\n`));
510
+ }
429
511
  // Converged resources (same content on both sides, stale state): record them as synced.
430
512
  const converged = entries.filter((e) => e.refresh && !done.some((d) => d.entry === e));
431
513
  for (const e of converged) {
@@ -439,8 +521,11 @@ async function runPushCommand(argv) {
439
521
  const pushed = done.filter((d) => d.result);
440
522
  let refreshed;
441
523
  if (pushed.length) {
524
+ // Agent files reference other resources by directory: re-read every loaded kind to render them as diff will.
525
+ const kinds = pushed.some((d) => d.entry.kind === 'agent') ? opts.kinds : [...new Set(pushed.map((d) => d.entry.kind))];
442
526
  try {
443
- refreshed = await (0, remote_1.fetchRemote)(ctx.client, ctx.project, [...new Set(pushed.map((d) => d.entry.kind))]);
527
+ refreshed = (await (0, remote_1.fetchRemote)(ctx.client, ctx.project, kinds)).resources;
528
+ (0, status_1.renderRemoteAgents)(stateAfter(state, done), refreshed, opts.kinds);
444
529
  }
445
530
  catch (error) {
446
531
  refetchFailed = true;
@@ -464,7 +549,7 @@ async function runPushCommand(argv) {
464
549
  ...(previous ?? {}),
465
550
  kind: d.entry.kind,
466
551
  id: r.id,
467
- base: (0, workspace_1.hashFiles)(d.entry.local),
552
+ base: (0, workspace_1.hashFiles)(r.files ?? d.entry.local),
468
553
  // Without a re-read, keep the last known remote hash: the next diff then shows what really changed.
469
554
  remote: refreshed ? (remote ? (0, workspace_1.hashFiles)(remote.files) : '') : (previous?.remote ?? ''),
470
555
  ...(r.pushed_version_id !== undefined ? { pushed_version_id: r.pushed_version_id, pushed_serial: r.pushed_serial } : {}),
@@ -38,7 +38,7 @@ exports.parseVerifyArgs = parseVerifyArgs;
38
38
  exports.resolveTargets = resolveTargets;
39
39
  exports.runVerifyCommand = runVerifyCommand;
40
40
  /**
41
- * `metrio verify` — runs cases.yaml against prompts and workflows and checks every reply.
41
+ * `metrio verify` — runs cases.yaml against prompts, workflows and Metrio agents and checks every reply.
42
42
  * Execution uses the project API key, like `chat` and `run-batch`. Targets come from the
43
43
  * workspace (.metrio/state.json) or from -p / -w with --cases. Every cases.yaml is
44
44
  * validated before the first request.
@@ -56,10 +56,13 @@ const report_1 = require("../verify/report");
56
56
  exports.VERIFY_HELP = `metrio verify - run cases.yaml and check every reply
57
57
 
58
58
  Usage:
59
- metrio verify [paths…] [options] Workspace prompts/<name>/cases.yaml and
60
- workflows/<id>/cases.yaml
59
+ metrio verify [paths…] [options] Workspace prompts/<name>/cases.yaml,
60
+ workflows/<id>/cases.yaml and
61
+ agents/<name>/cases.yaml
61
62
  metrio verify -p <prompt-id> --cases <file> One prompt, any cases file
62
63
  metrio verify -w <workflow-id> --cases <file> One workflow, any cases file
64
+ metrio verify -a <id|dir> --cases <file> One Metrio agent (external id, or an agents/<dir>
65
+ name from .metrio/state.json), any cases file
63
66
 
64
67
  In a workspace (see "metrio pull --help") each cases.yaml runs against the resource
65
68
  recorded in .metrio/state.json. Paths narrow the run: a resource directory, prompts/,
@@ -68,7 +71,8 @@ workflows/ or the root.
68
71
  Options:
69
72
  --version active|pushed|<n> active (default): the live version; pushed: the version
70
73
  the last "metrio push" created; <n>: prompt version serial
71
- or workflow version
74
+ or workflow version. Agents always run live (also for
75
+ pushed); a number is rejected for them
72
76
  (a number needs a single prompt or workflow path)
73
77
  --concurrency <n> Cases in flight (default 4)
74
78
  --json Print results as JSON instead of the text report
@@ -99,6 +103,8 @@ cases.yaml:
99
103
  max_latency_ms: 8000
100
104
  max_tokens: 4000 # input + output
101
105
  max_llm_calls: 3 # workflows
106
+ stop_reason: final # agents: final | terminal_tool | max_turns |
107
+ # interrupted | cancelled
102
108
 
103
109
  Agent loop: pull → edit → push → verify --version pushed → push --activate
104
110
 
@@ -151,6 +157,14 @@ function parseVerifyArgs(argv) {
151
157
  o.workflowId = v;
152
158
  break;
153
159
  }
160
+ case '-a':
161
+ case '--agent-id': {
162
+ const v = value().trim();
163
+ if (!v)
164
+ throw new errors_1.CliError(`${arg} cannot be empty`);
165
+ o.agent = v;
166
+ break;
167
+ }
154
168
  case '--cases':
155
169
  o.cases = value();
156
170
  break;
@@ -173,22 +187,44 @@ function parseVerifyArgs(argv) {
173
187
  o.paths.push(arg);
174
188
  }
175
189
  }
176
- const direct = o.promptId !== undefined || o.workflowId !== undefined;
177
- if (o.promptId !== undefined && o.workflowId !== undefined)
178
- throw new errors_1.CliError('Use -p or -w, not both');
190
+ const direct = o.promptId !== undefined || o.workflowId !== undefined || o.agent !== undefined;
191
+ if ([o.promptId, o.workflowId, o.agent].filter((v) => v !== undefined).length > 1)
192
+ throw new errors_1.CliError('Use only one of -p, -w, -a');
179
193
  if (direct && !o.cases)
180
- throw new errors_1.CliError('-p / -w need --cases <file>');
194
+ throw new errors_1.CliError('-p / -w / -a need --cases <file>');
181
195
  if (!direct && o.cases)
182
- throw new errors_1.CliError('--cases needs -p <prompt-id> or -w <workflow-id>');
196
+ throw new errors_1.CliError('--cases needs -p <prompt-id>, -w <workflow-id> or -a <agent-id|dir>');
183
197
  if (direct && o.paths.length)
184
- throw new errors_1.CliError('Workspace paths cannot be combined with -p / -w');
185
- if (direct && o.version === 'pushed') {
198
+ throw new errors_1.CliError('Workspace paths cannot be combined with -p / -w / -a');
199
+ // Agents always run live, so --version pushed is a no-op for -a.
200
+ if (direct && o.agent === undefined && o.version === 'pushed') {
186
201
  throw new errors_1.CliError('--version pushed reads .metrio/state.json; with -p / -w use active or a version number');
187
202
  }
203
+ if (o.agent !== undefined && typeof o.version === 'number')
204
+ throw new errors_1.CliError('Agents have no versions; --version <n> does not apply to -a');
188
205
  return o;
189
206
  }
207
+ /** `-a` takes an external id, or a workspace directory (agents/<dir> or <dir>) resolved through state. */
208
+ function agentRef(raw) {
209
+ if (/^\d+$/.test(raw))
210
+ return agentId(raw, `-a ${raw}`);
211
+ const root = (0, workspace_1.findRoot)();
212
+ if (!root)
213
+ throw new errors_1.CliError(`-a ${raw}: not a numeric id, and not inside a metrio workspace to look the directory up`);
214
+ const key = raw.startsWith('agents/') ? raw : `agents/${raw}`;
215
+ const entry = (0, workspace_1.readState)(root, (0, workspace_1.readProject)(root)).resources[key];
216
+ if (!entry || entry.kind !== 'agent')
217
+ throw new errors_1.CliError(`-a ${raw}: ${key} is not an agent in .metrio/state.json; run "metrio push" first`);
218
+ return agentId(entry.id, key);
219
+ }
220
+ function agentId(raw, label) {
221
+ const n = Number(raw);
222
+ if (!Number.isInteger(n) || n <= 0)
223
+ throw new errors_1.CliError(`${label}: agent id "${raw}" is not a positive integer`);
224
+ return n;
225
+ }
190
226
  function directTarget(o) {
191
- const kind = o.promptId !== undefined ? 'prompt' : 'workflow';
227
+ const kind = o.promptId !== undefined ? 'prompt' : o.agent !== undefined ? 'agent' : 'workflow';
192
228
  const file = o.cases;
193
229
  let raw;
194
230
  try {
@@ -199,12 +235,12 @@ function directTarget(o) {
199
235
  catch (err) {
200
236
  throw new errors_1.CliError(`Cannot read cases file ${file}: ${err.message}`);
201
237
  }
202
- const ref = kind === 'prompt' ? o.promptId : o.workflowId;
238
+ const ref = kind === 'prompt' ? o.promptId : kind === 'agent' ? agentRef(o.agent) : o.workflowId;
203
239
  return {
204
- label: `${kind} ${ref}`,
240
+ label: `${kind} ${kind === 'agent' ? o.agent : ref}`,
205
241
  kind,
206
242
  ref,
207
- version: typeof o.version === 'number' ? o.version : undefined,
243
+ version: kind !== 'agent' && typeof o.version === 'number' ? o.version : undefined,
208
244
  cases: (0, cases_1.parseCasesYaml)(raw, { kind, file, baseDir: path.dirname(path.resolve(file)) }),
209
245
  };
210
246
  }
@@ -215,7 +251,7 @@ function resolveTargets(o) {
215
251
  if (!root)
216
252
  throw new errors_1.CliError('Not in a metrio workspace (no metrio.yaml). Run it inside one, or use -p / -w with --cases.');
217
253
  const state = (0, workspace_1.readState)(root, (0, workspace_1.readProject)(root));
218
- const listed = (0, workspace_1.listLocal)(root, ['prompt', 'workflow']);
254
+ const listed = (0, workspace_1.listLocal)(root, ['prompt', 'workflow', 'agent']);
219
255
  // Filter by path first so an unrelated resource's cases.yaml is never read.
220
256
  const inScope = (0, sync_1.filterEntries)(listed, root, o.paths);
221
257
  const read = (rs) => rs.map((r) => ({ ...r, cases: (0, workspace_1.readResourceFile)(root, r.key, 'cases.yaml') }));
@@ -225,18 +261,23 @@ function resolveTargets(o) {
225
261
  }
226
262
  const selected = read(inScope).filter((r) => r.cases !== undefined);
227
263
  if (!selected.length) {
228
- throw new errors_1.CliError('No cases.yaml in this workspace. Add prompts/<name>/cases.yaml or workflows/<id>/cases.yaml.');
264
+ throw new errors_1.CliError('No cases.yaml in this workspace. Add prompts/<name>/cases.yaml, workflows/<id>/cases.yaml or agents/<name>/cases.yaml.');
229
265
  }
230
266
  if (typeof o.version === 'number' && selected.length > 1) {
231
267
  throw new errors_1.CliError(`--version <n> pins one resource; name a single prompt or workflow path (matched: ${selected.map((r) => r.key).join(', ')})`);
232
268
  }
269
+ if (typeof o.version === 'number' && selected.some((r) => r.kind === 'agent')) {
270
+ throw new errors_1.CliError('Agents have no versions; --version <n> does not apply to agents');
271
+ }
233
272
  return selected.map((r) => {
234
273
  const entry = state.resources[r.key];
235
274
  if (!entry)
236
275
  throw new errors_1.CliError(`${r.key} is not in .metrio/state.json yet; run "metrio push" first`);
237
276
  const kind = r.kind;
238
277
  let version;
239
- if (typeof o.version === 'number')
278
+ if (kind === 'agent')
279
+ version = undefined;
280
+ else if (typeof o.version === 'number')
240
281
  version = o.version;
241
282
  else if (o.version === 'pushed') {
242
283
  version = kind === 'prompt' ? entry.pushed_serial : entry.pushed_workflow_version;
@@ -244,9 +285,9 @@ function resolveTargets(o) {
244
285
  throw new errors_1.CliError(`${r.key}: no pushed version recorded in .metrio/state.json; run "metrio push"; a resource created by its first push (e.g. a new workflow) has none, so use --version active for it`);
245
286
  }
246
287
  }
247
- const ref = kind === 'prompt' ? Number(entry.id) : entry.id;
248
- if (kind === 'prompt' && (!Number.isInteger(ref) || ref <= 0)) {
249
- throw new errors_1.CliError(`${r.key}: prompt id "${entry.id}" in .metrio/state.json is not a positive integer`);
288
+ const ref = kind === 'prompt' || kind === 'agent' ? Number(entry.id) : entry.id;
289
+ if (kind !== 'workflow' && (!Number.isInteger(ref) || ref <= 0)) {
290
+ throw new errors_1.CliError(`${r.key}: ${kind} id "${entry.id}" in .metrio/state.json is not a positive integer`);
250
291
  }
251
292
  return {
252
293
  label: r.key,
@@ -265,11 +306,14 @@ async function runVerifyCommand(argv) {
265
306
  const o = parseVerifyArgs(argv);
266
307
  const targets = resolveTargets(o);
267
308
  (0, shared_1.loadEnv)(o.envFile);
268
- const client = new client_1.MetrioAI({ apiKey: (0, shared_1.resolveApiKey)(o.apiKey), baseUrl: (0, shared_1.resolveBaseUrl)(o.baseUrl) });
309
+ const apiKey = (0, shared_1.resolveApiKey)(o.apiKey);
310
+ const baseUrl = (0, shared_1.resolveBaseUrl)(o.baseUrl) || 'https://api.metrio.ai'; // the SDK's default
311
+ const client = new client_1.MetrioAI({ apiKey, baseUrl });
269
312
  const results = await (0, execute_1.runAll)(client, targets, {
270
313
  concurrency: o.concurrency,
271
314
  failFast: o.failFast,
272
315
  timezone: o.timezone,
316
+ agentApi: { apiKey, baseUrl },
273
317
  onResult: (r, done, total) => process.stderr.write((0, sync_1.safe)(`[${done}/${total}] ${r.resource} › ${r.case} ${r.status}\n`)),
274
318
  });
275
319
  process.stdout.write(o.json ? (0, report_1.formatJson)(results) : (0, report_1.formatHuman)(results));
package/dist/index.js CHANGED
@@ -40,10 +40,10 @@ Account (console OAuth):
40
40
  whoami Show the signed-in user and scopes
41
41
 
42
42
  Manage (console OAuth; run "metrio <group> --help"):
43
- project | prompt | version | skill | workflow | schedule | mcp | run | admin | model-tiers
43
+ project | prompt | version | skill | workflow | agent | schedule | mcp | run | admin | model-tiers
44
44
  e.g. metrio version create 12 -f system.md
45
45
  metrio skill get 5 -o skill.md
46
- pull | diff | push Sync prompts, skills and workflows with local files ("metrio pull --help")
46
+ pull | diff | push Sync prompts, skills, workflows and agents with local files ("metrio pull --help")
47
47
  tools List the console tools your login can call
48
48
  call <tool> Call any console tool with JSON args (-f file | --args json)
49
49
 
@@ -87,7 +87,9 @@ async function writeFile(data, opts, file) {
87
87
  const text = opts.textField ? record[opts.textField] : undefined;
88
88
  if (typeof text !== 'string')
89
89
  throw new errors_1.CliError(`-o ${file}: this result has no text body; use .json or .yaml`);
90
- const front = Object.fromEntries((opts.frontmatterKeys ?? []).filter((k) => record[k] !== undefined && record[k] !== null).map((k) => [k, record[k]]));
90
+ const front = Object.fromEntries((opts.frontmatterKeys ?? [])
91
+ .filter((k) => record[k] !== undefined && (record[k] !== null || (opts.frontmatterKeepNull ?? []).includes(k)))
92
+ .map((k) => [k, record[k]]));
91
93
  const head = Object.keys(front).length ? `---\n${(0, yaml_1.stringify)(front)}---\n\n` : '';
92
94
  body = `${head}${text}`;
93
95
  }
@@ -1,10 +1,39 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.MANAGE_GROUPS = exports.COMMANDS = void 0;
3
+ exports.MANAGE_GROUPS = exports.COMMANDS = exports.READ_ONLY_FRONTMATTER = void 0;
4
4
  exports.findCommand = findCommand;
5
5
  exports.groupHelp = groupHelp;
6
6
  exports.commandHelp = commandHelp;
7
7
  const P = (prop, label) => ({ prop, label });
8
+ /** Identity of the version a file came from: written for reference, never sent back. */
9
+ exports.READ_ONLY_FRONTMATTER = ['version_id', 'serial'];
10
+ const VERSION_FRONTMATTER = [
11
+ ...exports.READ_ONLY_FRONTMATTER, 'description', 'tier_key', 'model_provider', 'model_name',
12
+ 'output_format', 'model_settings', 'model_fallbacks', 'output_schema',
13
+ ];
14
+ /** get_version → the create_version/update_version field names the frontmatter uses. */
15
+ function versionFrontmatterView(r) {
16
+ const tier = r.model_tier?.tier_key;
17
+ // A file describes the whole version: "no schema" / "no fallbacks" are written explicitly, so
18
+ // update -f clears them instead of keeping what the target version had (omission keeps).
19
+ const view = {
20
+ ...r,
21
+ version_id: r.id,
22
+ output_schema: r.output_schema ?? null,
23
+ model_fallbacks: r.model_fallbacks ?? [],
24
+ };
25
+ if (typeof tier === 'string' && tier) {
26
+ view.tier_key = tier;
27
+ delete view.model_provider;
28
+ delete view.model_name;
29
+ }
30
+ return view;
31
+ }
32
+ const VERSION_FILE = {
33
+ optionalFrontmatterKeys: VERSION_FRONTMATTER,
34
+ frontmatterView: versionFrontmatterView,
35
+ frontmatterKeepNull: ['output_schema'],
36
+ };
8
37
  const PROMPT = P('prompt_external_id', '<prompt-id>');
9
38
  const VERSION_ID = P('version_id', '<version-id>');
10
39
  const SKILL = P('skill_id', '<skill-id>');
@@ -13,6 +42,8 @@ const WF_VERSION = P('version', '<version>');
13
42
  const SCHEDULE = P('schedule_id', '<schedule-id>');
14
43
  const INSTANCE = P('mcp_instance_id', '<instance-id>');
15
44
  const TASK = P('task_id', '<task-id>');
45
+ const AGENT = P('agent_id', '<agent>');
46
+ const RUN = P('run_id', '<run>');
16
47
  exports.COMMANDS = [
17
48
  { path: ['project', 'list'], tool: 'list_projects', summary: 'List your projects' },
18
49
  { path: ['project', 'create'], tool: 'create_project', summary: 'Create a project (-f or --team-id/--name)' },
@@ -28,9 +59,9 @@ exports.COMMANDS = [
28
59
  { path: ['prompt', 'set-mcp-tools'], tool: 'set_prompt_mcp_tools', summary: 'Set the MCP tool allowlist (-f tools.yaml)', args: [PROMPT] },
29
60
  { path: ['prompt', 'diff'], tool: 'diff_prompt', summary: 'Diff two versions', args: [PROMPT, P('from_serial', '<from>'), P('to_serial', '<to>')] },
30
61
  { path: ['version', 'list'], tool: 'list_versions', summary: 'List versions of a prompt', args: [PROMPT] },
31
- { path: ['version', 'get'], tool: 'get_version', summary: 'Get a version (-o v.yaml | system.md)', args: [VERSION_ID], textField: 'system_prompt' },
32
- { path: ['version', 'create'], tool: 'create_version', summary: 'Create a version (-f system.md | v.yaml)', args: [PROMPT], textField: 'system_prompt' },
33
- { path: ['version', 'update'], tool: 'update_version', summary: 'Update a version (-f system.md | v.yaml)', args: [VERSION_ID], textField: 'system_prompt' },
62
+ { path: ['version', 'get'], tool: 'get_version', summary: 'Get a version (-o v.yaml | system.md [--frontmatter])', args: [VERSION_ID], textField: 'system_prompt', ...VERSION_FILE },
63
+ { path: ['version', 'create'], tool: 'create_version', summary: 'Create a version (-f system.md [--frontmatter] | v.yaml)', args: [PROMPT], textField: 'system_prompt', ...VERSION_FILE },
64
+ { path: ['version', 'update'], tool: 'update_version', summary: 'Update a version (-f system.md [--frontmatter] | v.yaml)', args: [VERSION_ID], textField: 'system_prompt', ...VERSION_FILE },
34
65
  { path: ['version', 'create-batch'], tool: 'create_versions', summary: 'Create versions for several prompts (-f versions.jsonl)', wrap: 'versions' },
35
66
  { path: ['skill', 'list'], tool: 'list_skills', summary: 'List skills' },
36
67
  { path: ['skill', 'get'], tool: 'get_skill', summary: 'Get a skill (-o skill.md)', args: [SKILL], textField: 'content', frontmatterKeys: ['name', 'description', 'references'] },
@@ -49,6 +80,17 @@ exports.COMMANDS = [
49
80
  { path: ['workflow', 'version', 'diff'], tool: 'diff_workflow_version', summary: 'Diff two workflow versions', args: [WF, P('from_version', '<from>'), P('to_version', '<to>')] },
50
81
  { path: ['workflow', 'version', 'publish'], tool: 'publish_workflow_version', summary: 'Publish a draft (--activate to go live)', args: [WF, WF_VERSION] },
51
82
  { path: ['workflow', 'version', 'activate'], tool: 'set_active_workflow_version', summary: 'Make a published version live (rollback too)', args: [WF, WF_VERSION] },
83
+ { path: ['agent', 'list'], tool: 'list_agents', summary: 'List Metrio agents' },
84
+ { path: ['agent', 'get'], tool: 'get_agent', summary: 'Get an agent with its sub-agents and tool policies', args: [AGENT] },
85
+ { path: ['agent', 'create'], tool: 'create_agent', summary: 'Create an agent (-f agent.json, console field names)' },
86
+ { path: ['agent', 'update'], tool: 'update_agent', summary: 'Update an agent (-f agent.json, console field names)', args: [AGENT] },
87
+ { path: ['agent', 'delete'], tool: 'delete_agent', summary: 'Delete an agent (blocked while it is a sub-agent)', args: [AGENT], destructive: true },
88
+ { path: ['agent', 'tools'], tool: 'set_agent_tools', summary: 'Replace sub-agents and tool policies (-f tools.yaml)', args: [AGENT] },
89
+ { path: ['agent', 'bump-pins'], tool: 'bump_agent_pins', summary: 'Repoint sub-agent version pins (-f pins.yaml)', wrap: 'pins' },
90
+ { path: ['agent', 'run', 'list'], tool: 'list_agent_runs', summary: 'List runs of an agent, newest first', args: [AGENT] },
91
+ { path: ['agent', 'run', 'get'], tool: 'get_agent_run', summary: 'Get a run with children and pending approvals', args: [AGENT, RUN] },
92
+ { path: ['agent', 'run', 'approve'], tool: 'approve_agent_run', summary: 'Approve or reject gated tool calls (-f decisions.yaml)', args: [AGENT, RUN], wrap: 'decisions' },
93
+ { path: ['agent', 'run', 'cancel'], tool: 'cancel_agent_run', summary: 'Cancel an unfinished run', args: [AGENT, RUN], destructive: true },
52
94
  { path: ['schedule', 'list'], tool: 'list_schedules', summary: 'List schedules' },
53
95
  { path: ['schedule', 'create'], tool: 'create_schedule', summary: 'Create a schedule (-f schedule.yaml)' },
54
96
  { path: ['schedule', 'update'], tool: 'update_schedule', summary: 'Update a schedule (-f schedule.yaml)', args: [SCHEDULE] },
@@ -107,10 +149,16 @@ function commandHelp(spec) {
107
149
  ];
108
150
  if (spec.destructive)
109
151
  lines.push(' --yes Confirm the delete');
152
+ if (spec.optionalFrontmatterKeys) {
153
+ lines.push(' --frontmatter .md files carry the version settings as YAML frontmatter (see below)');
154
+ }
110
155
  if (spec.textField)
111
156
  lines.push('', `A .md/.txt file fills ${spec.textField}.`);
112
157
  if (spec.frontmatterKeys)
113
158
  lines.push(`Its YAML frontmatter may set: ${spec.frontmatterKeys.join(', ')}.`);
159
+ if (spec.optionalFrontmatterKeys) {
160
+ lines.push(`With --frontmatter, a .md file starts with YAML frontmatter (${spec.optionalFrontmatterKeys.join(', ')})`, `and the rest is ${spec.textField} verbatim. ${exports.READ_ONLY_FRONTMATTER.join(' and ')} are written by -o and ignored by -f.`, 'Without --frontmatter the whole file is the text, so a prompt that starts with --- stays intact.');
161
+ }
114
162
  if (spec.wrap)
115
163
  lines.push('', `A file without a "${spec.wrap}" key is sent as ${spec.wrap}.`);
116
164
  if (spec.downloadField)