kaloko 0.12.2 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.mjs CHANGED
@@ -20,7 +20,8 @@ import { evaluateRun, semanticPrefetch } from './lib/evaluate.mjs'
20
20
  import { buildManifest, writePreview, attentionLines } from './lib/build.mjs'
21
21
  import { stabilityCmd, pullDrawn } from './lib/stability.mjs'
22
22
  import { signoffCmd } from './lib/signoff.mjs'
23
- import { localized, readJson, UserError, globToRegExp, failureStreak, expiryNote, SUPPORT_LINE, SUPPORT_EMAIL, NO_ORG } from './lib/util.mjs'
23
+ import { localized, readJson, UserError, UsageError, globToRegExp, failureStreak, expiryNote, SUPPORT_LINE, SUPPORT_EMAIL, NO_ORG, EXIT, parseDuration } from './lib/util.mjs'
24
+ import { noteLegacy, legacyHint, resetLegacy, legacyInUse } from './lib/legacy.mjs'
24
25
  import { packageRoot } from './lib/schema.mjs'
25
26
  import { compareRuns, formatComparison } from './lib/compare.mjs'
26
27
  import { doctor, formatDoctor } from './lib/doctor.mjs'
@@ -36,6 +37,7 @@ import { upsertPrComment } from './lib/pr.mjs'
36
37
  import { formatReviewQueue, shareReviewLine } from './lib/review.mjs'
37
38
  import { htmlViewAllowed } from './lib/htmlview.mjs'
38
39
  import { service, serviceUrl } from './lib/service.mjs'
40
+ import { parseArgs, helpFor, checkOptions, closest } from './lib/cli-args.mjs'
39
41
  import { issueCmd, scheduleCmd } from './lib/integrations-cli.mjs'
40
42
  import { login } from './lib/login.mjs'
41
43
  import { initNextSteps, loginNextSteps } from './lib/onboarding.mjs'
@@ -65,6 +67,26 @@ import { attachAndroidWebView } from './lib/apps/webview.mjs'
65
67
  import { piiSettings, piiFindings, piiRequested, guardAllow, formatFindings } from './lib/pii.mjs'
66
68
 
67
69
 
