metergraph-cli 0.2.0-preview.4 → 0.2.0-preview.6

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/auth-login.js CHANGED
@@ -39,6 +39,11 @@ import { deadline, trapSignals } from "./transport.js";
39
39
  // server-verified workspace UUID. Token values, the authorization code, the
40
40
  // PKCE verifier, absolute paths and server text never leave this module.
41
41
  // progress(text) prints human progress lines; it is a no-op with --json.
42
+ // options.announce(data), given only with --json --no-browser, receives the
43
+ // login data with an open_url next action as soon as the authorization URL
44
+ // exists, while the listener keeps waiting. The caller prints it as the one
45
+ // JSON line, so an agent can hand the URL to a person; approval-handoff.js
46
+ // lets the agent's command exit then while this process keeps waiting.
42
47
 
43
48
  export async function runLogin(options, progress) {
44
49
  const ctx = {
@@ -58,21 +63,12 @@ export async function runLogin(options, progress) {
58
63
  if (options.runtime !== "local") return end("unsupported", "runtime_not_supported", GUIDE);
59
64
  const remote = detectRemoteSession();
60
65
  if (remote !== null) return end("unsupported", remote, GUIDE);
61
- // --json cannot show a person the --no-browser URL. A rerun with a saved,
62
- // working grant needs no URL, so only refuse once approval is required.
63
- const noBrowserHandoff = () => end("unsupported", "no_browser_requires_terminal", {
64
- kind: "run_in_terminal",
65
- message: "Run the same login command in a terminal without --json to see the sign in URL.",
66
- });
67
- const urlUnshowable = options.json && options.noBrowser;
68
-
69
66
  const trap = trapSignals();
70
67
  try {
71
68
  const root = resolveProject(options.project);
72
69
  const configDir = resolveConfigDir(options.configDir);
73
70
  const existing = readBinding(root);
74
71
  const bound = existing?.binding ?? null;
75
- if (urlUnshowable && (bound === null || options.reconnect)) return noBrowserHandoff();
76
72
  // Setup pins a deployment model before sign in. A stale binding for a
77
73
  // different profile must never be silently reused or reconnected.
78
74
  if (options.expectedProfile && bound !== null && !options.reconnect &&
@@ -102,9 +98,11 @@ export async function runLogin(options, progress) {
102
98
  }
103
99
  if (session.outcome !== "login_required") return end(session.outcome, session.reason);
104
100
  }
105
- if (urlUnshowable) return noBrowserHandoff();
106
101
 
107
- const flow = await authorize(options, ctx, trap.signal, progress);
102
+ const announce = options.announce
103
+ ? (next) => options.announce(loginData({ ...ctx, status: "approval_pending" }, next))
104
+ : null;
105
+ const flow = await authorize(options, ctx, trap.signal, progress, announce);
108
106
  if (!flow.ok) return end(flow.outcome, flow.reason, flow.next ?? null);
109
107
  const { endpoints, clientId, grant } = flow;
110
108
 
@@ -169,7 +167,7 @@ export async function runLogin(options, progress) {
169
167
  // written here. A validated grant that is not accepted is sent for revocation
170
168
  // before returning; that is best effort, and a token response that fails
171
169
  // validation cannot be revoked.
172
- async function authorize(options, ctx, cancel, progress) {
170
+ async function authorize(options, ctx, cancel, progress, announce) {
173
171
  const preflight = await runDoctor({ origin: ctx.origin, timeoutMs: AUTH_HTTP_TIMEOUT_MS });
174
172
  if (cancel.aborted) return fail("authorization_failed", "cancelled");
175
173
  if (preflight.outcome !== "authentication_required") return fail(preflight.outcome, preflight.reason);
@@ -217,7 +215,9 @@ async function authorize(options, ctx, cancel, progress) {
217
215
  const seconds = Math.round(options.timeoutMs / 1000);
218
216
  // A wait armed after Ctrl+C has already settled; do not show the URL.
219
217
  if (cancel.aborted) return fail("authorization_failed", "cancelled");
220
- if (options.noBrowser) {
218
+ if (options.noBrowser && announce) {
219
+ announce(openUrl(url, seconds, "sign in with Metadata access"));
220
+ } else if (options.noBrowser) {
221
221
  progress(
222
222
  "Open this URL in a browser on this machine to sign in:\n" +
223
223
  `${url}\n` +
@@ -375,6 +375,20 @@ function fail(outcome, reason, next = null) {
375
375
  return { ok: false, outcome, reason, next };
376
376
  }
377
377
 
378
+ // The next action a --json --no-browser run prints while it waits. The URL is
379
+ // the authorization request: public client ID, loopback redirect, state and
380
+ // PKCE challenge. It carries no token, code or verifier.
381
+ export function openUrl(url, seconds, purpose) {
382
+ return {
383
+ kind: "open_url",
384
+ url,
385
+ timeout_seconds: seconds,
386
+ message: `Show this URL to the person now and ask them to open it in a browser on this machine to ` +
387
+ `${purpose}. A background process waits up to ${seconds} seconds for their approval. ` +
388
+ "After they approve, run the same command again to continue.",
389
+ };
390
+ }
391
+
378
392
  const GUIDE = { kind: "connection_guide", url: CONNECTION_GUIDE_URL };
379
393
  const RECONNECT = {
380
394
  kind: "reconnect",
package/src/cli.js CHANGED
@@ -10,14 +10,15 @@ import {
10
10
  helpData,
11
11
  helpText,
12
12
  skillText,
13
+ skillsText,
13
14
  toJsonLine,
14
15
  versionData,
15
16
  } from "./output.js";
16
17
  import { runRead } from "./read.js";
17
18
  import { readMessage, readText } from "./read-output.js";
18
- import { runSkill } from "./skill.js";
19
+ import { listSkillPack, runSkill, runSkillPack } from "./skill.js";
19
20
  import { runSetup } from "./setup.js";
20
- import { containsKnownCredential, preflightNonHostedSetup } from "./setup-deployment.js";
21
+ import { containsKnownCredential, preflightNonHostedSetup, prerequisiteGuide, resumeNonHostedRoute } from "./setup-deployment.js";
21
22
  import { VERSION } from "./constants.js";
22
23
  import { runVerify } from "./verify.js";
23
24
  import { verifyMessage, verifyText } from "./verify-output.js";
@@ -74,8 +75,10 @@ async function run(argv, { stdout, stderr }) {
74
75
  // Human progress goes to stderr, never with --json, so stdout carries
75
76
  // only the final result.
76
77
  const progress = parsed.json ? () => {} : (text) => stderr.write(`${text}\n`);
78
+ const pending = announcer(parsed, stdout, (data) => data);
77
79
  const { outcome, reason, data } =
78
- parsed.command === "login" ? await runLogin(parsed, progress) : await runLogout(parsed);
80
+ parsed.command === "login" ? await runLogin({ ...parsed, announce: pending.announce }, progress) : await runLogout(parsed);
81
+ if (pending.result) return pending.result;
79
82
  const message = authMessage(outcome, reason);
80
83
  const result = envelope({
81
84
  command: parsed.command,
@@ -89,14 +92,18 @@ async function run(argv, { stdout, stderr }) {
89
92
  }
90
93
 
91
94
  if (parsed.command === "setup") {
92
- const routed = parsed.deployment === "managed"
95
+ const options = resumeNonHostedRoute(parsed);
96
+ const routed = options.deployment === "managed"
93
97
  ? { proceed: true, profile: "managed" }
94
- : await preflightNonHostedSetup(parsed);
98
+ : await preflightNonHostedSetup(options);
95
99
  const progress = parsed.json ? () => {} : (line) =>
96
100
  stderr.write(containsKnownCredential(line, routed) ? "Setup progress.\n" : `${line}\n`);
101
+ // The announced line is checked for known credentials like the final one.
102
+ const pending = announcer(parsed, stdout, (data) => containsKnownCredential(data, routed) ? null : data);
97
103
  const response = routed.proceed
98
- ? await runSetup({ ...parsed, expectedProfile: routed.profile ?? null }, progress)
104
+ ? await runSetup({ ...options, expectedProfile: routed.profile ?? null, announce: pending.announce }, progress)
99
105
  : routed;
106
+ if (pending.result) return pending.result;
100
107
  const { outcome, reason, data } = response;
101
108
  let result = envelope({ command: "setup", outcome, reason, data,
102
109
  message: outcome === "ok" ? null : "Setup did not complete. Review the reason and retry safely." });
@@ -106,7 +113,8 @@ async function run(argv, { stdout, stderr }) {
106
113
  const receipt = result.data?.receipt;
107
114
  const summary = receipt ? `Workspace: ${receipt.workspace_id}\nDeployment: ${receipt.deployment_profile} at ${receipt.origin}\nCompleted: ${receipt.completed_steps.join(", ")}\nPending: ${receipt.pending_steps.join(", ")}\n` : "";
108
115
  const identity = repositoryLine(result.data?.repository);
109
- const human = `Metergraph setup: ${result.data?.status ?? "not_ready"}\n${summary}${identity}Application traffic verified: no\n${result.ok ? "Next: instrument your application and verify an exact trace.\n" : `Result: ${result.outcome} (${result.error.reason})\n`}`;
116
+ const guide = handoffLines(result.data);
117
+ const human = `Metergraph setup: ${result.data?.status ?? "not_ready"}\n${summary}${identity}${guide}Application traffic verified: no\n${result.ok ? "Next: instrument your application and verify an exact trace.\n" : `Result: ${result.outcome} (${result.error.reason})\n`}`;
110
118
  if (containsKnownCredential(human, routed)) {
111
119
  result = envelope({ command: "setup", outcome: "unsupported", reason: "credential_echo" });
112
120
  stdout.write(parsed.json ? toJsonLine(result) : "Setup stopped.\n");
@@ -151,6 +159,20 @@ async function run(argv, { stdout, stderr }) {
151
159
  return result;
152
160
  }
153
161
 
162
+ if (parsed.command === "skills") {
163
+ const { outcome, reason, message, data } =
164
+ parsed.action === "list" ? listSkillPack(parsed) : runSkillPack(parsed);
165
+ const result = envelope({
166
+ command: `skills ${parsed.action}`,
167
+ outcome,
168
+ reason,
169
+ data,
170
+ message: outcome === "ok" ? null : message,
171
+ });
172
+ stdout.write(parsed.json ? toJsonLine(result) : skillsText(result, message));
173
+ return result;
174
+ }
175
+
154
176
  const { outcome, reason, report } = await runDoctor({
155
177
  origin: parsed.origin,
156
178
  timeoutMs: parsed.timeoutMs,
@@ -166,6 +188,47 @@ async function run(argv, { stdout, stderr }) {
166
188
  return result;
167
189
  }
168
190
 
191
+ // With --json --no-browser, login and setup print their one JSON line as
192
+ // soon as a person must open an approval URL, then keep waiting for the
193
+ // callback; bin/metergraph.js has already moved this process to the
194
+ // background (approval-handoff.js). Whatever the run does afterwards, that
195
+ // line is its result; the agent reruns the command to continue. screen(data) returns
196
+ // the data to print, or null to print a credential_echo refusal instead.
197
+ function announcer(parsed, stdout, screen) {
198
+ const pending = { announce: null, result: null };
199
+ if (!parsed.json || !parsed.noBrowser) return pending;
200
+ pending.announce = (data) => {
201
+ if (pending.result) return;
202
+ const safe = screen(data);
203
+ pending.result = safe === null
204
+ ? envelope({ command: parsed.command, outcome: "unsupported", reason: "credential_echo" })
205
+ : envelope({ command: parsed.command, outcome: "action_required", reason: "browser_approval_required",
206
+ data: safe, message: authMessage("action_required", "browser_approval_required") });
207
+ stdout.write(toJsonLine(pending.result));
208
+ };
209
+ return pending;
210
+ }
211
+
212
+ // What an operator handoff is waiting for, in fixed text: the route, each
213
+ // unconfirmed prerequisite with its guidance, and the next action.
214
+ function handoffLines(data) {
215
+ if (data?.status !== "operator_handoff") return "";
216
+ let text = data.origin ? `Deployment: ${data.deployment_profile} at ${data.origin}\n` : "";
217
+ if (data.workspace_id) text += `Workspace: ${data.workspace_id}\n`;
218
+ if (data.pending_prerequisites.length > 0) {
219
+ text += "Confirm each prerequisite, then rerun with --confirm-prerequisites:\n";
220
+ for (const name of data.pending_prerequisites) {
221
+ text += ` - ${name}: ${prerequisiteGuide(name, data.deployment_profile) ?? "see the guide"}\n`;
222
+ }
223
+ }
224
+ const next = data.next_action;
225
+ if (next?.message && !(next.kind === "complete_prerequisite" && data.pending_prerequisites.includes(next.prerequisite))) {
226
+ text += `Next: ${next.message}\n`;
227
+ }
228
+ if (next?.url) text += `Guide: ${next.url}\n`;
229
+ return text;
230
+ }
231
+
169
232
  // One line on the repository identity setup found or recorded. The value is
170
233
  // owner/name only; a remote URL is never shown.
171
234
  function repositoryLine(repository) {
package/src/constants.js CHANGED
@@ -39,6 +39,10 @@ export const SKILL_CLIENTS = Object.freeze({
39
39
  });
40
40
  export const SKILL_RUNTIMES = Object.freeze(["local", "cloud"]);
41
41
 
42
+ // Where every Metergraph skill is published. Claude Desktop installs the
43
+ // skills from here as a plugin marketplace instead of from project files.
44
+ export const SKILLS_REPOSITORY_URL = "https://github.com/metergraph/skills";
45
+
42
46
  // Recognized values that cannot load project skill files. They get a pointer
43
47
  // to the connection guide instead of a usage error, and nothing is written.
44
48
  export const HANDOFF_SKILL_CLIENTS = Object.freeze({
@@ -137,6 +141,7 @@ export const EXIT_CODES = Object.freeze({
137
141
  permission_denied: 15,
138
142
  rate_limited: 16,
139
143
  cancelled: 17,
144
+ action_required: 18,
140
145
  });
141
146
 
142
147
  export const EXIT_CODE_MEANINGS = Object.freeze({
@@ -173,4 +178,8 @@ export const EXIT_CODE_MEANINGS = Object.freeze({
173
178
  rate_limited: "The service asked the CLI to slow down. Nothing was retried. Try again later.",
174
179
  cancelled:
175
180
  "A read command was interrupted before it finished. Read commands never change workspace configuration or telemetry.",
181
+ action_required:
182
+ "A person must act before this command can finish; data.next_action says what to do. " +
183
+ "With --json --no-browser the command exits as soon as the approval URL exists, and a background " +
184
+ "process waits for approval until --timeout-ms. After approval, run the same command again to continue.",
176
185
  });
@@ -117,7 +117,7 @@ const DEPLOYMENT_PATH = "/v1/deployment";
117
117
  const MIN_TIMEOUT_MS = 100;
118
118
  const MAX_TIMEOUT_MS = 60000;
119
119
 
120
- function action(kind, prerequisite = null) {
120
+ export function action(kind, prerequisite = null) {
121
121
  // Kinds are constants from this module, so this is a programming error.
122
122
  if (!ACTION_KINDS.includes(kind)) throw new Error("unknown next action kind");
123
123
  return { kind, prerequisite, url: CONNECTION_GUIDE_URL };
package/src/output.js CHANGED
@@ -73,15 +73,46 @@ const SKILL_OPTIONS = [
73
73
  {
74
74
  name: "--runtime",
75
75
  value: "RUNTIME",
76
- summary: "Required. local or cloud: where the client runs. Recorded, not detected.",
76
+ summary: "local (default) or cloud: where the client runs. Recorded, not detected.",
77
77
  },
78
78
  { name: "--project", value: "DIR", summary: "Existing project directory. Default: the current directory." },
79
79
  { name: "--json", value: null, summary: "Print one JSON line on stdout." },
80
80
  ];
81
81
 
82
82
  const SKILL_USAGE = [
83
- "metergraph skill install --client CLIENT --runtime RUNTIME [--project DIR] [--json]",
84
- "metergraph skill update --client CLIENT --runtime RUNTIME [--project DIR] [--json]",
83
+ "metergraph skill install --client CLIENT [--runtime RUNTIME] [--project DIR] [--json]",
84
+ "metergraph skill update --client CLIENT [--runtime RUNTIME] [--project DIR] [--json]",
85
+ ];
86
+
87
+ const SKILLS_USAGE = [
88
+ "metergraph skills install --client CLIENT [--runtime RUNTIME] [--project DIR] [--json]",
89
+ "metergraph skills update --client CLIENT [--runtime RUNTIME] [--project DIR] [--json]",
90
+ "metergraph skills list [--project DIR] [--json]",
91
+ ];
92
+
93
+ const SKILLS_LIST_OPTIONS = [
94
+ { name: "--project", value: "DIR", summary: "Existing project directory. Default: the current directory." },
95
+ { name: "--json", value: null, summary: "Print one JSON line on stdout." },
96
+ ];
97
+
98
+ const SKILLS_STATE_LABELS = {
99
+ not_installed: "not installed",
100
+ installed: "installed",
101
+ outdated: "older revision; run skills update",
102
+ modified: "changed since install; left alone",
103
+ missing: "receipt only; run skills install",
104
+ not_owned: "not installed by this CLI; left alone",
105
+ receipt_invalid: "receipt not valid",
106
+ unsafe_path: "unsafe path",
107
+ };
108
+
109
+ // How a coding agent gets an approval URL from login or setup. Many agent
110
+ // shells show output only after the command exits, which is too late.
111
+ const AGENT_APPROVAL_HELP = [
112
+ "From a coding agent: with --json --no-browser, a step that needs approval exits at once",
113
+ "with exit 18 action_required and the URL in data.next_action.url, while a background",
114
+ "process keeps waiting for the approval until --timeout-ms. Show the URL to the person,",
115
+ "and after they approve, run the same command again to continue.",
85
116
  ];
86
117
 
87
118
  const CONFIG_DIR_OPTION = {
@@ -112,7 +143,7 @@ const LOGIN_OPTIONS = [
112
143
  summary: `Time to wait for the browser, ${LOGIN_MIN_TIMEOUT_MS} to ${LOGIN_MAX_TIMEOUT_MS}. Default ${LOGIN_DEFAULT_TIMEOUT_MS}.`,
113
144
  },
114
145
  { name: "--signup", value: null, summary: "Start at the hosted sign up page. Managed service only." },
115
- { name: "--no-browser", value: null, summary: "Print the sign in URL on stderr instead of opening a browser. With --json, only a rerun that needs no sign in succeeds." },
146
+ { name: "--no-browser", value: null, summary: "Print the sign in URL on stderr instead of opening a browser. With --json, exit 18 with it in data.next_action (open_url)." },
116
147
  { name: "--reconnect", value: null, summary: "Allow switching a bound project to another origin or workspace." },
117
148
  { name: "--json", value: null, summary: "Print one JSON line on stdout." },
118
149
  ];
@@ -257,6 +288,7 @@ export function helpData(topic) {
257
288
  "metergraph --version [--json]",
258
289
  "metergraph doctor [--url ORIGIN] [--timeout-ms N] [--json]",
259
290
  ...SKILL_USAGE,
291
+ ...SKILLS_USAGE,
260
292
  LOGIN_USAGE,
261
293
  LOGOUT_USAGE,
262
294
  SETUP_USAGE,
@@ -283,6 +315,25 @@ export function helpData(topic) {
283
315
  "Replace a skill this CLI installed, and that is unchanged since, with the bundled revision.",
284
316
  options: SKILL_OPTIONS,
285
317
  },
318
+ {
319
+ name: "skills install",
320
+ summary:
321
+ "Copy the Metergraph workflow skills bundled with this CLI, such as the model-swap loop, into one " +
322
+ "client's project skill directory. Never replaces a skill it did not install. No network requests, no sign in.",
323
+ options: SKILL_OPTIONS,
324
+ },
325
+ {
326
+ name: "skills update",
327
+ summary:
328
+ "Replace workflow skills this CLI installed, and that are unchanged since, with the bundled revisions, " +
329
+ "and install skills added since.",
330
+ options: SKILL_OPTIONS,
331
+ },
332
+ {
333
+ name: "skills list",
334
+ summary: "List the bundled workflow skills and whether each is installed for each client. Writes nothing.",
335
+ options: SKILLS_LIST_OPTIONS,
336
+ },
286
337
  {
287
338
  name: "login",
288
339
  summary:
@@ -311,7 +362,7 @@ export function helpData(topic) {
311
362
  { name: "--timeout-ms", value: "N", summary: "Time to wait for browser approval." },
312
363
  { name: "--signup", value: null, summary: "Start at hosted sign up when the project needs login." },
313
364
  { name: "--reconnect", value: null, summary: "Permit switching an existing project binding." },
314
- { name: "--no-browser", value: null, summary: "Print approval URL on stderr. With --json, only a rerun that needs no approval succeeds." },
365
+ { name: "--no-browser", value: null, summary: "Print approval URL on stderr. With --json, exit 18 with it in data.next_action (open_url); rerun after approval." },
315
366
  { name: "--repair", value: null, summary: "Explicitly approve replacement of an acknowledged key that no longer verifies." },
316
367
  { name: "--deployment", value: "MODEL", summary: "managed, customer-local, byoc or oss. Default managed." },
317
368
  { name: "--confirm-prerequisites", value: null, summary: "Attest deployment prerequisites are met; it does not verify bundle publication." },
@@ -375,6 +426,7 @@ export function helpText(topic) {
375
426
  const flag = option.value ? `${option.name} ${option.value}` : option.name;
376
427
  lines.push(` ${flag.padEnd(25)}${option.summary}`);
377
428
  }
429
+ lines.push("", ...AGENT_APPROVAL_HELP);
378
430
  } else if (topic === "login" || topic === "logout") {
379
431
  const login = topic === "login";
380
432
  lines.push(`Usage: ${login ? LOGIN_USAGE : LOGOUT_USAGE}`, "");
@@ -407,6 +459,7 @@ export function helpText(topic) {
407
459
  const flag = option.value ? `${option.name} ${option.value}` : option.name;
408
460
  lines.push(` ${flag.padEnd(18)}${option.summary}`);
409
461
  }
462
+ if (login) lines.push("", ...AGENT_APPROVAL_HELP);
410
463
  } else if (topic === "verify") {
411
464
  lines.push(`Usage: ${VERIFY_USAGE}`, "", "Uses the saved Metadata grant to poll for one exact, processed trace.",
412
465
  "The source label is supplied by the caller. A Metadata match alone does not prove application traffic.",
@@ -463,6 +516,40 @@ export function helpText(topic) {
463
516
  "",
464
517
  "Discovery stays pending until the client itself loads the skill.",
465
518
  );
519
+ } else if (topic === "skills") {
520
+ lines.push(
521
+ "Usage:",
522
+ ...SKILLS_USAGE.map((usage) => ` ${usage}`),
523
+ "",
524
+ "Copies the Metergraph workflow skills bundled with this CLI into one client's project",
525
+ "skill directory, one folder per skill, and records ownership of each in",
526
+ ".metergraph/skills/NAME.json. The skills come from https://github.com/metergraph/skills",
527
+ "at the commit this release pins. They cover investigation and the model-swap loop:",
528
+ "choose a workload, choose traces and models, define the eval, run the analysis,",
529
+ "summarize the report and rerun. The setup skill is installed by \"metergraph skill\".",
530
+ "",
531
+ "Writes nothing else. Makes no network requests, does not sign in and does not",
532
+ "configure MCP, client settings, AGENTS.md or CLAUDE.md.",
533
+ "",
534
+ "Options:",
535
+ );
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
+ }
540
+ lines.push(
541
+ "",
542
+ "install never replaces an existing skill. update replaces only skills this CLI",
543
+ "installed and that are unchanged since, and installs skills added since. A conflict",
544
+ "on one skill leaves the others in place. There is no force option.",
545
+ "list takes only --project and reports every client. It never writes.",
546
+ "",
547
+ "Claude Desktop (--client claude-desktop) cannot load project skill files. It exits",
548
+ "with code 6, writes nothing and points to the metergraph/skills plugin marketplace.",
549
+ "ChatGPT and cloud runtimes without a shell get the connection guide instead.",
550
+ "",
551
+ "Discovery stays pending until the client itself loads the skills.",
552
+ );
466
553
  } else {
467
554
  lines.push(
468
555
  `metergraph ${VERSION} (preview)`,
@@ -472,6 +559,7 @@ export function helpText(topic) {
472
559
  " metergraph --version [--json] Show the CLI version",
473
560
  " metergraph doctor [options] Check a Metergraph service, read only",
474
561
  " metergraph skill install|update Install or update the agent skill in a project",
562
+ " metergraph skills install|update|list Workflow skills, such as the model-swap loop",
475
563
  " metergraph login [options] Sign in and bind a project to a workspace",
476
564
  " metergraph logout [options] Revoke and remove a project's sign in",
477
565
  " metergraph setup [options] Approve and write a private ingest key",
@@ -485,6 +573,7 @@ export function helpText(topic) {
485
573
  "",
486
574
  'Run "metergraph help doctor" for doctor options.',
487
575
  'Run "metergraph help skill" for skill options.',
576
+ 'Run "metergraph help skills" for workflow skill options.',
488
577
  'Run "metergraph help login" or "metergraph help logout" for sign in options.',
489
578
  'Run "metergraph help verify" for exact-trace verification options.',
490
579
  'Run "metergraph help COMMAND" for status, context, capabilities, usage, routes or traces.',
@@ -521,6 +610,46 @@ export function skillText(result, message) {
521
610
  return `${lines.join("\n")}\n`;
522
611
  }
523
612
 
613
+ export function skillsText(result, message) {
614
+ const report = result.data;
615
+ const lines = [];
616
+ 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
+ }
623
+ 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
+ }
628
+ }
629
+ return `${lines.join("\n")}\n`;
630
+ }
631
+ 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}`);
637
+ }
638
+ 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
+ );
645
+ } 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}`);
649
+ }
650
+ return `${lines.join("\n")}\n`;
651
+ }
652
+
524
653
  export function versionData() {
525
654
  return { name: PACKAGE_NAME, version: VERSION };
526
655
  }
@@ -601,9 +730,9 @@ const AUTH_MESSAGES = {
601
730
  "so sign in cannot finish here. Follow the connection guide instead. Nothing was written.",
602
731
  ci_environment:
603
732
  "This is a CI environment, where no person can approve sign in in a browser. Nothing was written.",
604
- no_browser_requires_terminal:
605
- "Approval is needed, and --no-browser prints its URL for a person to open, which --json cannot do. " +
606
- "Run the same command without --json in a terminal. Nothing was done.",
733
+ browser_approval_required:
734
+ "A person must approve in a browser. Open data.next_action.url on this machine; a background process " +
735
+ "waits for the approval until the timeout. After approval, run the same command again to continue.",
607
736
  bound_to_other_origin:
608
737
  "This project is bound to a different origin. Nothing was changed. Use --reconnect to switch it.",
609
738
  bound_to_other_workspace:
@@ -619,7 +748,7 @@ const AUTH_MESSAGES = {
619
748
  timeout: "The operation did not finish within the time limit. Nothing was saved.",
620
749
  cancelled: "Sign in was cancelled. Nothing was saved.",
621
750
  browser_unavailable:
622
- "The browser could not be opened. Run login again with --no-browser, without --json, and open the URL it prints.",
751
+ "The browser could not be opened. Run login again with --no-browser and open the URL it prints.",
623
752
  oauth_metadata_missing: "This service does not support CLI sign in yet. Nothing was written.",
624
753
  metadata_scope_unsupported:
625
754
  "This service does not offer Metadata-only access for the CLI yet. The CLI does not fall back to broader access.",