@metrio-ai/cli 1.4.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -70,7 +70,9 @@ metrio version list pick-products # prompt directories work wherever a promp
70
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.
71
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.
72
72
 
73
- ## 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
74
76
 
75
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):
76
78
 
@@ -95,4 +97,6 @@ metrio push --activate # make it live once the cases pass
95
97
  metrio verify -p 12 --cases cases.yaml --junit report.xml # outside a workspace
96
98
  ```
97
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
+
98
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,15 @@ 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
+ // The SDK appends paths as-is, so a trailing slash would give `//v1/...`.
311
+ const baseUrl = ((0, shared_1.resolveBaseUrl)(o.baseUrl) || 'https://api.metrio.ai').replace(/\/+$/, ''); // the SDK's default
312
+ const client = new client_1.MetrioAI({ apiKey, baseUrl });
269
313
  const results = await (0, execute_1.runAll)(client, targets, {
270
314
  concurrency: o.concurrency,
271
315
  failFast: o.failFast,
272
316
  timezone: o.timezone,
317
+ apiKey,
273
318
  onResult: (r, done, total) => process.stderr.write((0, sync_1.safe)(`[${done}/${total}] ${r.resource} › ${r.case} ${r.status}\n`)),
274
319
  });
275
320
  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
 
@@ -42,6 +42,8 @@ const WF_VERSION = P('version', '<version>');
42
42
  const SCHEDULE = P('schedule_id', '<schedule-id>');
43
43
  const INSTANCE = P('mcp_instance_id', '<instance-id>');
44
44
  const TASK = P('task_id', '<task-id>');
45
+ const AGENT = P('agent_id', '<agent>');
46
+ const RUN = P('run_id', '<run>');
45
47
  exports.COMMANDS = [
46
48
  { path: ['project', 'list'], tool: 'list_projects', summary: 'List your projects' },
47
49
  { path: ['project', 'create'], tool: 'create_project', summary: 'Create a project (-f or --team-id/--name)' },
@@ -78,6 +80,17 @@ exports.COMMANDS = [
78
80
  { path: ['workflow', 'version', 'diff'], tool: 'diff_workflow_version', summary: 'Diff two workflow versions', args: [WF, P('from_version', '<from>'), P('to_version', '<to>')] },
79
81
  { path: ['workflow', 'version', 'publish'], tool: 'publish_workflow_version', summary: 'Publish a draft (--activate to go live)', args: [WF, WF_VERSION] },
80
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 },
81
94
  { path: ['schedule', 'list'], tool: 'list_schedules', summary: 'List schedules' },
82
95
  { path: ['schedule', 'create'], tool: 'create_schedule', summary: 'Create a schedule (-f schedule.yaml)' },
83
96
  { path: ['schedule', 'update'], tool: 'update_schedule', summary: 'Update a schedule (-f schedule.yaml)', args: [SCHEDULE] },
@@ -73,21 +73,28 @@ function isCommandPrefix(argv) {
73
73
  const words = argv.filter((a) => !a.startsWith('-'));
74
74
  return registry_1.COMMANDS.some((c) => words.length < c.path.length && words.every((w, i) => c.path[i] === w));
75
75
  }
76
+ /** Positionals that may name a tracked directory instead of the numeric id. */
77
+ const DIR_REFS = {
78
+ prompt_external_id: { kind: 'prompt', noun: 'prompt' },
79
+ agent_id: { kind: 'agent', noun: 'agent' },
80
+ };
76
81
  /**
77
- * A prompt positional may name a tracked directory (prompts/<dir> or <dir>) instead of the
78
- * numeric id — only for the workspace's own project, since ids are per project.
82
+ * A prompt or agent positional may name a tracked directory (prompts/<dir> or <dir>) instead of
83
+ * the numeric id — only for the workspace's own project, since ids are per project.
79
84
  */
80
- function promptRef(raw, project) {
85
+ function dirRef(raw, project, prop) {
81
86
  if (/^\d+$/.test(raw))
82
87
  return raw;
88
+ const { kind, noun } = DIR_REFS[prop];
89
+ const prefix = workspace_1.KIND_DIR[kind];
83
90
  const root = (0, workspace_1.findRoot)();
84
91
  const dir = raw.replace(/\/+$/, '');
85
- const key = dir.startsWith('prompts/') ? dir : `prompts/${dir}`;
92
+ const key = dir.startsWith(`${prefix}/`) ? dir : `${prefix}/${dir}`;
86
93
  // A broken workspace surfaces its own (informative) CliError.
87
94
  const workspaceProject = root ? (0, workspace_1.readProject)(root) : undefined;
88
95
  const entry = root ? (0, workspace_1.readState)(root, workspaceProject).resources[key] : undefined;
89
- if (!entry || entry.kind !== 'prompt') {
90
- throw new errors_1.CliError(`Unknown prompt "${raw}": use the numeric id, or a prompts/<dir> tracked in .metrio/state.json`);
96
+ if (!entry || entry.kind !== kind) {
97
+ throw new errors_1.CliError(`Unknown ${noun} "${raw}": use the numeric id, or a ${prefix}/<dir> tracked in .metrio/state.json`);
91
98
  }
92
99
  if (project !== undefined && project !== workspaceProject) {
93
100
  throw new errors_1.CliError(`"${raw}" is a directory of project ${workspaceProject}, but this command targets project ${String(project)}`);
@@ -144,11 +151,11 @@ async function runManageCommand(argv, readStdin) {
144
151
  delete args.tier_key;
145
152
  delete sources.tier_key;
146
153
  }
147
- // A prompt directory resolves against the final project_slug, so it waits for it.
148
- let promptDir;
154
+ // A directory name resolves against the final project_slug, so it waits for it.
155
+ const dirRefs = [];
149
156
  expected.forEach((arg, i) => {
150
- if (arg.prop === 'prompt_external_id' && !/^\d+$/.test(positionals[i]))
151
- promptDir = { prop: arg.prop, raw: positionals[i], label: arg.label };
157
+ if (arg.prop in DIR_REFS && !/^\d+$/.test(positionals[i]))
158
+ dirRefs.push({ prop: arg.prop, raw: positionals[i], label: arg.label });
152
159
  else
153
160
  put(arg.prop, (0, schema_1.coercePositional)(positionals[i], props[arg.prop], arg.label), arg.label);
154
161
  });
@@ -163,8 +170,8 @@ async function runManageCommand(argv, readStdin) {
163
170
  throw err;
164
171
  }
165
172
  }
166
- if (promptDir) {
167
- put(promptDir.prop, (0, schema_1.coercePositional)(promptRef(promptDir.raw, args.project_slug), props[promptDir.prop], promptDir.label), promptDir.label);
173
+ for (const ref of dirRefs) {
174
+ put(ref.prop, (0, schema_1.coercePositional)(dirRef(ref.raw, args.project_slug, ref.prop), props[ref.prop], ref.label), ref.label);
168
175
  }
169
176
  if ((schema.required ?? []).includes('project_slug') && args.project_slug === undefined) {
170
177
  throw new errors_1.CliError('No project. Pass --project <slug>, set METRIO_PROJECT, or add "project: <slug>" to metrio.yaml.');