70
+ /** Options every command takes and the exit codes; the end of kaloko help and of the help of each command. */
71
+ const COMMON_HELP = `Common options (every command)
72
+ --run <id|dir> The run to act on (default: the current run, or KALOKO_RUN)
73
+ --config <file> Another kaloko.config.yml
74
+ --org <slug> Another organization than org: in the config
75
+ --json Machine-readable output, where a command prints data: stdout holds only that
76
+ JSON, progress and notes go to stderr, and an error is one line on stderr:
77
+ {"error":{"message":"…","code":"…","next":"…"}} with the exit code below
78
+ --help, -h Help for one command, like kaloko help <command> (kaloko help export run: one
79
+ sub-command)
80
+ --version, -v The version of kaloko (also kaloko version)
81
+ KALOKO_AGENT=<name> Labels writes by an agent
82
+ Durations (--timeout, --wait, --expires, --older-than) take a number in the option's unit or one with s, m, h or d:
83
+ 90s, 10m, 2h, 14d
84
+
85
+ Exit codes: 0 done · 1 checks found problems or the command failed · 2 usage error (unknown command or option,
86
+ missing argument) · 3 the Kaloko service cannot be reached (no connection; an HTTP error answer is 1) · 4 no token,
87
+ or an invalid, expired or revoked one (kaloko login)
88
+ `
89
+
68
90
  const HELP = `kaloko — walk, assess and approve what your agents built, on one flow canvas
69
91
 
70
92
  Usage: kaloko <command> [options]
@@ -88,9 +110,10 @@ Project
88
110
  Write .github/workflows/kaloko-release.yml: when release-please, changesets or
89
111
  semantic-release publishes a GitHub release, kaloko release records it on Kaloko
90
112
  (--snippet prints the step to add after the tool's own step instead)
91
- ci preview-url [--wait 600] [--github-env] [--dotenv file]
113
+ ci preview-url [--wait 10m] [--github-env] [--dotenv file] [--json]
92
114
  In CI: the preview URL (KALOKO_PREVIEW_URL, the deployment event, provider
93
- variables or the commit's GitHub deployments), the pull request and its base
115
+ variables or the commit's GitHub deployments), the pull request and its base;
116
+ --wait polls that long for the deployment (seconds, or 10m)
94
117
  login [--org <slug>] [--url <service>] [--print] [--no-open]
95
118
  Sign this machine in through the browser: approve the code on kaloko.app, choose
96
119
  the organization and what the token may do; the token (at most 30 days) goes to
@@ -99,15 +122,15 @@ Project
99
122
  and app tools; --fix installs missing Playwright browsers, creates the run and
100
123
  scenario folders, writes org: from the token and asks macOS for the desktop
101
124
  capture permissions
102
- validate <scenario.yml>... [--coverage [--source ACCEPTANCE.md]]
125
+ validate <scenario.yml>... [--coverage [--source ACCEPTANCE.md]] [--json]
103
126
  Validate scenarios (schema + references); --coverage compares criteria with the plan
104
127
  With a token it also warns about scenarios the service still lists whose file is gone (--offline skips it)
105
- scenarios List scenarios matched by the config
128
+ scenarios [--json] List scenarios matched by the config
106
129
 
107
130
  Run
108
131
  start --scenario <file> --env <name> [--locales cs,en] [--viewports desktop,mobile] [--executor <name>]
109
132
  [--app-version <v>] (default: the git tag on HEAD; documentation captures carry it)
110
- Create a run directory and make it current
133
+ Create a run directory and make it current (--json: the run id and its folder)
111
134
  [--viewports iphone-15,ipad-landscape,desktop] Viewport names of the scenario or device presets
112
135
  (kaloko devices lists them; <preset>-landscape turns one sideways)
113
136
  [--browsers chromium,webkit,firefox] [--color-schemes light,dark] [--reduced-motion no-preference,reduce]
@@ -120,7 +143,7 @@ Run
120
143
  Design environments: the token modes to render (default every mode of the set)
121
144
  [--pii redact|pseudonymize|off] Hide personal data in every capture (placeholders or consistent fake values);
122
145
  default from the environment's or the scenario's pii
123
- browser start|stop|status [--env e] [--headed] One shared browser per environment (port = browser.port + index)
146
+ browser start|stop|status [--env e] [--headed] [--json] One shared browser per environment (port = browser.port + index)
124
147
  Shared Chromium with CDP (agents, scripts and people drive it)
125
148
  browser install [--with-deps] [--browsers chromium,webkit,firefox]
126
149
  Download the engines of kaloko's Playwright (default Chromium; CI:
@@ -137,7 +160,7 @@ Run
137
160
  An app screen taken by any tool (Maestro, Appium, XCUITest, a person) with its
138
161
  UI dump (uiautomator/Appium XML, idb or Maestro JSON); app scenarios without
139
162
  --image capture the current screen of the environment's app
140
- mail --step <id> [--locale l] [--to <addr>] [--subject <text>] [--timeout 60]
163
+ mail --step <id> [--locale l] [--to <addr>] [--subject <text>] [--timeout 60s]
141
164
  [--since <ISO date> | --any-time] [--id <mailbox message id>]
142
165
  Only messages received since the run started, unless --since/--any-time; --id takes one exact message
143
166
  Capture an e-mail from the environment's mailbox (mail.provider) as an email step
@@ -146,7 +169,7 @@ Run
146
169
  auth save --env <e> --account <key> [--url <url>]
147
170
  A person signs in once (SSO, MFA) in a visible browser; the session is saved
148
171
  for that account and walk reuses it (never uploaded)
149
- auth list Saved sessions per environment and account, with their age
172
+ auth list [--json] Saved sessions per environment and account, with their age
150
173
  walk [--steps a,b] [--locales l] [--keep-going] [--ephemeral] [--browsers webkit]
151
174
  Run playwright steps (execution.script) and capture them; a session stops
152
175
  after 3 steps in a row fail with the same error unless --keep-going
@@ -174,30 +197,32 @@ Run
174
197
  per locale with a chapter per step (flow), or only steps that fail (failed);
175
198
  also capture.record in the config or scenario, record: true on a step.
176
199
  A trace (actions, requests, console) is kept per step unless --no-trace
177
- affected [--scenario file | --all] [--base ref] [--files a,b] [--json] [--check] [--offline]
200
+ affected [--scenario file | --all] [--base ref] [--files a,b] [--json] [--fail-if-none] [--offline]
178
201
  Which steps changed files reach, and why: framework route files against step paths
179
202
  and URLs captured earlier, imports, global files, i18n messages, step scripts and
180
- affects: globs; --check exits 1 when nothing is affected
203
+ affects: globs; --fail-if-none exits 1 when nothing is affected (--check, its
204
+ older name, still works)
181
205
  import <folder | playwright-report/ | results.json | trace.zip | walkthrough.md> [--scenario file | --adhoc]
182
206
  [--env e] [--locale l] [--viewport v] [--map "Title=step,…"] [--format f] [--out file]
183
207
  Screenshots, recordings and traces from agents and tools as a run: into the
184
208
  current run, a new run of --scenario, or an ad-hoc flow of the screens
185
- status Show what the current run has captured
209
+ status [--json] Show what the current run has captured
186
210
  Scenarios may declare "sessions" (people in the flow); walk keeps one browser context per session and locale
187
211
 
188
212
  Results
189
- evaluate Deterministic checks + semantic evaluator (default JEV) for all captures
213
+ evaluate [--json] Deterministic checks + semantic evaluator (default JEV) for all captures
190
214
  evaluate --recheck [--steps a,b] After a scenario change: re-run deterministic checks on the stored HTML and ask
191
215
  the semantic questions again on the stored captures (no walk);
192
216
  console, overflow and performance keep their live result
193
- build Write run.json and the local preview (index.html)
217
+ build [--json] Write run.json and the local preview (index.html)
194
218
  video export [--format mp4|webm|gif] [--locale l] [--steps a,b] [--lang en|cs] [--out file]
195
219
  The run's recordings in flow order with a title card per step (title, verdict);
196
220
  shared with the run on Business and up
197
221
  video list | trace --step <id> | tools
198
222
  Recordings and traces of the run; a step's trace as a timeline; ffmpeg, adb, simctl
199
223
  preview Build and print the preview path
200
- share [--expires 30] [--pr] Upload the run to kaloko.app (KALOKO_TOKEN, tester role); files travel by content
224
+ share [--expires 30d] [--pr] [--json]
225
+ Upload the run to kaloko.app (KALOKO_TOKEN, tester role); files travel by content
201
226
  hash, so unchanged captures are not sent again and a broken-off share resumes;
202
227
  --pr adds or updates one comment on the branch's pull request via gh (KALOKO_PR in
203
228
  CI), or a note on the GitLab merge request (KALOKO_GITLAB_TOKEN)
@@ -214,25 +239,32 @@ Results
214
239
  comment --reply|--resolve|--reopen|--edit|--delete <id> [--note "…"] [--flow]
215
240
  Comment, answer and close feedback from the CLI (resolve with "Fixed in <commit>")
216
241
  projects [--json] Projects of the organization (use the slug as project: in a scenario or config)
217
- runs [--scenario id] [--all] [--json]
218
- Shared runs: the newest per scenario (with "+ N older"); --all or --scenario lists every run
242
+ runs [--scenario id] [--env e] [--all] [--limit N] [--cursor c] [--json]
243
+ Shared runs: the newest per scenario (with "+ N older"); --all or --scenario lists every
244
+ run: --all to the last one page by page, otherwise the newest 300; --limit N lists at
245
+ most N, --cursor c goes on where a list stopped (it prints the cursor)
219
246
  trash <run-id>... | restore <run-id>
220
247
  Move runs to the trash (uploader or admin) and back; restorable until purge
221
- prune [--scenario id] [--environment e] [--keep 3] [--older-than 14d] [--yes]
248
+ prune [--scenario id] [--env e] [--keep 3] [--older-than 14d|48h] [--dry-run | --yes] [--json]
222
249
  Keep the newest runs per scenario and environment plus kept, accepted, baseline,
223
- referenced and commented runs; lists what would go to the trash unless --yes
250
+ referenced and commented runs; lists what would go to the trash (--dry-run, the
251
+ default) unless --yes
224
252
  keep <run-id> [--off] A kept run never expires and is never pruned
225
- archive <scenario-id> [--undo] Hide a retired scenario from the scenario list (admin); its runs stay reachable
253
+ archive <scenario-id> [--off] Hide a retired scenario from the scenario list (admin); its runs stay reachable;
254
+ --off shows it again
226
255
  inbox [--all] [--read] [--json] Your notifications on kaloko.app (mentions, returned runs, new runs, expiry); --read marks them read
227
256
  calibrate [--golden labels.yml] [--scenario id [--limit 30]] [--target 0.95] [--json]
228
257
  Semantic evaluator vs human labels: precision/recall per criterion at the
229
258
  configured thresholds and suggested thresholds (labels from a golden file or
230
259
  from reviewers' criterion verdicts in the service)
231
- export [--run id] [--out file.zip] Download a shared run as a zip before it expires: run.json, every file, the live
260
+ export run [--run id] [--out file.zip]
261
+ Download a shared run as a zip before it expires: run.json, every file, the live
232
262
  HTML of design steps and index.html, the canvas that opens offline from disk
233
- export --scenario <id> [--accepted] [--out dir] [--locales l] [--viewports v]
263
+ (also kaloko export --run id)
264
+ export steps --scenario <id> [--accepted] [--out dir] [--locales l] [--viewports v]
234
265
  Newest capture of every step as steps/<step>/<locale>/<viewport>.<ext> plus
235
266
  index.json (documentation layer, Business plan); unchanged files are skipped
267
+ (also kaloko export --scenario <id>)
236
268
  public [--run id] [--off] Make a shared run readable without sign-in (admin; testers may refresh an already public scenario)
237
269
  stability [--last 4 | --runs a,b] [--same-commit] [--apply] [--json]
238
270
  Regions that change on every run or move while the page is idle (clocks, ads,
@@ -248,7 +280,7 @@ Results
248
280
  Regions people drew on screenshots on kaloko.app (step detail → Mark a changing
249
281
  region) go into the scenario's ignore: of their steps; walk them again after
250
282
  screens [words] [--project p] [--product p] [--module m] [--scenario id] [--locale l] [--viewport v] [--device d]
251
- [--status pass|partial|fail|skip] [--environment e] [--designs] [--all] [--json] [--cursor c]
283
+ [--status pass|partial|fail|skip] [--env e] [--designs] [--all] [--json] [--cursor c]
252
284
  The screen library: the newest screenshot of every step across your flows, searched
253
285
  by title, text, URL or id, with the canvas link of each (--designs adds design versions)
254
286
  compare [--run id] [--baseline id | --design] [--json]
@@ -268,6 +300,9 @@ Hosted runs (read-only scenarios on Kaloko's browsers, Business plan)
268
300
  run as you: public pages, HTTP Basic from the environment, or a saved session
269
301
  (--account). Steps that click or type are left out; they need the full walk
270
302
  schedule list | run <id> | pause <id> | resume <id> | remove <id> [--json]
303
+ schedule hook <id> [--remove] [--json]
304
+ A URL and secret that start the hosted run from a deploy (Vercel, Netlify) or
305
+ any CI job; the secret is shown once, a second call replaces it
271
306
  schedule export github --scenario <file> --env <name> [--every day|week] [--at 5] [--out file] [--print]
272
307
  A GitHub Actions workflow that walks the scenario on a schedule with the full CLI
273
308
 
@@ -303,12 +338,13 @@ Docs (Markdown in docs/ with step references; Diátaxis folders; translations as
303
338
  text was published), missing or outdated translations and user-visible changelog
304
339
  items without a picture; exit 1 on references or stale sections (--strict: on
305
340
  anything)
306
- export --format md|docusaurus|vitepress|mkdocs|html|pdf|json [--product p] [--version v] [--lang l]
341
+ export docs [--format md|docusaurus|vitepress|mkdocs|html|pdf|json] [--product p] [--version v] [--lang l]
307
342
  [--what all|docs|changelog] [--local] [--out dir|file]
308
343
  The docs and changelog with their pictures as files: Markdown with an assets
309
- folder, a Docusaurus, VitePress or MkDocs folder with sidebars, offline HTML, PDF
310
- (printed by the browser) or the changelog as JSON; --local exports the files in
311
- the repository instead of the published ones
344
+ folder (the default), a Docusaurus, VitePress or MkDocs folder with sidebars,
345
+ offline HTML, PDF (printed by the browser) or the changelog as JSON; --local
346
+ exports the files in the repository instead of the published ones (also kaloko
347
+ export --format <f>)
312
348
 
313
349
  Design (environments with kind: design; live HTML steps in a folder)
314
350
  start --scenario <file> --env <design env> A run of the design folder (same scenario as development and QA)
@@ -345,16 +381,17 @@ Design (environments with kind: design; live HTML steps in a folder)
345
381
  The project's token set (DTCG); check lists raw values outside it in the live folder
346
382
  branch list | create <name> [--from vN] [--git <branch>] | merge <name> [--take-branch a,b] [--keep-main c]
347
383
  Branches of the draft; a git-linked branch is what implementation runs of that git branch check against
348
- export --live [--version n|--draft|--run id] [--out design/]
384
+ export live [--version n|--draft|--run id] [--out design/]
349
385
  Live files of a version (default: the design reference) into a folder: the handoff
350
- export --live --views --run <id> [--viewport desktop] [--out design/]
386
+ (also kaloko export --live)
387
+ export live --views --run <id> [--viewport desktop] [--out design/]
351
388
  Fork into a draft: the HTML views of an app run become design steps (experimental)
352
- export --design [--version n|--draft|--run id] [--out dir|file.zip]
389
+ export design [--version n|--draft|--run id] [--out dir|file.zip]
353
390
  A design version in open formats: HTML + assets, tokens.json (DTCG), scenario.yml,
354
- screenshots and an index.html that opens from disk (default: the design reference)
391
+ screenshots and an index.html that opens from disk (default: the design reference;
392
+ also kaloko export --design)
355
393
 
356
- Common options: --run <id|dir> (or KALOKO_RUN) --config <file> KALOKO_AGENT=<name> labels writes by an agent
357
- `
394
+ ${COMMON_HELP}`
358
395
 
359
396
  /** A comment's drawing for the terminal: "· box 120,340 300×80 px on desktop" (CSS px when the size is known). */
360
397
  export function regionText (g) {
@@ -367,18 +404,19 @@ export function regionText (g) {
367
404
 
368
405
  /** validate: scenarios the service still lists as active whose scenario file no longer exists here (#58). */
369
406
  async function warnRetired (args) {
370
- if (args.offline) return
407
+ if (args.offline) return []
371
408
  let config, client
372
- try { ({ config } = await context(args)); client = service(config) } catch { return } // no project or no token: offline
373
- if (!config?.org) return
409
+ try { ({ config } = await context(args)); client = service(config) } catch { return [] } // no project or no token: offline
410
+ if (!config?.org) return []
374
411
  let list
375
- try { list = await client.request('GET', `/orgs/${config.org}/scenarios?runs=1`) } catch { return }
412
+ try { list = await client.request('GET', `/orgs/${config.org}/scenarios?runs=1`) } catch { return [] }
376
413
  const gone = []
377
414
  for (const s of list || []) {
378
415
  if (s.archived || !s.file || (config.project && s.project && s.project !== config.project)) continue
379
416
  try { await readFile(resolve(s.file)) } catch { gone.push(s) }
380
417
  }
381
- for (const s of gone) say(`! ${s.id}: the service lists it as active, but ${s.file} is gone here (split or renamed?). Archive it: kaloko archive ${s.id}`)
418
+ if (!args.json) for (const s of gone) say(`! ${s.id}: the service lists it as active, but ${s.file} is gone here (split or renamed?). Archive it: kaloko archive ${s.id}`)
419
+ return gone.map((s) => ({ id: s.id, file: s.file }))
382
420
  }
383
421
 
384
422
  /** The end of a walk: evaluate (the semantic answers are mostly cached by now) and say what did not pass. */
@@ -397,19 +435,6 @@ async function walkVerdicts ({ prefetch, run, scenario, config, root }) {
397
435
  if (report.skipped.length) say(` Semantic criteria not evaluated: ${[...new Set(report.skipped.map((x) => x.split(': ').slice(1).join(': ')))].slice(0, 2).join('; ')}`)
398
436
  }
399
437
 
400
- function parse (argv) {
401
- const args = { _: [] }
402
- for (let i = 0; i < argv.length; i++) {
403
- const a = argv[i]
404
- if (a.startsWith('--')) {
405
- const [k, v] = a.slice(2).split('=')
406
- if (v !== undefined) args[k] = v
407
- else if (argv[i + 1] && !argv[i + 1].startsWith('--')) args[k] = argv[++i]
408
- else args[k] = true
409
- } else args._.push(a)
410
- }
411
- return args
412
- }
413
438
  const list = (v) => (typeof v === 'string' ? v.split(',').map((s) => s.trim()).filter(Boolean) : null)
414
439
  // parallel walks (#27) print each locale's lines together, in locale order: `say` writes into the running unit's buffer
415
440
  const output = new AsyncLocalStorage()
@@ -446,8 +471,15 @@ export function walkConcurrency (args, config, units, shared, cpus = availablePa
446
471
  return Math.max(1, Math.min(units, 4, Math.floor(cpus / 2)))
447
472
  }
448
473
 
474
+ /** --env names an environment everywhere; --environment is its older spelling on runs, prune and screens. */
475
+ const envOption = (args) => typeof args.env === 'string' ? args.env : typeof args.environment === 'string' ? args.environment : null
476
+ /** The most runs `kaloko runs` lists in one go. */
477
+ const RUNS_CAP = 300
478
+
449
479
  async function context (args) {
450
480
  const { config, root, file } = await loadConfig(process.cwd(), args.config)
481
+ // --org: another organization than the config's for this command (an agent working for several)
482
+ if (typeof args.org === 'string' && args.org) config.org = args.org
451
483
  return { config, root, file }
452
484
  }
453
485
 
@@ -463,7 +495,7 @@ async function runContext (args) {
463
495
  }
464
496
 
465
497
  function stepOf (scenario, id) {
466
- if (!id) throw new UserError(`Which step? Pass --step <id>; this scenario has: ${scenario.nodes.filter((n) => n.kind !== 'decision').map((n) => n.id).join(', ')}.`)
498
+ if (!id) throw new UsageError(`Which step? Pass --step <id>; this scenario has: ${scenario.nodes.filter((n) => n.kind !== 'decision').map((n) => n.id).join(', ')}.`)
467
499
  const node = scenario.nodes.find((n) => n.id === id)
468
500
  if (!node) throw new UserError(`Unknown step "${id}". Steps: ${scenario.nodes.filter((n) => n.kind !== 'decision').map((n) => n.id).join(', ')}`)
469
501
  return node
@@ -491,7 +523,7 @@ async function lintOrigins (config, env, { online = false } = {}) {
491
523
  */
492
524
  async function designImport (args) {
493
525
  const [, kind, source] = args._
494
- if (kind !== 'figma' || !source) throw new UserError('Usage: kaloko design import figma <file-url|key|export folder> [--scenario file] [--env <design env>] [--frames a,b] [--page name] [--map frames.json|yml] [--token-env FIGMA_TOKEN] [--tokens auto|write|skip] [--force] [--dry-run] [--publish [--notes file]]')
526
+ if (kind !== 'figma' || !source) throw new UsageError('Usage: kaloko design import figma <file-url|key|export folder> [--scenario file] [--env <design env>] [--frames a,b] [--page name] [--map frames.json|yml] [--token-env FIGMA_TOKEN] [--tokens auto|write|skip] [--force] [--dry-run] [--publish [--notes file]]')
495
527
  const { config, root } = await context(args)
496
528
  let run = null
497
529
  try { run = await resolveRun({ root, output: config.output, run: args.run }) } catch {}
@@ -506,22 +538,24 @@ async function designImport (args) {
506
538
  const viewports = Object.fromEntries(baseViewports(scope).map((v) => [v, scope.viewports[v]]))
507
539
  const tokenVar = typeof args['token-env'] === 'string' ? args['token-env'] : typeof args.token === 'string' && /^[A-Z_][A-Z0-9_]*$/.test(args.token) ? args.token : 'FIGMA_TOKEN'
508
540
  const dir = typeof args.dir === 'string' ? resolve(args.dir) : designDir(env, root)
541
+ // --json: standard output is the JSON alone; progress and next steps go to standard error
542
+ const log = args.json ? (...m) => console.error(...m) : say
509
543
  const r = await importFigma({
510
544
  source, scenario, viewports, locales: scenario.locales || ['default'], dir,
511
545
  token: process.env[tokenVar] || (tokenVar === 'FIGMA_TOKEN' ? process.env.FIGMA_ACCESS_TOKEN : null) || null,
512
546
  map: typeof args.map === 'string' ? await readMap(resolve(args.map)) : null, only: list(args.frames), page: typeof args.page === 'string' ? args.page : null,
513
- tokensMode: ['write', 'skip'].includes(args.tokens) ? args.tokens : 'auto', force: !!args.force, dryRun: !!args['dry-run'], say
547
+ tokensMode: ['write', 'skip'].includes(args.tokens) ? args.tokens : 'auto', force: !!args.force, dryRun: !!args['dry-run'], say: log
514
548
  })
515
549
  if (args.json) say(JSON.stringify(r, null, 2))
516
550
  else say(formatImport(r, { dir: relative(process.cwd(), dir) || '.' }))
517
551
  if (args['dry-run']) return r
518
552
  const lint = await lintDesign({ dir, scenario, origins: await lintOrigins(config, env) })
519
- if (lint.errors) say(`\n${formatLint(lint, { max: 10 })}`)
553
+ if (lint.errors) log(`\n${formatLint(lint, { max: 10 })}`)
520
554
  if (!args.publish) {
521
- say(`\nNext: kaloko start --scenario ${relative(process.cwd(), scenarioFile)} --env ${env.name} && kaloko walk && kaloko share (or rerun with --publish).\nAccept the version on the canvas and set it as the design reference (kaloko reference --design --version <n>): implementation runs with packs: [design] are then checked against these frames.`)
555
+ log(`\nNext: kaloko start --scenario ${relative(process.cwd(), scenarioFile)} --env ${env.name} && kaloko walk && kaloko share (or rerun with --publish).\nAccept the version on the canvas and set it as the design reference (kaloko reference --design --version <n>): implementation runs with packs: [design] are then checked against these frames.`)
522
556
  return r
523
557
  }
524
- await commands.start({ ...args, _: [], scenario: scenarioFile, env: env.name })
558
+ await commands.start({ ...args, _: [], scenario: scenarioFile, env: env.name, json: false })
525
559
  const current = await resolveRun({ root, output: config.output })
526
560
  await commands.walk({ _: [], run: current.dir })
527
561
  await commands.share({ _: [], run: current.dir, ...(args['allow-pii'] ? { 'allow-pii': true } : {}), ...(typeof args.notes === 'string' ? { notes: args.notes } : {}), ...(typeof args.name === 'string' ? { name: args.name } : {}), ...(typeof args.branch === 'string' ? { branch: args.branch } : {}) })
@@ -534,7 +568,7 @@ async function scenarioIdOf (args, config, root) {
534
568
  return args.scenario
535
569
  }
536
570
  try { return (await resolveRun({ root, output: config.output, run: args.run })).meta.scenario.id } catch {}
537
- throw new UserError('--scenario <id|file> is required when there is no current run.')
571
+ throw new UsageError('--scenario <id|file> is required when there is no current run.')
538
572
  }
539
573
 
540
574
  /**
@@ -923,6 +957,29 @@ async function captureImage ({ config, run, scenario, node, locale, args }) {
923
957
  }
924
958
 
925
959
  /** walk --affected / kaloko affected: the selection for one scenario (a token adds URLs of the newest shared run). */
960
+ /**
961
+ * `kaloko export run|steps|docs|live|design …`: the sub-command sets the flag form it stands for (export --live,
962
+ * --design, --format, --scenario), which keeps working. A flag of another kind of export is a usage mistake.
963
+ */
964
+ export function exportSubcommand (args) {
965
+ const sub = args._[0]
966
+ if (sub === undefined) return null
967
+ // the flags of the other kinds (live and design exports take --scenario too)
968
+ const clashes = { run: ['live', 'design', 'format', 'scenario'], steps: ['live', 'design', 'format'], docs: ['live', 'design', 'scenario'], live: ['design', 'format'], design: ['live', 'format'] }
969
+ const owner = { live: 'live', design: 'design', format: 'docs', scenario: 'steps' }
970
+ // kaloko export <run-id>: the run named as a word, as people type it
971
+ if (/^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9-]+$/.test(sub) && args.run === undefined) { args._.shift(); args.run = sub; return 'run' }
972
+ if (!(sub in clashes)) throw new UsageError(`kaloko export ${sub}: there is no such export. Use run, steps, docs, live or design (kaloko help export).`)
973
+ args._.shift()
974
+ const clash = clashes[sub].find((f) => args[f] !== undefined)
975
+ if (clash) throw new UsageError(`kaloko export ${sub} does not take --${clash}; that is kaloko export ${owner[clash]}.`)
976
+ if (sub === 'live') args.live = true
977
+ if (sub === 'design') args.design = typeof args.design === 'string' ? args.design : true
978
+ if (sub === 'docs' && typeof args.format !== 'string') args.format = 'md'
979
+ if (sub === 'steps' && typeof args.scenario !== 'string') throw new UsageError('kaloko export steps needs --scenario <id>: the flow whose steps to export.')
980
+ return sub
981
+ }
982
+
926
983
  async function affectedSelection (args, { config, root, scenario, scenarioFile }) {
927
984
  let client = null
928
985
  try { if (!args.offline) client = service(config) } catch {}
@@ -943,7 +1000,7 @@ const commands = {
943
1000
  if (!exists) {
944
1001
  let tpl = await readFile(join(packageRoot, 'templates', 'kaloko.config.yml'), 'utf8')
945
1002
  if (typeof args.org === 'string') {
946
- if (!/^[a-z0-9][a-z0-9-]{1,40}$/.test(args.org)) throw new UserError('--org must be the organization slug from kaloko.app (a-z, 0-9, -).')
1003
+ if (!/^[a-z0-9][a-z0-9-]{1,40}$/.test(args.org)) throw new UsageError('--org must be the organization slug from kaloko.app (a-z, 0-9, -).')
947
1004
  tpl = tpl.replace(/^org: .*$/m, `org: ${args.org}`)
948
1005
  }
949
1006
  if (detected) {
@@ -956,7 +1013,7 @@ const commands = {
956
1013
  } else if (!existsSync(join(cwd, LEGACY_CONFIG_FILE))) say(`${CONFIG_FILE} already exists; kept.`)
957
1014
  // the visual changelog (docs/DOCS-CHANGELOG.md): asked once; --docs=off|changelog|full for scripts and agents
958
1015
  let docsMode = typeof args.docs === 'string' ? args.docs : null
959
- if (docsMode && !['off', 'changelog', 'full'].includes(docsMode)) throw new UserError('--docs must be off, changelog or full.')
1016
+ if (docsMode && !['off', 'changelog', 'full'].includes(docsMode)) throw new UsageError('--docs must be off, changelog or full.')
960
1017
  if (!docsMode && process.stdin.isTTY && process.stdout.isTTY && !process.env.CI && !process.env.NODE_TEST_CONTEXT) {
961
1018
  const rl = (await import('node:readline/promises')).createInterface({ input: process.stdin, output: process.stdout })
962
1019
  const a = (await rl.question('Keep a visual changelog? (recommended) [Y/n] ')).trim().toLowerCase(); rl.close()
@@ -987,7 +1044,7 @@ const commands = {
987
1044
  }
988
1045
  } else if (!docsMode) say('Visual changelog: kaloko init --docs=changelog adds CHANGELOG.md and the config block (recommended).')
989
1046
  const agent = typeof args.agent === 'string' ? args.agent : 'none'
990
- if (!['claude', 'codex', 'all', 'none'].includes(agent)) throw new UserError('--agent must be claude, codex, all or none.')
1047
+ if (!['claude', 'codex', 'all', 'none'].includes(agent)) throw new UsageError('--agent must be claude, codex, all or none.')
991
1048
  const { cp, mkdir, appendFile, rm } = await import('node:fs/promises')
992
1049
  const skill = join(packageRoot, 'skills', 'kaloko')
993
1050
  const targets = { claude: ['.claude/skills/kaloko'], codex: ['skills/kaloko'], all: ['.claude/skills/kaloko', 'skills/kaloko'], none: [] }[agent]
@@ -1047,15 +1104,20 @@ const commands = {
1047
1104
  },
1048
1105
 
1049
1106
  async validate (args) {
1050
- if (!args._.length) throw new UserError('Usage: kaloko validate <scenario.yml>...')
1107
+ if (!args._.length) throw new UsageError('Usage: kaloko validate <scenario.yml>...')
1051
1108
  let failed = 0
1109
+ // --json: one entry per file with its problems, warnings and coverage; the lines below go to stderr
1110
+ const results = []
1052
1111
  for (const file of args._) {
1053
1112
  let scenario
1054
- try { scenario = parseScenario(await readFile(file, 'utf8'), file) } catch (e) { if (!(e instanceof UserError)) throw e; failed++; say(`✕ ${e.message}`); continue }
1113
+ try { scenario = parseScenario(await readFile(file, 'utf8'), file) } catch (e) { if (!(e instanceof UserError)) throw e; failed++; say(`✕ ${e.message}`); results.push({ file, ok: false, problems: [e.message], warnings: [] }); continue }
1055
1114
  const problems = validateScenario(scenario)
1056
1115
  if (!problems.length) expandPacks(scenario)
1057
1116
  if (problems.length) { failed++; say(`✕ ${file}\n - ${problems.join('\n - ')}`) } else say(`✓ ${file}`)
1058
- if (!problems.length) for (const w of scenarioWarnings(scenario)) say(` ! ${w}`)
1117
+ const warnings = problems.length ? [] : scenarioWarnings(scenario)
1118
+ for (const w of warnings) say(` ! ${w}`)
1119
+ const entry = { file, id: scenario.id || null, ok: !problems.length, problems, warnings }
1120
+ results.push(entry)
1059
1121
  if (args.coverage) {
1060
1122
  // the plan is scenario.source (relative to the working directory or to the scenario file), or --source
1061
1123
  const candidates = typeof args.source === 'string' ? [resolve(args.source)] : scenario.source ? [resolve(scenario.source), resolve(file, '..', scenario.source), resolve(file, '..', '..', scenario.source)] : []
@@ -1064,21 +1126,26 @@ const commands = {
1064
1126
  if (!plan) { say(` · no acceptance plan found (${scenario.source ? `source: ${scenario.source}` : 'scenario has no source'}); pass --source <ACCEPTANCE.md>`); continue }
1065
1127
  const cov = coverage(scenario, plan)
1066
1128
  say(formatCoverage(file, cov))
1129
+ entry.coverage = { missing: cov.missing }
1067
1130
  if (cov.missing.length) process.exitCode = 1
1068
1131
  }
1069
1132
  }
1070
1133
  if (failed) process.exitCode = 1
1071
- await warnRetired(args)
1134
+ const retired = await warnRetired(args)
1135
+ if (args.json) say(JSON.stringify({ ok: !failed && process.exitCode !== 1, files: results, retired }, null, 2))
1072
1136
  },
1073
1137
 
1074
1138
  async scenarios (args) {
1075
1139
  const { config, root } = await context(args)
1076
- for (const s of await catalogFor(config, root)) say(`${s.id.padEnd(36)} ${s.kind.padEnd(5)} ${String(s.runs.length).padStart(3)} runs ${s.file}`)
1140
+ const all = await catalogFor(config, root)
1141
+ // --json: id, title, kind, file and the local runs (newest first) of every scenario the config matches
1142
+ if (args.json) { say(JSON.stringify(all, null, 2)); return }
1143
+ for (const s of all) say(`${s.id.padEnd(36)} ${s.kind.padEnd(5)} ${String(s.runs.length).padStart(3)} runs ${s.file}`)
1077
1144
  },
1078
1145
 
1079
1146
  async start (args) {
1080
1147
  const { config, root } = await context(args)
1081
- if (!args.scenario || !args.env) throw new UserError('Usage: kaloko start --scenario <file> --env <name>')
1148
+ if (!args.scenario || !args.env) throw new UsageError('Usage: kaloko start --scenario <file> --env <name>')
1082
1149
  const scenarioFile = resolve(args.scenario)
1083
1150
  const scenario = await loadScenario(scenarioFile)
1084
1151
  const env = environment(config, args.env)
@@ -1099,9 +1166,11 @@ const commands = {
1099
1166
  // the product the flow documents (visual changelog): its tag, or the only product of the config
1100
1167
  const product = scenario.product || (Object.keys(config.products || {}).length === 1 ? Object.keys(config.products)[0] : null)
1101
1168
  const { dir, meta } = await createRun({ output: config.output, scenario, scenarioFile, env, scope, git, executor, root, project, issue, appVersion: appVer, product, pii })
1169
+ const addr = recipient(env.mail, meta.short)
1170
+ // --json: what a script needs to pin the run (KALOKO_RUN) and find its files
1171
+ if (args.json) { say(JSON.stringify({ run: meta.id, dir, env: env.name, readonly: !!env.readonly, locales, viewports: vpNames, project: project.slug, issue: issue || null, test_email: addr || null, pii: pii?.mode || null }, null, 2)); return }
1102
1172
  say(`Run ${meta.id}\n dir: ${relative(process.cwd(), dir)}\n env: ${env.name}${env.readonly ? ' (readonly)' : ''}\n locales: ${locales.join(', ')} viewports: ${vpNames.length > 12 ? `${vpNames.slice(0, 12).join(', ')} … (${vpNames.length})` : vpNames.join(', ')}${dimensionLine(scope)}\n project: ${project.slug}${issue ? ` issue: ${issue.repo ? issue.repo + '#' : '#'}${issue.number}` : ''}`)
1103
1173
  if (pii) say(` personal data: ${pii.mode === 'pseudonymize' ? 'replaced with fake values' : 'hidden'} in every capture`)
1104
- const addr = recipient(env.mail, meta.short)
1105
1174
  if (addr) say(` test e-mail: ${addr}`)
1106
1175
  say(` other agents may start runs here too: pin this one with --run ${meta.id} or KALOKO_RUN=${meta.id}`)
1107
1176
  },
@@ -1138,6 +1207,7 @@ const commands = {
1138
1207
  say(await stopBrowser({ root, config, env }) ? `Browser stopped${env ? ` (${env.name})` : ' (all environments)'}.` : 'Browser was not running.')
1139
1208
  } else {
1140
1209
  const states = await allStates({ root, config })
1210
+ if (args.json) { say(JSON.stringify(states.map((s) => ({ environment: s.environment, cdp: s.cdp, pid: s.pid, readonly: !!s.readonly })), null, 2)); return }
1141
1211
  say(states.length ? states.map((s) => `${s.environment}: ${s.cdp} (pid ${s.pid})${s.readonly ? ', readonly' : ''}`).join('\n') : 'No shared browser is running.')
1142
1212
  }
1143
1213
  },
@@ -1232,7 +1302,7 @@ const commands = {
1232
1302
  let since
1233
1303
  if (args['any-time']) since = null
1234
1304
  else if (typeof args.since === 'string') { const t = Date.parse(args.since); if (Number.isNaN(t)) throw new UserError(`--since ${args.since} is not a date (ISO 8601, e.g. 2026-09-27T10:00).`); since = new Date(t).toISOString() }
1235
- const results = await captureMail({ config, root, run, env, scenario, node, locale, to, subject, timeoutMs: (Number(args.timeout) || 60) * 1000, viewports: list(args.viewports), message, since, id: typeof args.id === 'string' ? args.id : null })
1305
+ const results = await captureMail({ config, root, run, env, scenario, node, locale, to, subject, timeoutMs: (parseDuration(args.timeout, 's', 'timeout') || 60) * 1000, viewports: list(args.viewports), message, since, id: typeof args.id === 'string' ? args.id : null })
1236
1306
  say(`✓ ${node.id} ${locale}: "${results.subject}" → ${results.count} viewport(s)`)
1237
1307
  },
1238
1308
 
@@ -1489,10 +1559,13 @@ const commands = {
1489
1559
  out.push({ scenario: scenario.id, file: relative(root, file), base: r.base, changed: r.files, runs: r.runs, ...r.sel, walk: selectedIds(scenario, r.sel) })
1490
1560
  if (!args.json) say(formatAffected(r.sel, { scenario, base: r.base, changed: r.files.length }) + '\n')
1491
1561
  }
1562
+ const failIfNone = !!(args['fail-if-none'] || args.check)
1492
1563
  if (args.json) say(JSON.stringify(targets.length === 1 && typeof args.scenario === 'string' ? out[0] : out, null, 2))
1493
- else if (!args.check) say(`Walk them with: kaloko walk --affected${args.base ? ` --base ${args.base}` : ''} (after kaloko start)`)
1494
- // --check: exit code 1 when nothing is affected (CI skips the scenario)
1495
- if (args.check && !out.some((o) => o.walk.length && !o.none)) process.exitCode = 1
1564
+ else if (!failIfNone) say(`Walk them with: kaloko walk --affected${args.base ? ` --base ${args.base}` : ''} (after kaloko start)`)
1565
+ // --check is the older name of --fail-if-none: it works the same and says so once
1566
+ if (args.check && !args['fail-if-none']) console.error('! kaloko affected --check is now --fail-if-none: it works the same; rename it in your CI when you next touch it.')
1567
+ // --fail-if-none: exit code 1 when nothing is affected (CI skips the scenario)
1568
+ if (failIfNone && !out.some((o) => o.walk.length && !o.none)) process.exitCode = 1
1496
1569
  },
1497
1570
 
1498
1571
  /**
@@ -1501,7 +1574,7 @@ const commands = {
1501
1574
  */
1502
1575
  async import (args) {
1503
1576
  const from = args._[0]
1504
- if (!from) throw new UserError('Usage: kaloko import <folder | playwright-report/ | results.json | trace.zip | walkthrough.md> [--scenario file | --adhoc] [--env e] [--locale l] [--viewport v] [--map "Title=step,…"]')
1577
+ if (!from) throw new UsageError('Usage: kaloko import <folder | playwright-report/ | results.json | trace.zip | walkthrough.md> [--scenario file | --adhoc] [--env e] [--locale l] [--viewport v] [--map "Title=step,…"]')
1505
1578
  const { config, root } = await context(args)
1506
1579
  let source
1507
1580
  try { source = await readSource(resolve(from), typeof args.format === 'string' ? args.format : 'auto') } catch (e) { throw new UserError(`kaloko import: ${e.message}`) }
@@ -1568,7 +1641,7 @@ const commands = {
1568
1641
  const action = args._[0]
1569
1642
  if (action === 'preview-url') {
1570
1643
  const err = (m) => console.error(m)
1571
- const preview = await findPreview({ wait: Number(args.wait) || 0, say: err })
1644
+ const preview = await findPreview({ wait: parseDuration(args.wait, 's', 'wait') || 0, say: err })
1572
1645
  const pr = await pullRequestOf().catch(() => null)
1573
1646
  if (!preview) throw new UserError('No preview URL found. Set KALOKO_PREVIEW_URL, or let Vercel, Netlify or Cloudflare Pages report deployments to GitHub (the workflow needs deployments: read).')
1574
1647
  const lines = envLines({ preview, pr })
@@ -1578,10 +1651,12 @@ const commands = {
1578
1651
  }
1579
1652
  if (typeof args.dotenv === 'string') await writeEnvFile(resolve(args.dotenv), lines)
1580
1653
  err(`Preview ${preview.url} (from ${preview.source})${pr?.number ? `, pull request #${pr.number}${pr.base ? ` into ${pr.base}` : ''}` : ''}`)
1654
+ // --json: the URL, where it came from and the pull request, with the variables the env lines set
1655
+ if (args.json) { say(JSON.stringify({ url: preview.url, source: preview.source, pr: pr?.number || null, base: pr?.base || null, head: pr?.head || null, env: Object.fromEntries(lines.map((l) => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)])) }, null, 2)); return }
1581
1656
  console.log(preview.url)
1582
1657
  return
1583
1658
  }
1584
- if (!['github', 'gitlab'].includes(action)) throw new UserError('Usage: kaloko ci github|gitlab [--scenario a.yml,b.yml] [--env preview] [--trigger deployment|pull_request] [--no-affected] [--out file] [--print] [--force]\n kaloko ci github --release [--snippet] [--print] [--force]\n kaloko ci preview-url [--wait 600] [--github-env] [--dotenv file]')
1659
+ if (!['github', 'gitlab'].includes(action)) throw new UsageError('Usage: kaloko ci github|gitlab [--scenario a.yml,b.yml] [--env preview] [--trigger deployment|pull_request] [--no-affected] [--out file] [--print] [--force]\n kaloko ci github --release [--snippet] [--print] [--force]\n kaloko ci preview-url [--wait 600] [--github-env] [--dotenv file]')
1585
1660
  const { config, root, file } = await context(args)
1586
1661
  if (args.release) {
1587
1662
  // release-tool integrations: record each published release on Kaloko (docs/DOCS-CHANGELOG.md)
@@ -1654,19 +1729,22 @@ const commands = {
1654
1729
  const { stat, mkdir, writeFile: write, chmod } = await import('node:fs/promises')
1655
1730
  if (action === 'list') {
1656
1731
  const rows = []
1732
+ const items = []
1657
1733
  for (const name of Object.keys(config.environments)) {
1658
1734
  let env
1659
1735
  try { env = environment(config, name) } catch { continue }
1660
1736
  for (const key of Object.keys(env.accounts || {})) {
1661
1737
  const file = accountFor(env, key, { root, config }).storage_state
1662
1738
  const st = await stat(file).catch(() => null)
1739
+ items.push({ environment: name, account: key, saved: !!st, saved_at: st ? new Date(st.mtimeMs).toISOString() : null, file: relative(process.cwd(), file) })
1663
1740
  rows.push(`${name.padEnd(12)} ${key.padEnd(16)} ${st ? `saved ${Math.round((Date.now() - st.mtimeMs) / 36e5)} h ago · ${relative(process.cwd(), file)}` : 'no saved session'}`)
1664
1741
  }
1665
1742
  }
1743
+ if (args.json) { say(JSON.stringify(items, null, 2)); return }
1666
1744
  say(rows.length ? rows.join('\n') : 'No accounts in kaloko.config.yml (environments.<env>.accounts).')
1667
1745
  return
1668
1746
  }
1669
- if (action !== 'save' || typeof args.env !== 'string' || typeof args.account !== 'string') throw new UserError('Usage: kaloko auth save --env <name> --account <key> [--url <url>] | kaloko auth list')
1747
+ if (action !== 'save' || typeof args.env !== 'string' || typeof args.account !== 'string') throw new UsageError('Usage: kaloko auth save --env <name> --account <key> [--url <url>] | kaloko auth list')
1670
1748
  const env = environment(config, args.env)
1671
1749
  if (env.readonly) throw new UserError(`Environment ${env.name} is readonly; saved sign-ins are for accounts on non-production environments.`)
1672
1750
  const account = accountFor(env, args.account, { root, config })
@@ -1690,7 +1768,7 @@ const commands = {
1690
1768
  },
1691
1769
 
1692
1770
  async session (args) {
1693
- if (args._[0] !== 'reset') throw new UserError('Usage: kaloko session reset')
1771
+ if (args._[0] !== 'reset') throw new UsageError('Usage: kaloko session reset')
1694
1772
  const { config, root, env } = await runContext(args)
1695
1773
  const conn = await connectBrowser({ root, config, env })
1696
1774
  if (!conn) throw new UserError('Start the browser first: kaloko browser start')
@@ -1701,6 +1779,18 @@ const commands = {
1701
1779
  async status (args) {
1702
1780
  const { config, root, run, scenario, env } = await runContext(args)
1703
1781
  const caps = await readCaptures(run.dir)
1782
+ if (args.json && env.kind !== 'design') {
1783
+ // per step and locale: the viewports captured so far (stack steps: the pages)
1784
+ const steps = scenario.nodes.filter((n) => n.kind !== 'decision').map((node) => ({
1785
+ id: node.id, kind: node.kind, strategy: node.execution?.strategy || null,
1786
+ captured: Object.fromEntries(run.meta.scope.locales.map((l) => {
1787
+ const mine = caps.filter((c) => c.type === 'capture' && c.node === node.id && c.locale === l)
1788
+ return [l, node.kind === 'stack' ? { pages: new Set(mine.filter((c) => c.item).map((c) => c.item)).size } : [...new Set(mine.map((c) => c.viewport).filter(Boolean))]]
1789
+ }))
1790
+ }))
1791
+ say(JSON.stringify({ run: run.meta.id, dir: run.dir, env: run.meta.environment.name, locales: run.meta.scope.locales, steps }, null, 2))
1792
+ return
1793
+ }
1704
1794
  say(`Run ${run.meta.id} · ${run.meta.environment.name} · ${run.meta.scope.locales.join(', ')}`)
1705
1795
  if (env.kind === 'design') {
1706
1796
  // per step: unchanged / changed here / changed in the draft, from the folder and the last sync (#42)
@@ -1753,6 +1843,11 @@ const commands = {
1753
1843
  say(`Semantic criteria not evaluated:\n - ${[...byReason].slice(0, 8).map(([why, where]) => `${where.length > 1 ? `${where.length} captures (${where.slice(0, 3).join(', ')}${where.length > 3 ? ', …' : ''})` : where[0]}: ${why}`).join('\n - ')}`)
1754
1844
  }
1755
1845
  if (report.outdated.length) say(`Criteria changed after capture of ${report.outdated.join(', ')}: not evaluated. Run "kaloko evaluate --recheck --steps ${report.outdated.join(',')}": it rechecks the stored captures against the new criteria, deterministic and semantic, without walking again.`)
1846
+ // --json: the counts and every verdict a person or agent has to act on (fail, needs_review)
1847
+ if (args.json) {
1848
+ const pick = (f) => ({ step: f.node, ...(f.item ? { item: f.item } : {}), locale: f.locale, viewport: f.viewport, criterion: f.id, verdict: f.verdict, why: f.why || null, fix: f.fix || null })
1849
+ say(JSON.stringify({ run: run.meta.id, evaluated: report.evaluated, semantic: { model: report.model, calls: report.semantic_calls, cached: report.semantic_cached, via: report.via.qawalk ? 'kaloko' : report.via['own-key'] ? 'own-key' : null }, vision: report.vision || null, triage: report.triage || null, findings: (report.findings || []).filter((f) => f.verdict === 'fail' || f.verdict === 'needs_review').map(pick), skipped: report.skipped, outdated: report.outdated }, null, 2))
1850
+ }
1756
1851
  },
1757
1852
 
1758
1853
  async build (args) {
@@ -1760,8 +1855,11 @@ const commands = {
1760
1855
  const manifest = await buildManifest({ run, scenario, config, root, catalog: await catalogFor(config, root) })
1761
1856
  const file = await writePreview({ run, manifest })
1762
1857
  const s = manifest.summary
1858
+ const why = attentionLines(manifest)
1859
+ // --json: the run id, its summary, the preview file and the AI verdicts to act on
1860
+ if (args.json) { say(JSON.stringify({ run: manifest.id, summary: s, preview: file, manifest: join(run.dir, 'run.json'), attention: why.map((l) => l.trim()) }, null, 2)); return file }
1763
1861
  say(`${manifest.id}: ${s.pass} pass · ${s.partial} partial · ${s.fail} fail · ${s.skip} not checked\nPreview: ${file}`)
1764
- const why = attentionLines(manifest); if (why.length) say(`AI verdicts to act on (score and why):\n${why.join('\n')}`)
1862
+ if (why.length) say(`AI verdicts to act on (score and why):\n${why.join('\n')}`)
1765
1863
  return file
1766
1864
  },
1767
1865
 
@@ -1774,7 +1872,7 @@ const commands = {
1774
1872
 
1775
1873
  async preview (args) {
1776
1874
  const file = await commands.build(args)
1777
- say(pathToFileURL(file).href)
1875
+ if (!args.json) say(pathToFileURL(file).href)
1778
1876
  },
1779
1877
 
1780
1878
  async share (args) {
@@ -1789,6 +1887,7 @@ const commands = {
1789
1887
  const meta = await readJson(join(run.dir, 'meta.json'))
1790
1888
  meta.shared = { url: v.url, at: new Date().toISOString(), version: v.version, run: v.id }
1791
1889
  await writeFile(join(run.dir, 'meta.json'), JSON.stringify(meta, null, 2) + '\n')
1890
+ if (args.json) say(JSON.stringify({ run: v.id, url: v.url, version: v.version ?? null, scenario: scenario.id }, null, 2))
1792
1891
  return
1793
1892
  }
1794
1893
  let manifest = await buildManifest({ run, scenario, config, root, catalog: [] })
@@ -1819,7 +1918,10 @@ const commands = {
1819
1918
  if (findings.length && strict && !args['allow-pii']) throw new UserError(`Not shared: the run holds ${findings.length} unmasked personal data value(s):\n${formatFindings(findings)}\nMask them (capture.mask in the scenario, or pii: redact for automatic detection) and capture the steps again. Values that are not personal data, such as a company contact, go under pii.allow in kaloko.config.yml. To share anyway: kaloko share --allow-pii.`)
1820
1919
  if (findings.length && strict) say(`Sharing ${findings.length} unmasked personal data value(s), as --allow-pii says:\n${formatFindings(findings, 5)}`)
1821
1920
  else if (findings.length) say(`This run shows ${findings.length} value(s) that look like personal data:\n${formatFindings(findings, 5)}\nShared anyway, because the run does not hide personal data. To hide it, add pii: redact to the scenario or the environment; values that are not personal data go under pii.allow in kaloko.config.yml.`)
1822
- const days = Number(args.expires) || config.share?.expires_days || 30
1921
+ // --expires: days (30), or with a unit that makes whole days (whole days: 48h is 2)
1922
+ const asked = parseDuration(args.expires, 'd', 'expires')
1923
+ if (asked != null && (!Number.isInteger(asked) || asked < 1)) throw new UsageError(`--expires counts whole days: ${args.expires} is ${Math.round(asked * 100) / 100} days. Use 30, 30d or 72h.`)
1924
+ const days = asked || config.share?.expires_days || 30
1823
1925
  // by content hash (#28): files the organization already stores (earlier runs, an interrupted share) are not sent
1824
1926
  let entries = await hashFiles(run.dir, [...files])
1825
1927
  // a recording above the service's file limit stays local (with its poster and preview), the rest is shared
@@ -1843,7 +1945,7 @@ const commands = {
1843
1945
  } catch (e) { if (e instanceof UserError && /limit|quota/.test(e.message)) throw e }
1844
1946
  const created = await client.request('POST', `/orgs/${config.org}/runs?expires_days=${days}`, manifest)
1845
1947
  await writeFile(join(run.dir, PROGRESS_FILE), JSON.stringify({ run: manifest.id, started_at: new Date().toISOString() }) + '\n').catch(() => {})
1846
- const up = await uploadRunFiles({ client, org: config.org, runId: manifest.id, dir: run.dir, entries, missing, say })
1948
+ const up = await uploadRunFiles({ client, org: config.org, runId: manifest.id, dir: run.dir, entries, missing, say, ...(args.json ? { write: (t) => process.stderr.write(t) } : {}) })
1847
1949
  if (up.mode === 'blobs') say(` ${up.uploaded} new file(s) sent (${fmtBytes(up.bytes)}), ${up.reused} unchanged file(s) reused`)
1848
1950
  // experimental HTML views (#42): their bundles travel as blobs and attach to the run as revisions
1849
1951
  const viewIds = new Set(manifest.nodes.flatMap((n) => Object.values(n.captures || {}).flatMap((vps) => Object.values(vps).map((c) => c.view?.revision).filter(Boolean))))
@@ -1869,10 +1971,14 @@ const commands = {
1869
1971
  if (review) say(review)
1870
1972
  const capped = expiryNote(days, created.expires_days)
1871
1973
  if (capped) say(`Note: ${capped}`)
1974
+ let prComment = null
1872
1975
  if (args.pr) {
1873
1976
  const r = await upsertPrComment({ manifest, url: fin.url, cwd: root })
1977
+ prComment = r.ok ? { action: r.action, url: r.url } : { skipped: r.reason }
1874
1978
  say(r.ok ? `PR comment ${r.action}: ${r.url}` : `PR comment skipped: ${r.reason}.`)
1875
1979
  }
1980
+ // --json: what a script needs next, the run id and its canvas URL (the lines above went to stderr)
1981
+ if (args.json) say(JSON.stringify({ run: manifest.id, url: fin.url, scenario: scenario.id, files: files.size, expires_days: created.expires_days, review: fin.review || null, ...(args.pr ? { pr_comment: prComment } : {}) }, null, 2))
1876
1982
  },
1877
1983
 
1878
1984
  async sync (args) {
@@ -1889,7 +1995,7 @@ const commands = {
1889
1995
 
1890
1996
  async design (args) {
1891
1997
  if (args._[0] === 'import') return designImport(args)
1892
- if (args._[0] !== 'lint') throw new UserError('Usage: kaloko design lint [--env <design env> | --dir design/] [--scenario file] [--all] [--strict] [--offline] [--json]\n kaloko design import figma <file-url|key|export folder> [--frames a,b] [--map frames.json] [--token-env FIGMA_TOKEN] [--publish]')
1998
+ if (args._[0] !== 'lint') throw new UsageError('Usage: kaloko design lint [--env <design env> | --dir design/] [--scenario file] [--all] [--strict] [--offline] [--json]\n kaloko design import figma <file-url|key|export folder> [--frames a,b] [--map frames.json] [--token-env FIGMA_TOKEN] [--publish]')
1893
1999
  const { config, root } = await context(args)
1894
2000
  let run = null
1895
2001
  try { run = await resolveRun({ root, output: config.output, run: args.run }) } catch {}
@@ -2035,7 +2141,7 @@ const commands = {
2035
2141
  const { config, root } = await context(args)
2036
2142
  let runId = args._[0] || (typeof args.run === 'string' ? args.run : null)
2037
2143
  if (!runId) { try { const r = await resolveRun({ root, output: config.output }); if (r.meta.shared) runId = r.meta.id } catch {} }
2038
- if (!runId) throw new UserError('Usage: kaloko signoff <run-id> [--out dir] | kaloko signoff --verify <file.json|file.pdf>')
2144
+ if (!runId) throw new UsageError('Usage: kaloko signoff <run-id> [--out dir] | kaloko signoff --verify <file.json|file.pdf>')
2039
2145
  return signoffCmd({ config, args, say, client: service(config), runId })
2040
2146
  },
2041
2147
 
@@ -2072,7 +2178,7 @@ const commands = {
2072
2178
  if (typeof args.reopen === 'string') { await client.request('POST', `${base}/${encodeURIComponent(args.reopen)}/reopen`, {}); say(`✓ reopened ${args.reopen}`); return }
2073
2179
  if (typeof args.delete === 'string') { const r = await client.request('DELETE', `${base}/${encodeURIComponent(args.delete)}`); say(`✓ deleted ${args.delete}${r.kept ? ' (kept as "deleted" because others replied)' : ''}`); return }
2074
2180
  if (typeof args.edit === 'string') { await client.request('PATCH', `${base}/${encodeURIComponent(args.edit)}`, { note }); say(`✓ edited ${args.edit}`); return }
2075
- if (!note.trim()) throw new UserError('Write the comment with --note "…".')
2181
+ if (!note.trim()) throw new UsageError('Write the comment with --note "…".')
2076
2182
  const status = ['rejected', 'approved'].includes(args.status) ? args.status : 'comment'
2077
2183
  const body = { note, status, node_id: typeof args.step === 'string' ? args.step : '', ...(typeof args.reply === 'string' ? { parent_id: args.reply } : {}), ...(typeof args.locale === 'string' ? { locale: args.locale } : {}) }
2078
2184
  const c = await client.request('POST', base, body)
@@ -2083,8 +2189,23 @@ const commands = {
2083
2189
  const { config } = await context(args)
2084
2190
  const client = service(config)
2085
2191
  const all = !!args.all || typeof args.scenario === 'string'
2086
- const q = new URLSearchParams({ ...(all ? {} : { latest: '1' }), ...(typeof args.scenario === 'string' ? { scenario: args.scenario } : {}), ...(typeof args.environment === 'string' ? { environment: args.environment } : {}), limit: '300' })
2087
- const runs = await client.request('GET', `/orgs/${config.org}/runs?${q}`)
2192
+ const envName = envOption(args)
2193
+ // --limit N: at most N runs; --all follows the service's cursors to the last run, otherwise the newest RUNS_CAP;
2194
+ // --cursor continues where an earlier list stopped (the cursor it printed)
2195
+ if (args.limit !== undefined && !(Number(args.limit) >= 1)) throw new UsageError('--limit takes a number of runs, e.g. --limit 50.')
2196
+ const want = args.limit !== undefined ? Math.floor(Number(args.limit)) : args.all ? Infinity : RUNS_CAP
2197
+ const runs = []
2198
+ let cursor = typeof args.cursor === 'string' ? args.cursor : null
2199
+ let next = null
2200
+ do {
2201
+ const page = Math.min(1000, want - runs.length)
2202
+ const q = new URLSearchParams({ ...(all ? {} : { latest: '1' }), ...(typeof args.scenario === 'string' ? { scenario: args.scenario } : {}), ...(envName ? { environment: envName } : {}), limit: String(page), ...(cursor ? { cursor } : {}) })
2203
+ const got = await client.page(`/orgs/${config.org}/runs?${q}`)
2204
+ runs.push(...got.data)
2205
+ next = cursor = got.next
2206
+ } while (next && runs.length < want)
2207
+ // more runs than were listed: say how to go on (on stderr, so --json stays one JSON array)
2208
+ if (next) console.error(`Showing ${runs.length}; there are more. kaloko runs${args.all ? ' --all' : ''}${typeof args.scenario === 'string' ? ` --scenario ${args.scenario}` : ''}${envName ? ` --env ${envName}` : ''} --cursor ${next} lists the next ones${all && !args.all ? ', --all every one' : ''}.`)
2088
2209
  if (args.json) { say(JSON.stringify(runs, null, 2)); return }
2089
2210
  if (!runs.length) { say('No shared runs match.'); return }
2090
2211
  for (const r of runs) {
@@ -2096,23 +2217,25 @@ const commands = {
2096
2217
 
2097
2218
  async trash (args) {
2098
2219
  const { config } = await context(args)
2099
- if (!args._.length) throw new UserError('Usage: kaloko trash <run-id>...')
2220
+ if (!args._.length) throw new UsageError('Usage: kaloko trash <run-id>...')
2100
2221
  const client = service(config)
2101
2222
  for (const id of args._) { await client.request('DELETE', `/orgs/${config.org}/runs/${encodeURIComponent(id)}`); say(`✓ ${id} moved to the trash (kaloko restore ${id} brings it back)`) }
2102
2223
  },
2103
2224
 
2104
2225
  async restore (args) {
2105
2226
  const { config } = await context(args)
2106
- if (args._.length !== 1) throw new UserError('Usage: kaloko restore <run-id>')
2227
+ if (args._.length !== 1) throw new UsageError('Usage: kaloko restore <run-id>')
2107
2228
  await service(config).request('POST', `/orgs/${config.org}/runs/${encodeURIComponent(args._[0])}/restore`, {})
2108
2229
  say(`✓ ${args._[0]} restored`)
2109
2230
  },
2110
2231
 
2111
2232
  async prune (args) {
2112
2233
  const { config } = await context(args)
2113
- const days = typeof args['older-than'] === 'string' ? Number(args['older-than'].replace(/d$/, '')) : null
2114
- if (days != null && !Number.isInteger(days)) throw new UserError('--older-than takes days, e.g. 14d.')
2115
- const body = { dry_run: !args.yes, keep_latest: args.keep === undefined ? 3 : Number(args.keep), ...(typeof args.scenario === 'string' ? { scenario: args.scenario } : {}), ...(typeof args.environment === 'string' ? { environment: args.environment } : {}), ...(typeof args.project === 'string' ? { project: args.project } : {}), ...(days != null ? { older_than_days: days } : {}) }
2234
+ const days = parseDuration(args['older-than'], 'd', 'older-than')
2235
+ if (days != null && !Number.isInteger(days)) throw new UsageError(`--older-than counts whole days, e.g. 14, 14d or 48h; ${args['older-than']} is ${Math.round(days * 100) / 100} days.`)
2236
+ if (args.yes && args['dry-run']) throw new UsageError('--dry-run and --yes contradict each other: --dry-run (the default) lists, --yes moves runs to the trash.')
2237
+ const envName = envOption(args)
2238
+ const body = { dry_run: !args.yes, keep_latest: args.keep === undefined ? 3 : Number(args.keep), ...(typeof args.scenario === 'string' ? { scenario: args.scenario } : {}), ...(envName ? { environment: envName } : {}), ...(typeof args.project === 'string' ? { project: args.project } : {}), ...(days != null ? { older_than_days: days } : {}) }
2116
2239
  const r = await service(config).request('POST', `/orgs/${config.org}/runs/prune`, body)
2117
2240
  if (args.json) { say(JSON.stringify(r, null, 2)); return }
2118
2241
  if (!r.trash.length) { say(`Nothing to clean up (keeping the newest ${r.keep_latest} per scenario and environment).`); return }
@@ -2125,23 +2248,25 @@ const commands = {
2125
2248
 
2126
2249
  async keep (args) {
2127
2250
  const { config } = await context(args)
2128
- if (args._.length !== 1) throw new UserError('Usage: kaloko keep <run-id> [--off]')
2251
+ if (args._.length !== 1) throw new UsageError('Usage: kaloko keep <run-id> [--off]')
2129
2252
  const r = await service(config).request('POST', `/orgs/${config.org}/runs/${encodeURIComponent(args._[0])}/keep`, { keep: !args.off })
2130
2253
  say(r.kept ? `✓ ${args._[0]} is kept: it never expires and is never pruned` : `✓ ${args._[0]} is no longer kept`)
2131
2254
  },
2132
2255
 
2133
2256
  async archive (args) {
2134
2257
  const { config } = await context(args)
2135
- if (args._.length !== 1) throw new UserError('Usage: kaloko archive <scenario-id> [--undo]')
2136
- await service(config).request('POST', `/orgs/${config.org}/scenarios/${encodeURIComponent(args._[0])}/${args.undo ? 'unarchive' : 'archive'}`, {})
2137
- say(args.undo ? `✓ ${args._[0]} is active again` : `✓ ${args._[0]} archived: hidden from the scenario list, its runs stay reachable (kaloko archive ${args._[0]} --undo)`)
2258
+ if (args._.length !== 1) throw new UsageError('Usage: kaloko archive <scenario-id> [--off]')
2259
+ // --off undoes it, as with keep and public; --undo is the older name
2260
+ const off = !!(args.off || args.undo)
2261
+ await service(config).request('POST', `/orgs/${config.org}/scenarios/${encodeURIComponent(args._[0])}/${off ? 'unarchive' : 'archive'}`, {})
2262
+ say(off ? `✓ ${args._[0]} is active again` : `✓ ${args._[0]} archived: hidden from the scenario list, its runs stay reachable (kaloko archive ${args._[0]} --off)`)
2138
2263
  },
2139
2264
 
2140
2265
  async login (args) {
2141
2266
  // a project is optional: it only names the service URL, the organization and the token variable
2142
2267
  let config = {}
2143
2268
  const shell = { ...process.env } // before the project and user .env files are read
2144
- try { ({ config } = await context(args)) } catch (e) { if (!(e instanceof UserError)) throw e }
2269
+ try { ({ config } = await context({ ...args, org: undefined })) } catch (e) { if (!(e instanceof UserError)) throw e }
2145
2270
  const tokenEnv = config.share?.token_env || 'KALOKO_TOKEN'
2146
2271
  const inShell = shell[tokenEnv]
2147
2272
  const base = typeof args.url === 'string' ? args.url : serviceUrl(config)
@@ -2218,7 +2343,7 @@ const commands = {
2218
2343
  async draft (args) {
2219
2344
  const { root } = await context(args).catch(() => ({ root: process.cwd() }))
2220
2345
  const urls = args._.filter((u) => /^https?:\/\//.test(u))
2221
- if (!urls.length) throw new UserError('Usage: kaloko draft <url> [url…] (public pages of one site)')
2346
+ if (!urls.length) throw new UsageError('Usage: kaloko draft <url> [url…] (public pages of one site)')
2222
2347
  const pages = []
2223
2348
  for (const url of urls) {
2224
2349
  let facts = {}
@@ -2266,6 +2391,7 @@ const commands = {
2266
2391
  },
2267
2392
 
2268
2393
  async export (args) {
2394
+ exportSubcommand(args)
2269
2395
  const { config, root } = await context(args)
2270
2396
  const client = service(config)
2271
2397
  if (args.design) {
@@ -2336,7 +2462,7 @@ const commands = {
2336
2462
  const engines = []
2337
2463
  const scenarios = []
2338
2464
  for (const f of await scenarioFiles(config, root).catch(() => [])) { try { const s = yaml.load(await readFile(f, 'utf8')) || {}; engines.push(...(s.browsers || [])); scenarios.push({ file: relative(root, f), product: s.product || null, module: s.module || null }) } catch {} }
2339
- const r = await doctor({ config, root, file, fix: !!args.fix, engines, scenarios })
2465
+ const r = await doctor({ config, root, file, fix: !!args.fix, engines, scenarios, legacy: legacyInUse() })
2340
2466
  say(args.json ? JSON.stringify(r, null, 2) : formatDoctor(r))
2341
2467
  if (!r.ok) process.exitCode = 1
2342
2468
  },
@@ -2344,7 +2470,7 @@ const commands = {
2344
2470
  /** `kaloko changelog draft`: the Unreleased entry from merged work and the screens accepted runs show differently. */
2345
2471
  async changelog (args) {
2346
2472
  const sub = args._[0]
2347
- if (sub !== 'draft') throw new UserError('Usage: kaloko changelog draft [--product p] [--since <tag|version>] [--apply] [--json] [--offline]')
2473
+ if (sub !== 'draft') throw new UsageError('Usage: kaloko changelog draft [--product p] [--since <tag|version>] [--apply] [--json] [--offline]')
2348
2474
  const { config, root } = await context(args)
2349
2475
  const { pickProduct, draftChangelog, draftText, applyToChangelog } = await import('./lib/changelog.mjs')
2350
2476
  const product = pickProduct(config, root, args.product)
@@ -2366,7 +2492,7 @@ const commands = {
2366
2492
  /** `kaloko docs publish|check`: the docs folder on Kaloko per version, and what in it needs attention. */
2367
2493
  async docs (args) {
2368
2494
  const sub = args._[0]
2369
- if (!['publish', 'check'].includes(sub)) throw new UserError('Usage: kaloko docs publish [--product p] [--version latest|<v>] [--reviewed] [--dry-run] [--json]\n kaloko docs check [--product p] [--version v] [--offline] [--strict] [--json]')
2495
+ if (!['publish', 'check'].includes(sub)) throw new UsageError('Usage: kaloko docs publish [--product p] [--version latest|<v>] [--reviewed] [--dry-run] [--json]\n kaloko docs check [--product p] [--version v] [--offline] [--strict] [--json]')
2370
2496
  const { config, root } = await context(args)
2371
2497
  const { pickProduct } = await import('./lib/changelog.mjs')
2372
2498
  const docs = await import('./lib/docs.mjs')
@@ -2397,7 +2523,7 @@ const commands = {
2397
2523
  /** `kaloko products sync|list`: the products of the config on Kaloko, without a release; what the service has. */
2398
2524
  async products (args) {
2399
2525
  const sub = args._[0]
2400
- if (!['sync', 'list'].includes(sub)) throw new UserError('Usage: kaloko products sync [--product p] [--dry-run] [--json]\n kaloko products list [--json]')
2526
+ if (!['sync', 'list'].includes(sub)) throw new UsageError('Usage: kaloko products sync [--product p] [--dry-run] [--json]\n kaloko products list [--json]')
2401
2527
  const { config, root } = await context(args)
2402
2528
  const lib = await import('./lib/changelog.mjs')
2403
2529
  if (sub === 'list') {
@@ -2442,18 +2568,86 @@ const commands = {
2442
2568
  say(lines.join('\n'))
2443
2569
  },
2444
2570
 
2445
- async help () { say(HELP) }
2571
+ /** `kaloko help [command]`: everything, or the entries of one command with the common options. */
2572
+ async help (args = { _: [] }) {
2573
+ const topic = args._?.[0]
2574
+ if (!topic) { say(HELP); return }
2575
+ // kaloko help export run: the entries of that sub-command (all of the command's when it has none by that name)
2576
+ const text = commands[topic] && topic !== 'help' ? helpFor(HELP, topic, COMMON_HELP, args._?.[1]) : null
2577
+ if (text) { say(text); return }
2578
+ unknownCommand(topic)
2579
+ }
2580
+ }
2581
+
2582
+ /** The version of this kaloko (package.json). */
2583
+ async function packageVersion () {
2584
+ return JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')).version
2585
+ }
2586
+
2587
+ /** "There is no command": the nearest command when it looks like a typo, the full help, exit code 2. */
2588
+ function unknownCommand (name, json = false) {
2589
+ const guess = closest(name, Object.keys(commands))
2590
+ const message = `There is no command "${name}".${guess ? ` Did you mean kaloko ${guess}?` : ''}`
2591
+ if (json) { console.error(JSON.stringify(jsonError(new UsageError(message, { next: 'kaloko help lists the commands.' })))); process.exitCode = EXIT.usage; return }
2592
+ console.error(`${message} The commands are below; kaloko help <command> explains one.\n`)
2593
+ say(HELP)
2594
+ process.exitCode = EXIT.usage
2595
+ }
2596
+
2597
+ /** A line of output that is a JSON document (the --json answer), told apart from progress and notes. */
2598
+ const isJsonText = (text) => { const t = text.trim(); if (!/^[[{]/.test(t)) return false; try { JSON.parse(t); return true } catch { return false } }
2599
+
2600
+ /** The --json error: { error: { message, code, next } } (help/reference/cli.md → Machine output). */
2601
+ export function jsonError (error) {
2602
+ const usage = error instanceof UsageError
2603
+ return { error: { message: error.reason || error.message, code: typeof error.code === 'string' ? error.code : usage ? 'usage' : error instanceof UserError ? 'failed' : 'internal', next: error.next || (usage ? 'kaloko help <command> shows how to use it.' : null) } }
2604
+ }
2605
+
2606
+ /** The exit code of an error: 2 usage, 3 the service cannot be reached, 4 no or a bad token, else 1. */
2607
+ export const exitCodeOf = (error) => error?.exitCode || (error instanceof UsageError ? EXIT.usage : EXIT.failed)
2608
+
2609
+ /**
2610
+ * Runs one command. `bin` is the name it was started by: the qawalk command from before the rename still works.
2611
+ * With --json, stdout carries only the JSON answer: every other line goes to stderr, and an error is one JSON line
2612
+ * on stderr ({ error: { message, code, next } }) with the same exit code as without --json.
2613
+ */
2614
+ export async function main (argv, { bin = 'kaloko' } = {}) {
2615
+ resetLegacy()
2616
+ if (bin === 'qawalk') noteLegacy('the qawalk command', 'kaloko')
2617
+ const args = parseArgs(argv)
2618
+ const json = !!args.json
2619
+ const stdout = console.log
2620
+ if (json) console.log = (...m) => (isJsonText(m.join(' ')) ? stdout : console.error)(...m)
2621
+ try {
2622
+ await runCommand(args, json)
2623
+ } finally {
2624
+ if (json) console.log = stdout
2625
+ const hint = legacyHint()
2626
+ if (hint) console.error(hint)
2627
+ }
2446
2628
  }
2447
2629
 
2448
- export async function main (argv) {
2449
- const args = parse(argv)
2630
+ async function runCommand (args, json) {
2631
+ if ((!args._.length && args.version) || args._[0] === 'version') { say(json ? JSON.stringify({ version: await packageVersion() }) : await packageVersion()); return }
2450
2632
  const name = args._.shift() || 'help'
2451
- const cmd = commands[name] || (args.help ? commands.help : null)
2452
- if (!cmd) { console.error(`There is no command "${name}". The commands are below; kaloko help <command> explains one.\n`); say(HELP); process.exitCode = 2; return }
2633
+ const cmd = commands[name]
2634
+ if (!cmd) { if (args.help) say(HELP); else unknownCommand(name, json); return }
2635
+ // kaloko <command> --help: the command's help instead of running it
2636
+ if (args.help && name !== 'help') { say(helpFor(HELP, name, COMMON_HELP, args._[0]) || HELP); return }
2453
2637
  try {
2638
+ for (const line of checkOptions(HELP, name, args)) console.error(line)
2454
2639
  await cmd(args)
2455
2640
  } catch (error) {
2456
- if (error instanceof UserError) { console.error(`${error.message}\n${SUPPORT_LINE}`); process.exitCode = 1 } else {
2641
+ const known = error instanceof UserError
2642
+ if (json) {
2643
+ // the reason in one JSON line on stderr, the documented exit code; a bug also prints its stack before it
2644
+ if (!known) console.error(error?.stack || String(error))
2645
+ console.error(JSON.stringify(jsonError(error)))
2646
+ process.exitCode = exitCodeOf(error)
2647
+ return
2648
+ }
2649
+ // a usage mistake: the command's help says what is wrong; no support line, exit code 2
2650
+ if (error instanceof UsageError) { console.error(`${error.message}${/kaloko help /.test(error.message) ? '' : `\nkaloko help ${name} shows how to use it.`}`); process.exitCode = EXIT.usage } else if (known) { console.error(`${error.message}\n${SUPPORT_LINE}`); process.exitCode = exitCodeOf(error) } else {
2457
2651
  console.error(`Kaloko stopped on an unexpected error (a bug on our side). Send the output below to ${SUPPORT_EMAIL} with the command you ran.`)
2458
2652
  throw error
2459
2653
  }