metergraph-cli 0.2.1 → 0.2.2

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/src/output.js CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  SUPPORTED_PROFILES,
28
28
  VERSION,
29
29
  } from "./constants.js";
30
+ import { PLAIN, fields, headline, paragraph, render, section, steps, toneFor } from "./style.js";
30
31
 
31
32
  // Every JSON result, success or failure, has the same top-level keys:
32
33
  // schema_version, command, ok, outcome, exit_code, data, error
@@ -115,6 +116,20 @@ const AGENT_APPROVAL_HELP = [
115
116
  "and after they approve, run the same command again to continue.",
116
117
  ];
117
118
 
119
+ // One recommended sequence per place an agent runs. login and setup detect
120
+ // SSH, cloud workspaces and CI and answer with a structured next_action.
121
+ const AGENT_PATH_HELP = [
122
+ "Where the agent runs:",
123
+ " Local shell or local container setup --runtime local --client C --json --no-browser,",
124
+ " rerun after each approval, then status --json",
125
+ " SSH, cloud workspace or CI A person creates a coding-agent key with Metadata access",
126
+ " on the Keys page and saves it as the secret",
127
+ " METERGRAPH_AGENT_TOKEN; then status --json, usage --json",
128
+ " Metadata key, no saved sign in The same: reads use METERGRAPH_AGENT_TOKEN, or",
129
+ " --agent-token-file PATH, after verifying the workspace",
130
+ "Never pass a key as an argument. Debug and Replay keys are refused, not reduced.",
131
+ ];
132
+
118
133
  const CONFIG_DIR_OPTION = {
119
134
  name: "--config-dir",
120
135
  value: "DIR",
@@ -127,7 +142,9 @@ const LOGIN_OPTIONS = [
127
142
  {
128
143
  name: "--runtime",
129
144
  value: "RUNTIME",
130
- summary: "Required. local: the browser runs on this machine. cloud and cloud-no-shell get a handoff.",
145
+ summary:
146
+ "Required. Where this terminal runs, not which Metergraph service. local: your own machine, " +
147
+ "including for the hosted service. cloud, cloud-no-shell: a cloud sandbox; get the connection guide.",
131
148
  },
132
149
  { name: "--url", value: "ORIGIN", summary: `Origin to sign in to. Default ${DEFAULT_ORIGIN}.` },
133
150
  {
@@ -195,24 +212,61 @@ const READ_BASE_OPTIONS = [
195
212
  `Total time for every request of the command, ${READ_MIN_TIMEOUT_MS} to ${READ_MAX_TIMEOUT_MS}. ` +
196
213
  `Default ${READ_DEFAULT_TIMEOUT_MS}.`,
197
214
  },
215
+ {
216
+ name: "--url",
217
+ value: "ORIGIN",
218
+ summary:
219
+ "Where a Metadata key is sent when the project is not bound. Default METERGRAPH_URL, else the hosted service. " +
220
+ "A bound project's origin cannot be changed here.",
221
+ },
222
+ {
223
+ name: "--workspace",
224
+ value: "UUID",
225
+ summary: "The workspace the credential must be for. Without it, an unbound key's own workspace is used.",
226
+ },
227
+ {
228
+ name: "--agent-token-file",
229
+ value: "PATH",
230
+ summary:
231
+ "Private (0600) file holding a coding-agent key with Metadata access. Used instead of a saved sign in. " +
232
+ "Without it, METERGRAPH_AGENT_TOKEN is used when this project has no saved sign in on this machine.",
233
+ },
198
234
  ];
199
235
  const DAYS_OPTION = {
200
236
  name: "--days",
201
237
  value: "N",
202
238
  summary: `Window of the last N days, 1 to ${READ_MAX_DAYS}. Default ${READ_DEFAULT_DAYS}.`,
203
239
  };
240
+ const METADATA_OPTIONS = [
241
+ {
242
+ name: "--metadata",
243
+ value: "KEY=VALUE",
244
+ summary:
245
+ "Only calls whose metadata (SDK tags) has exactly this string value: case-sensitive, no prefix or " +
246
+ "trimming. Repeat for up to 5 keys, all must match. A call without the key never matches; KEY= " +
247
+ "matches only an empty value. Splits at the first =, so a value may contain =.",
248
+ },
249
+ {
250
+ name: "--metadata-json",
251
+ value: "JSON",
252
+ summary:
253
+ 'The same filters as one JSON object, for keys that contain = (for example {"a=b":"v"}). Not with --metadata.',
254
+ },
255
+ ];
204
256
  const limitOption = (fallback, extra = "") => ({
205
257
  name: "--limit",
206
258
  value: "N",
207
259
  summary: `Rows to return, 1 to ${READ_MAX_LIMIT}. Default ${fallback}.${extra}`,
208
260
  });
209
- const READ_BASE_USAGE = "[--project DIR] [--config-dir DIR] [--timeout-ms N] [--json]";
261
+ const READ_BASE_USAGE =
262
+ "[--url ORIGIN] [--workspace UUID] [--agent-token-file PATH] [--project DIR] [--config-dir DIR] [--timeout-ms N] [--json]";
210
263
  const REFUSED_NOTE =
211
264
  "--environment, --workload, --since, --until, --sql, --query, --content, --include-content, --debug and --replay " +
212
- "are recognized and refused with exit code 6 before any request.";
265
+ "are recognized and refused with exit code 6 before any request, except --environment, --since and --until on usage.";
213
266
 
214
- // The read commands. Each uses the project's saved Metadata grant, makes GET
215
- // requests to fixed paths on the bound origin and never opens a browser.
267
+ // The read commands. Each uses the project's saved Metadata grant or a
268
+ // coding-agent key with Metadata access, makes GET requests to fixed paths on
269
+ // the verified origin and never opens a browser.
216
270
  export const READ_HELP = Object.freeze([
217
271
  {
218
272
  name: "status",
@@ -237,9 +291,82 @@ export const READ_HELP = Object.freeze([
237
291
  },
238
292
  {
239
293
  name: "usage",
240
- usage: `metergraph usage [--days N] [--limit N] ${READ_BASE_USAGE}`,
241
- summary: "Show daily calls, errors, cost, tokens and latency per route for a recent window. Metadata only.",
242
- options: [...READ_BASE_OPTIONS, DAYS_OPTION, limitOption(READ_DEFAULT_LIMIT), JSON_OPTION],
294
+ usage:
295
+ `metergraph usage [--days N | --today | --since TIME [--until TIME]] [--group-by DIMS] ` +
296
+ `[--route NAME]... [--provider NAME]... [--model NAME]... [--environment NAME]... ` +
297
+ `[--sort ORDER] [--compare previous|previous-same-time] [--limit N] ` +
298
+ `[--metadata KEY=VALUE]... [--metadata-json JSON] [--group-by-metadata KEY] ${READ_BASE_USAGE}`,
299
+ summary:
300
+ "Show calls, errors, cost, tokens and latency. With --group-by, a filter, a UTC window, --sort or --compare " +
301
+ "it reads grouped usage: cost by provider, model, route or UTC day, computed per call by the service, with " +
302
+ "totals over every matching call and coverage, pricing and attribution evidence. Without them it lists cost " +
303
+ "per route and day for the last N days. A route is not a prompt: one route can carry several prompt IDs, " +
304
+ 'so "metergraph usage --group-by metadata.prompt_id" splits cost by prompt. "metergraph routes" lists ' +
305
+ "route definitions, not their cost.",
306
+ examples: [
307
+ ["Most expensive route today", "metergraph usage --group-by route --today"],
308
+ ["Spend with one provider this week", "metergraph usage --group-by provider --provider NAME --days 7"],
309
+ ["Spend by model", "metergraph usage --group-by model --days 7"],
310
+ ["One route's daily trend", "metergraph usage --route NAME --group-by day --days 14"],
311
+ ["Cost by prompt ID", "metergraph usage --group-by metadata.prompt_id --days 7"],
312
+ ["Today against yesterday", "metergraph usage --group-by route --today --compare previous"],
313
+ ],
314
+ options: [
315
+ ...READ_BASE_OPTIONS,
316
+ {
317
+ name: "--days",
318
+ value: "N",
319
+ summary:
320
+ `Window, 1 to ${READ_MAX_DAYS}. Default ${READ_DEFAULT_DAYS}. Grouped: the current UTC day so far and the ` +
321
+ "N - 1 whole UTC days before it (window.kind utc_days). Otherwise the last N x 24 hours, rolling and not " +
322
+ "aligned to UTC days (window.kind rolling).",
323
+ },
324
+ { name: "--today", value: null, summary: "Grouped: the current UTC day so far (--days 1)." },
325
+ {
326
+ name: "--since",
327
+ value: "TIME",
328
+ summary:
329
+ "Grouped: window start, inclusive. A UTC date (YYYY-MM-DD, 00:00 UTC) or an ISO 8601 timestamp with Z " +
330
+ `or an offset. At most ${READ_MAX_DAYS} days before --until.`,
331
+ },
332
+ { name: "--until", value: "TIME", summary: "Grouped: window end, exclusive, in the same form. Default now." },
333
+ {
334
+ name: "--group-by",
335
+ value: "DIMS",
336
+ summary: "Comma-separated, in order: provider, model, route, day (UTC calendar day). Default provider,model.",
337
+ },
338
+ { name: "--route", value: "NAME", summary: "Grouped: only this route, exactly. Repeat for up to 20; (unrouted) selects calls without one." },
339
+ { name: "--provider", value: "NAME", summary: "Grouped: only this provider, exactly. Repeat for up to 20." },
340
+ { name: "--model", value: "NAME", summary: "Grouped: only this model, exactly. Repeat for up to 20." },
341
+ { name: "--environment", value: "NAME", summary: "Grouped: only this environment. Repeat for several. A restricted grant reads its own environments only." },
342
+ {
343
+ name: "--sort",
344
+ value: "ORDER",
345
+ summary:
346
+ "cost (default), cost-per-call, calls, errors, avg-latency (mean over calls that reported latency), " +
347
+ "p95-latency (estimated from a fixed histogram) or day (oldest first; the default for --group-by day). " +
348
+ "The service returns the highest-cost groups, so another order over a cut list is marked partial.",
349
+ },
350
+ {
351
+ name: "--compare",
352
+ value: "MODE",
353
+ summary:
354
+ "Also read the period before and show the change per group. previous: the whole prior period, which is " +
355
+ "longer than a window ending part way through today (a warning says so). previous-same-time: the prior " +
356
+ "period to the same elapsed time. Not with --group-by day.",
357
+ },
358
+ limitOption(READ_DEFAULT_LIMIT, " Grouped: the most groups listed; totals still cover every call."),
359
+ ...METADATA_OPTIONS,
360
+ {
361
+ name: "--group-by-metadata",
362
+ value: "KEY",
363
+ summary:
364
+ "Group by one metadata key: the same as ending --group-by with metadata.KEY, and the way to give a key " +
365
+ "that holds a comma. Calls without the key or with a non-string value are one group with no value, and " +
366
+ "the spend not attributed to any value is totalled.",
367
+ },
368
+ JSON_OPTION,
369
+ ],
243
370
  },
244
371
  {
245
372
  name: "routes",
@@ -260,7 +387,7 @@ export const READ_HELP = Object.freeze([
260
387
  name: "traces",
261
388
  usage:
262
389
  `metergraph traces [--days N] [--limit N] [--route NAME] [--status success|error] ` +
263
- `[--cursor CURSOR] ${READ_BASE_USAGE}`,
390
+ `[--metadata KEY=VALUE]... [--metadata-json JSON] [--cursor CURSOR] ${READ_BASE_USAGE}`,
264
391
  summary:
265
392
  "List one page of trace metadata: status, span count, tokens, cost, routes, providers and models. " +
266
393
  "No prompts, responses or tool calls.",
@@ -270,6 +397,7 @@ export const READ_HELP = Object.freeze([
270
397
  limitOption(TRACES_DEFAULT_LIMIT),
271
398
  { name: "--route", value: "NAME", summary: "Only traces that include this route." },
272
399
  { name: "--status", value: "STATUS", summary: "Only success or error traces." },
400
+ ...METADATA_OPTIONS,
273
401
  {
274
402
  name: "--cursor",
275
403
  value: "CURSOR",
@@ -280,6 +408,15 @@ export const READ_HELP = Object.freeze([
280
408
  },
281
409
  ]);
282
410
 
411
+ // One " FLAG summary" line per option. The flag column keeps its usual
412
+ // width but grows to fit the longest flag, so a flag never runs into its
413
+ // summary.
414
+ function optionLines(options, minimum) {
415
+ const flags = options.map((option) => (option.value ? `${option.name} ${option.value}` : option.name));
416
+ const width = Math.max(minimum, ...flags.map((flag) => flag.length + 2));
417
+ return options.map((option, index) => ` ${flags[index].padEnd(width)}${option.summary}`);
418
+ }
419
+
283
420
  export function helpData(topic) {
284
421
  return {
285
422
  topic,
@@ -377,7 +514,12 @@ export function helpData(topic) {
377
514
  summary: "Poll Metadata for one exact trace in an explicit invocation window. Never sends application traffic.",
378
515
  options: VERIFY_OPTIONS,
379
516
  },
380
- ...READ_HELP.map(({ name, summary, options }) => ({ name, summary, options })),
517
+ ...READ_HELP.map(({ name, summary, options, examples }) => ({
518
+ name,
519
+ summary,
520
+ options,
521
+ ...(examples ? { examples: examples.map(([question, command]) => ({ question, command })) } : {}),
522
+ })),
381
523
  ],
382
524
  skill_clients: Object.keys(SKILL_CLIENTS),
383
525
  skill_runtimes: [...SKILL_RUNTIMES],
@@ -391,7 +533,7 @@ export function helpData(topic) {
391
533
  };
392
534
  }
393
535
 
394
- export function helpText(topic) {
536
+ export function helpText(topic, style = PLAIN) {
395
537
  const lines = [];
396
538
  if (topic === "doctor") {
397
539
  lines.push(
@@ -402,10 +544,7 @@ export function helpText(topic) {
402
544
  "",
403
545
  "Options:",
404
546
  );
405
- for (const option of DOCTOR_OPTIONS) {
406
- const flag = option.value ? `${option.name} ${option.value}` : option.name;
407
- lines.push(` ${flag.padEnd(18)}${option.summary}`);
408
- }
547
+ lines.push(...optionLines(DOCTOR_OPTIONS, 18));
409
548
  lines.push(
410
549
  "",
411
550
  "ORIGIN must be a bare https origin such as https://metergraph.example.com.",
@@ -422,10 +561,7 @@ export function helpText(topic) {
422
561
  "operator prerequisite attestation; OSS remains an operator handoff. No application",
423
562
  "traffic is claimed until an exact instrumented invocation is separately verified.",
424
563
  "", "Options:");
425
- for (const option of helpData(null).commands.find((entry) => entry.name === "setup").options) {
426
- const flag = option.value ? `${option.name} ${option.value}` : option.name;
427
- lines.push(` ${flag.padEnd(25)}${option.summary}`);
428
- }
564
+ lines.push(...optionLines(helpData(null).commands.find((entry) => entry.name === "setup").options, 25));
429
565
  lines.push("", ...AGENT_APPROVAL_HELP);
430
566
  } else if (topic === "login" || topic === "logout") {
431
567
  const login = topic === "login";
@@ -455,20 +591,14 @@ export function helpText(topic) {
455
591
  );
456
592
  }
457
593
  lines.push("Options:");
458
- for (const option of login ? LOGIN_OPTIONS : LOGOUT_OPTIONS) {
459
- const flag = option.value ? `${option.name} ${option.value}` : option.name;
460
- lines.push(` ${flag.padEnd(18)}${option.summary}`);
461
- }
594
+ lines.push(...optionLines(login ? LOGIN_OPTIONS : LOGOUT_OPTIONS, 18));
462
595
  if (login) lines.push("", ...AGENT_APPROVAL_HELP);
463
596
  } else if (topic === "verify") {
464
597
  lines.push(`Usage: ${VERIFY_USAGE}`, "", "Uses the saved Metadata grant to poll for one exact, processed trace.",
465
598
  "The source label is supplied by the caller. A Metadata match alone does not prove application traffic.",
466
599
  "Opening requires a server link that selects the verified workspace; older links get a manual handoff.",
467
600
  "", "Options:");
468
- for (const option of VERIFY_OPTIONS) {
469
- const flag = option.value ? `${option.name} ${option.value}` : option.name;
470
- lines.push(` ${flag.padEnd(22)}${option.summary}`);
471
- }
601
+ lines.push(...optionLines(VERIFY_OPTIONS, 22));
472
602
  } else if (READ_HELP.some((entry) => entry.name === topic)) {
473
603
  const entry = READ_HELP.find((candidate) => candidate.name === topic);
474
604
  lines.push(
@@ -476,18 +606,23 @@ export function helpText(topic) {
476
606
  "",
477
607
  entry.summary,
478
608
  "",
479
- "Uses this project's saved Metadata grant (see \"metergraph help login\"). Sends GET requests only, to",
480
- "fixed paths on the bound origin, follows no redirects and reads bounded responses. Never opens a",
481
- "browser, signs in, writes project files, reads retained content, replays or calls a model provider.",
482
- "It may refresh its own saved grant, and the service may record the access (for example last used",
483
- "times); it never changes workspace configuration or telemetry and never sends ingest data.",
609
+ "Uses this project's saved Metadata grant (see \"metergraph help login\"), or, where there is none on",
610
+ "this machine, a coding-agent key with Metadata access from METERGRAPH_AGENT_TOKEN or",
611
+ "--agent-token-file. A key is sent only after the origin answers as a supported Metergraph service,",
612
+ "and nothing is read until the service shows exactly the Metadata scope, no content access and the",
613
+ "expected workspace. Sends GET requests only, to fixed paths on that origin, follows no redirects and",
614
+ "reads bounded responses. Never opens a browser, signs in, writes project files or the key, reads",
615
+ "retained content, replays or calls a model provider. It may refresh its own saved grant, and the",
616
+ "service may record the access (for example last used times); it never changes workspace",
617
+ "configuration or telemetry and never sends ingest data.",
484
618
  REFUSED_NOTE,
485
619
  "",
486
620
  "Options:",
487
621
  );
488
- for (const option of entry.options) {
489
- const flag = option.value ? `${option.name} ${option.value}` : option.name;
490
- lines.push(` ${flag.padEnd(18)}${option.summary}`);
622
+ lines.push(...optionLines(entry.options, 25));
623
+ if (entry.examples) {
624
+ lines.push("", "Examples (add --json for one JSON line):");
625
+ for (const [question, command] of entry.examples) lines.push(` ${question}:`, ` ${command}`);
491
626
  }
492
627
  } else if (topic === "skill") {
493
628
  lines.push(
@@ -501,10 +636,7 @@ export function helpText(topic) {
501
636
  "",
502
637
  "Options:",
503
638
  );
504
- for (const option of SKILL_OPTIONS) {
505
- const flag = option.value ? `${option.name} ${option.value}` : option.name;
506
- lines.push(` ${flag.padEnd(18)}${option.summary}`);
507
- }
639
+ lines.push(...optionLines(SKILL_OPTIONS, 18));
508
640
  lines.push(
509
641
  "",
510
642
  "install never replaces an existing skill. update replaces only a skill this CLI",
@@ -533,10 +665,7 @@ export function helpText(topic) {
533
665
  "",
534
666
  "Options:",
535
667
  );
536
- for (const option of SKILL_OPTIONS) {
537
- const flag = option.value ? `${option.name} ${option.value}` : option.name;
538
- lines.push(` ${flag.padEnd(18)}${option.summary}`);
539
- }
668
+ lines.push(...optionLines(SKILL_OPTIONS, 18));
540
669
  lines.push(
541
670
  "",
542
671
  "install never replaces an existing skill. update replaces only skills this CLI",
@@ -577,77 +706,93 @@ export function helpText(topic) {
577
706
  'Run "metergraph help login" or "metergraph help logout" for sign in options.',
578
707
  'Run "metergraph help verify" for exact-trace verification options.',
579
708
  'Run "metergraph help COMMAND" for status, context, capabilities, usage, routes or traces.',
709
+ "",
710
+ ...AGENT_PATH_HELP,
580
711
  );
581
712
  }
582
713
  lines.push("", "Exit codes:");
583
714
  for (const [outcome, code] of Object.entries(EXIT_CODES)) {
584
- lines.push(` ${String(code).padEnd(3)}${outcome.padEnd(25)}${EXIT_CODE_MEANINGS[outcome]}`);
715
+ lines.push(` ${String(code).padEnd(3)}${outcome.padEnd(25)}${style.dim(EXIT_CODE_MEANINGS[outcome])}`);
585
716
  }
586
- return `${lines.join("\n")}\n`;
717
+ // Section labels ("Usage:", "Options:", "Exit codes:") in bold.
718
+ return `${lines.map((line) => line.replace(/^(Usage|Options|Exit codes):/, (label) => style.bold(label))).join("\n")}\n`;
587
719
  }
588
720
 
589
- export function skillText(result, message) {
721
+ export function skillText(result, message, style = PLAIN) {
590
722
  const report = result.data;
591
723
  const label = SKILL_CLIENTS[report.client]?.label ?? HANDOFF_SKILL_CLIENTS[report.client];
592
- const lines = [`Metergraph ${result.command}: ${label}, ${report.runtime} runtime`];
593
- if (report.path !== null) lines.push(`Path: ${report.path}`);
724
+ const verb = result.command === "skill update" ? "updated" : "installed";
725
+ const lines = [
726
+ result.ok
727
+ ? headline(style, "ok", `Metergraph skill ${report.status === "unchanged" ? "already up to date" : verb} for ${label}`)
728
+ : headline(style, toneFor(result.outcome), `Metergraph skill not ${verb} for ${label}`, result.outcome),
729
+ "",
730
+ ];
731
+ const rows = [["Runtime", report.runtime], ["Path", report.path]];
594
732
  if (result.ok) {
595
- lines.push(
596
- `Status: ${report.status}`,
597
- `Source revision: ${report.source.revision} (sha256 ${report.source.sha256})`,
598
- `Discovery: pending until ${label} loads the skill`,
599
- "Authenticated: no",
600
- "",
601
- message,
602
- `Next: ${report.next_action.message}`,
733
+ rows.push(
734
+ ["Status", report.status],
735
+ ["Source", `${report.source.revision} ${style.dim(`(sha256 ${report.source.sha256})`)}`],
736
+ ["Discovery", `pending until ${label} loads the skill`],
737
+ ["Authenticated", "no"],
603
738
  );
604
- } else {
605
- lines.push("", `Result: ${result.outcome} (exit ${result.exit_code})`, message);
606
- if (report.next_action?.kind === "connection_guide") {
607
- lines.push(`Connection guide: ${report.next_action.url}`);
608
- }
609
739
  }
610
- return `${lines.join("\n")}\n`;
740
+ lines.push(...fields(style, rows), "");
741
+ if (message) lines.push(...paragraph(style, message), "");
742
+ lines.push(...steps(style, [
743
+ ["", result.ok ? report.next_action.message : null],
744
+ ["Connection guide", !result.ok && report.next_action?.kind === "connection_guide" ? report.next_action.url : null],
745
+ ]));
746
+ return render(lines);
611
747
  }
612
748
 
613
- export function skillsText(result, message) {
749
+ export function skillsText(result, message, style = PLAIN) {
614
750
  const report = result.data;
615
751
  const lines = [];
752
+ const source = report.source
753
+ ? ["Source", `${report.source.repository} ${style.dim(`at ${report.source.commit.slice(0, 12)}`)}`]
754
+ : ["Source", null];
616
755
  if (result.command === "skills list") {
617
- lines.push("Metergraph skills bundled with this CLI");
618
- if (report.source) lines.push(`Source: ${report.source.repository} at ${report.source.commit.slice(0, 12)}`);
619
- if (!result.ok) {
620
- lines.push("", `Result: ${result.outcome} (exit ${result.exit_code})`, message);
621
- return `${lines.join("\n")}\n`;
622
- }
756
+ lines.push(
757
+ result.ok
758
+ ? headline(style, "info", "Metergraph skills bundled with this CLI")
759
+ : headline(style, toneFor(result.outcome), "Could not list Metergraph skills", result.outcome),
760
+ "",
761
+ ...fields(style, [source]),
762
+ );
763
+ if (!result.ok) return render([...lines, "", ...paragraph(style, message)]);
623
764
  for (const skill of report.skills) {
624
- lines.push("", `${skill.name} (${skill.revision})`);
625
- for (const [client, state] of Object.entries(skill.installs)) {
626
- lines.push(` ${SKILL_CLIENTS[client].label.padEnd(12)}${SKILLS_STATE_LABELS[state] ?? state}`);
627
- }
765
+ lines.push("", `${section(style, skill.name)} ${style.dim(skill.revision)}`);
766
+ lines.push(...fields(style, Object.entries(skill.installs).map(([client, state]) =>
767
+ [SKILL_CLIENTS[client].label, state === "installed" ? style.green(SKILLS_STATE_LABELS[state]) : (SKILLS_STATE_LABELS[state] ?? state)])));
628
768
  }
629
- return `${lines.join("\n")}\n`;
769
+ return render(lines);
630
770
  }
631
771
  const label = SKILL_CLIENTS[report.client]?.label ?? HANDOFF_SKILL_CLIENTS[report.client] ?? report.client;
632
- lines.push(`Metergraph ${result.command}: ${label}, ${report.runtime} runtime`);
633
- if (report.source) lines.push(`Source: ${report.source.repository} at ${report.source.commit.slice(0, 12)}`);
634
- for (const skill of report.skills) {
635
- const status = skill.status === "failed" ? `failed (${skill.reason})` : skill.status;
636
- lines.push(` ${skill.name.padEnd(24)}${status.padEnd(12)}${skill.path}`);
772
+ const verb = result.command === "skills update" ? "updated" : "installed";
773
+ lines.push(
774
+ result.ok
775
+ ? headline(style, "ok", `Metergraph skills ${verb} for ${label}`)
776
+ : headline(style, toneFor(result.outcome), `Metergraph skills not ${verb} for ${label}`, result.outcome),
777
+ "",
778
+ ...fields(style, [["Runtime", report.runtime], source]),
779
+ );
780
+ if (report.skills.length > 0) {
781
+ lines.push("", ...fields(style, report.skills.map((skill) => {
782
+ const status = skill.status === "failed" ? style.red(`failed (${skill.reason})`) : skill.status;
783
+ return [skill.name, `${status} ${style.dim(skill.path ?? "")}`];
784
+ })));
637
785
  }
638
786
  if (result.ok) {
639
- lines.push(
640
- `Discovery: pending until ${label} loads the skills`,
641
- "Authenticated: no",
642
- "",
643
- `Next: ${report.next_action.message}`,
644
- );
787
+ lines.push("", ...fields(style, [["Discovery", `pending until ${label} loads the skills`], ["Authenticated", "no"]]),
788
+ "", ...steps(style, [["", report.next_action.message]]));
645
789
  } else {
646
- lines.push("", `Result: ${result.outcome} (exit ${result.exit_code})`, message);
647
- if (report.next_action?.kind === "plugin_marketplace") lines.push(`Next: ${report.next_action.message}`);
648
- if (report.next_action?.kind === "connection_guide") lines.push(`Connection guide: ${report.next_action.url}`);
790
+ lines.push("", ...paragraph(style, message), "", ...steps(style, [
791
+ ["", report.next_action?.kind === "plugin_marketplace" ? report.next_action.message : null],
792
+ ["Connection guide", report.next_action?.kind === "connection_guide" ? report.next_action.url : null],
793
+ ]));
649
794
  }
650
- return `${lines.join("\n")}\n`;
795
+ return render(lines);
651
796
  }
652
797
 
653
798
  export function versionData() {
@@ -693,35 +838,48 @@ export function doctorMessage(outcome, reason) {
693
838
  return `${detail.charAt(0).toUpperCase()}${detail.slice(1)}.`;
694
839
  }
695
840
 
696
- export function doctorText(result) {
841
+ const CHECK_MARKS = { pass: ["✓", "green"], fail: ["✗", "red"], skipped: ["-", "dim"] };
842
+
843
+ export function doctorText(result, style = PLAIN) {
697
844
  const report = result.data;
698
- const lines = ["Metergraph doctor (read only, no credentials sent)", `Origin: ${report.origin}`, ""];
845
+ const title = result.outcome === "authentication_required"
846
+ ? "Service is reachable and requires sign in"
847
+ : result.ok ? "Service is healthy" : "Service check failed";
848
+ const lines = [
849
+ headline(style, toneFor(result.outcome), title, result.outcome),
850
+ "",
851
+ ...fields(style, [["Origin", report.origin]]),
852
+ ` ${style.dim("Read only, no credentials sent")}`,
853
+ "",
854
+ ];
855
+ const width = Math.max(...report.checks.map((check) => CHECK_LABELS[check.name].length));
699
856
  for (const check of report.checks) {
700
- let line = ` ${check.result.padEnd(8)}${CHECK_LABELS[check.name]} (${check.path})`;
857
+ const [mark, color] = CHECK_MARKS[check.result] ?? ["?", "dim"];
858
+ let line = ` ${style[color](mark)} ${CHECK_LABELS[check.name].padEnd(width)} ${style.dim(check.path)}`;
701
859
  if (check.name === "deployment" && report.deployment_profile !== null) {
702
- line += `: ${report.deployment_profile}`;
860
+ line += ` ${report.deployment_profile}`;
703
861
  } else if (check.reason !== null) {
704
- line += `: ${REASON_TEXT[check.reason] ?? check.reason}`;
862
+ line += ` ${REASON_TEXT[check.reason] ?? check.reason}`;
705
863
  }
706
864
  lines.push(line);
707
865
  }
708
- lines.push("", `Result: ${result.outcome} (exit ${result.exit_code})`);
709
- if (result.error !== null) lines.push(result.error.message);
866
+ if (result.error !== null) lines.push("", ...paragraph(style, result.error.message));
710
867
  if (result.outcome === "authentication_required") {
711
- lines.push(
712
- "Doctor sends no credentials. To connect an application, follow the connection guide:",
713
- CONNECTION_GUIDE_URL,
714
- );
868
+ lines.push("", ...steps(style, [
869
+ ["Sign in from this machine", "metergraph login --runtime local"],
870
+ ["Connect an application", CONNECTION_GUIDE_URL],
871
+ ]));
715
872
  }
716
- return `${lines.join("\n")}\n`;
873
+ return render(lines);
717
874
  }
718
875
 
719
876
  // Fixed text for every login, logout and session reason. Reasons shared
720
877
  // with doctor fall back to its wording.
721
878
  const AUTH_MESSAGES = {
722
879
  runtime_not_supported:
723
- "Sign in needs a browser on the same machine as the CLI, so cloud runtimes cannot complete it. " +
724
- "Follow the connection guide instead. Nothing was written.",
880
+ "--runtime says where this terminal runs, not which Metergraph service you sign in to. " +
881
+ "A cloud runtime is a remote sandbox, and sign in needs a browser on the same machine as the CLI, " +
882
+ "so it cannot finish there. Nothing was written.",
725
883
  ssh_session:
726
884
  "This is a remote shell session. A browser on your own machine cannot reach this machine's loopback " +
727
885
  "address, so sign in cannot finish here. Run login where your browser runs. Nothing was written.",
@@ -823,36 +981,73 @@ export function authMessage(outcome, reason) {
823
981
  return AUTH_MESSAGES[reason] ?? doctorMessage(outcome, reason);
824
982
  }
825
983
 
826
- export function authText(result, message) {
984
+ // Short headlines for the reasons people meet most. Others use a generic one.
985
+ const AUTH_TITLES = {
986
+ runtime_not_supported: "Sign in cannot run from a cloud runtime",
987
+ ssh_session: "Sign in cannot run over SSH",
988
+ cloud_workspace: "Sign in cannot run in a cloud development environment",
989
+ ci_environment: "Sign in cannot run in CI",
990
+ browser_approval_required: "Waiting for approval in the browser",
991
+ access_denied: "Sign in was declined in the browser",
992
+ timeout: "Sign in timed out",
993
+ cancelled: "Sign in was cancelled",
994
+ bound_to_other_origin: "This project is bound to a different origin",
995
+ bound_to_other_workspace: "This project is bound to a different workspace",
996
+ not_signed_in: "This project is not signed in",
997
+ revocation_unconfirmed: "Signed out locally; revocation not confirmed",
998
+ };
999
+
1000
+ export function authText(result, message, style = PLAIN) {
827
1001
  const report = result.data;
828
- const lines = [`Metergraph ${result.command}`];
829
- if (report.origin !== null) lines.push(`Origin: ${report.origin}`);
830
- if (report.deployment_profile) lines.push(`Deployment profile: ${report.deployment_profile}`);
831
- if (report.workspace !== null) lines.push(`Workspace: ${report.workspace.id}`);
832
- if (result.command === "login") {
833
- if (result.ok) {
834
- const status = { signed_in: "signed in", reused: "already signed in", reconnected: "reconnected" }[report.status];
835
- lines.push(
836
- `Status: ${status}`,
837
- `Scopes: ${report.scopes.join(" ")}`,
838
- `Binding: ${report.binding}`,
839
- `Grant storage: ${report.credential_protection === "dpapi" ? "Windows DPAPI, current user" : "owner-only file"}`,
840
- );
841
- if (report.previous_grant_revocation === "unconfirmed") {
842
- lines.push("The previous grant was removed locally; the service did not confirm its revocation.");
843
- }
844
- }
1002
+ const login = result.command === "login";
1003
+ const reason = result.error?.reason ?? null;
1004
+ let title;
1005
+ if (result.ok && login) {
1006
+ title = { signed_in: "Signed in", reused: "Already signed in", reconnected: "Reconnected" }[report.status];
1007
+ } else if (result.ok) {
1008
+ title = report.binding === "none" ? "This project was not signed in" : "Signed out";
845
1009
  } else {
846
- lines.push(
847
- `Saved grant: ${report.local_credentials}`,
848
- `Binding: ${report.binding}`,
849
- `Server revocation: ${report.revocation.replace("_", " ")}`,
1010
+ title = AUTH_TITLES[reason] ?? (login ? "Sign in did not complete" : "Sign out did not complete");
1011
+ }
1012
+ const lines = [headline(style, toneFor(result.outcome), title, result.outcome), ""];
1013
+ const rows = [
1014
+ ["Origin", report.origin],
1015
+ ["Workspace", report.workspace?.id ?? null],
1016
+ ["Profile", report.deployment_profile || null],
1017
+ ];
1018
+ if (login && result.ok) {
1019
+ rows.push(
1020
+ ["Scopes", report.scopes.join(" ")],
1021
+ ["Binding", report.binding],
1022
+ ["Grant storage", report.credential_protection === "dpapi" ? "Windows DPAPI, current user" : "owner-only file"],
1023
+ );
1024
+ } else if (!login) {
1025
+ rows.push(
1026
+ ["Saved grant", report.local_credentials],
1027
+ ["Binding", report.binding],
1028
+ ["Server revocation", report.revocation.replace("_", " ")],
850
1029
  );
851
- if (result.ok && report.binding === "none") lines.push("This project was not signed in.");
852
1030
  }
853
- if (!result.ok) lines.push("", `Result: ${result.outcome} (exit ${result.exit_code})`, message);
1031
+ lines.push(...fields(style, rows), "");
1032
+ if (login && result.ok && report.previous_grant_revocation === "unconfirmed") {
1033
+ lines.push(...paragraph(style, "The previous grant was removed locally; the service did not confirm its revocation."), "");
1034
+ }
1035
+ if (!result.ok) lines.push(...paragraph(style, message), "");
854
1036
  const next = report.next_action;
855
- if (next?.message) lines.push(`Next: ${next.message}`);
856
- if (next?.url) lines.push(`Connection guide: ${next.url}`);
857
- return `${lines.join("\n")}\n`;
1037
+ const guide = next?.url ?? null;
1038
+ // A remote handoff names the Keys page where a person creates the
1039
+ // Metadata key the agent then reads from METERGRAPH_AGENT_TOKEN.
1040
+ const keys = next?.kind === "use_agent_key"
1041
+ ? next.person_steps.find((step) => step.action === "create_metadata_key")?.url ?? null
1042
+ : null;
1043
+ if (reason === "runtime_not_supported") {
1044
+ lines.push(...steps(style, [
1045
+ ["Signing in from this machine?", style.bold("metergraph login --runtime local")],
1046
+ ["Connecting a cloud agent?", guide],
1047
+ ["Metadata key for the agent", keys],
1048
+ ]));
1049
+ } else {
1050
+ lines.push(...steps(style, [["", next?.message ?? null], ["Keys page", keys], ["Connection guide", guide]]));
1051
+ }
1052
+ return render(lines);
858
1053
  }