kaloko 0.12.2 → 0.13.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,7 @@ 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 } from './lib/util.mjs'
24
24
  import { packageRoot } from './lib/schema.mjs'
25
25
  import { compareRuns, formatComparison } from './lib/compare.mjs'
26
26
  import { doctor, formatDoctor } from './lib/doctor.mjs'
@@ -36,6 +36,7 @@ import { upsertPrComment } from './lib/pr.mjs'
36
36
  import { formatReviewQueue, shareReviewLine } from './lib/review.mjs'
37
37
  import { htmlViewAllowed } from './lib/htmlview.mjs'
38
38
  import { service, serviceUrl } from './lib/service.mjs'
39
+ import { parseArgs, helpFor, checkOptions, closest } from './lib/cli-args.mjs'
39
40
  import { issueCmd, scheduleCmd } from './lib/integrations-cli.mjs'
40
41
  import { login } from './lib/login.mjs'
41
42
  import { initNextSteps, loginNextSteps } from './lib/onboarding.mjs'
@@ -65,6 +66,20 @@ import { attachAndroidWebView } from './lib/apps/webview.mjs'
65
66
  import { piiSettings, piiFindings, piiRequested, guardAllow, formatFindings } from './lib/pii.mjs'
66
67
 
67
68
 
69
+ /** Options every command takes and the exit codes; the end of kaloko help and of the help of each command. */
70
+ const COMMON_HELP = `Common options (every command)
71
+ --run <id|dir> The run to act on (default: the current run, or KALOKO_RUN)
72
+ --config <file> Another kaloko.config.yml
73
+ --org <slug> Another organization than org: in the config
74
+ --json Machine-readable output, where a command prints data
75
+ --help, -h Help for one command, like kaloko help <command>
76
+ --version, -v The version of kaloko (also kaloko version)
77
+ KALOKO_AGENT=<name> Labels writes by an agent
78
+
79
+ Exit codes: 0 done · 1 checks found problems or the command failed · 2 usage error (unknown command or option,
80
+ missing argument)
81
+ `
82
+
68
83
  const HELP = `kaloko — walk, assess and approve what your agents built, on one flow canvas
69
84
 
70
85
  Usage: kaloko <command> [options]
@@ -107,7 +122,7 @@ Project
107
122
  Run
108
123
  start --scenario <file> --env <name> [--locales cs,en] [--viewports desktop,mobile] [--executor <name>]
109
124
  [--app-version <v>] (default: the git tag on HEAD; documentation captures carry it)
110
- Create a run directory and make it current
125
+ Create a run directory and make it current (--json: the run id and its folder)
111
126
  [--viewports iphone-15,ipad-landscape,desktop] Viewport names of the scenario or device presets
112
127
  (kaloko devices lists them; <preset>-landscape turns one sideways)
113
128
  [--browsers chromium,webkit,firefox] [--color-schemes light,dark] [--reduced-motion no-preference,reduce]
@@ -182,7 +197,7 @@ Run
182
197
  [--env e] [--locale l] [--viewport v] [--map "Title=step,…"] [--format f] [--out file]
183
198
  Screenshots, recordings and traces from agents and tools as a run: into the
184
199
  current run, a new run of --scenario, or an ad-hoc flow of the screens
185
- status Show what the current run has captured
200
+ status [--json] Show what the current run has captured
186
201
  Scenarios may declare "sessions" (people in the flow); walk keeps one browser context per session and locale
187
202
 
188
203
  Results
@@ -214,15 +229,18 @@ Results
214
229
  comment --reply|--resolve|--reopen|--edit|--delete <id> [--note "…"] [--flow]
215
230
  Comment, answer and close feedback from the CLI (resolve with "Fixed in <commit>")
216
231
  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
232
+ runs [--scenario id] [--env e] [--all] [--json]
233
+ Shared runs: the newest per scenario (with "+ N older"); --all or --scenario lists every
234
+ run (the newest 300; --scenario and --env narrow the list)
219
235
  trash <run-id>... | restore <run-id>
220
236
  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]
237
+ prune [--scenario id] [--env e] [--keep 3] [--older-than 14d] [--dry-run | --yes] [--json]
222
238
  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
239
+ referenced and commented runs; lists what would go to the trash (--dry-run, the
240
+ default) unless --yes
224
241
  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
242
+ archive <scenario-id> [--off] Hide a retired scenario from the scenario list (admin); its runs stay reachable;
243
+ --off shows it again
226
244
  inbox [--all] [--read] [--json] Your notifications on kaloko.app (mentions, returned runs, new runs, expiry); --read marks them read
227
245
  calibrate [--golden labels.yml] [--scenario id [--limit 30]] [--target 0.95] [--json]
228
246
  Semantic evaluator vs human labels: precision/recall per criterion at the
@@ -248,7 +266,7 @@ Results
248
266
  Regions people drew on screenshots on kaloko.app (step detail → Mark a changing
249
267
  region) go into the scenario's ignore: of their steps; walk them again after
250
268
  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]
269
+ [--status pass|partial|fail|skip] [--env e] [--designs] [--all] [--json] [--cursor c]
252
270
  The screen library: the newest screenshot of every step across your flows, searched
253
271
  by title, text, URL or id, with the canvas link of each (--designs adds design versions)
254
272
  compare [--run id] [--baseline id | --design] [--json]
@@ -353,8 +371,7 @@ Design (environments with kind: design; live HTML steps in a folder)
353
371
  A design version in open formats: HTML + assets, tokens.json (DTCG), scenario.yml,
354
372
  screenshots and an index.html that opens from disk (default: the design reference)
355
373
 
356
- Common options: --run <id|dir> (or KALOKO_RUN) --config <file> KALOKO_AGENT=<name> labels writes by an agent
357
- `
374
+ ${COMMON_HELP}`
358
375
 
359
376
  /** A comment's drawing for the terminal: "· box 120,340 300×80 px on desktop" (CSS px when the size is known). */
360
377
  export function regionText (g) {
@@ -397,19 +414,6 @@ async function walkVerdicts ({ prefetch, run, scenario, config, root }) {
397
414
  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
415
  }
399
416
 
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
417
  const list = (v) => (typeof v === 'string' ? v.split(',').map((s) => s.trim()).filter(Boolean) : null)
414
418
  // parallel walks (#27) print each locale's lines together, in locale order: `say` writes into the running unit's buffer
415
419
  const output = new AsyncLocalStorage()
@@ -446,8 +450,15 @@ export function walkConcurrency (args, config, units, shared, cpus = availablePa
446
450
  return Math.max(1, Math.min(units, 4, Math.floor(cpus / 2)))
447
451
  }
448
452
 
453
+ /** --env names an environment everywhere; --environment is its older spelling on runs, prune and screens. */
454
+ const envOption = (args) => typeof args.env === 'string' ? args.env : typeof args.environment === 'string' ? args.environment : null
455
+ /** The most runs `kaloko runs` lists in one go. */
456
+ const RUNS_CAP = 300
457
+
449
458
  async function context (args) {
450
459
  const { config, root, file } = await loadConfig(process.cwd(), args.config)
460
+ // --org: another organization than the config's for this command (an agent working for several)
461
+ if (typeof args.org === 'string' && args.org) config.org = args.org
451
462
  return { config, root, file }
452
463
  }
453
464
 
@@ -463,7 +474,7 @@ async function runContext (args) {
463
474
  }
464
475
 
465
476
  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(', ')}.`)
477
+ 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
478
  const node = scenario.nodes.find((n) => n.id === id)
468
479
  if (!node) throw new UserError(`Unknown step "${id}". Steps: ${scenario.nodes.filter((n) => n.kind !== 'decision').map((n) => n.id).join(', ')}`)
469
480
  return node
@@ -491,7 +502,7 @@ async function lintOrigins (config, env, { online = false } = {}) {
491
502
  */
492
503
  async function designImport (args) {
493
504
  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]]')
505
+ 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
506
  const { config, root } = await context(args)
496
507
  let run = null
497
508
  try { run = await resolveRun({ root, output: config.output, run: args.run }) } catch {}
@@ -506,22 +517,24 @@ async function designImport (args) {
506
517
  const viewports = Object.fromEntries(baseViewports(scope).map((v) => [v, scope.viewports[v]]))
507
518
  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
519
  const dir = typeof args.dir === 'string' ? resolve(args.dir) : designDir(env, root)
520
+ // --json: standard output is the JSON alone; progress and next steps go to standard error
521
+ const log = args.json ? (...m) => console.error(...m) : say
509
522
  const r = await importFigma({
510
523
  source, scenario, viewports, locales: scenario.locales || ['default'], dir,
511
524
  token: process.env[tokenVar] || (tokenVar === 'FIGMA_TOKEN' ? process.env.FIGMA_ACCESS_TOKEN : null) || null,
512
525
  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
526
+ tokensMode: ['write', 'skip'].includes(args.tokens) ? args.tokens : 'auto', force: !!args.force, dryRun: !!args['dry-run'], say: log
514
527
  })
515
528
  if (args.json) say(JSON.stringify(r, null, 2))
516
529
  else say(formatImport(r, { dir: relative(process.cwd(), dir) || '.' }))
517
530
  if (args['dry-run']) return r
518
531
  const lint = await lintDesign({ dir, scenario, origins: await lintOrigins(config, env) })
519
- if (lint.errors) say(`\n${formatLint(lint, { max: 10 })}`)
532
+ if (lint.errors) log(`\n${formatLint(lint, { max: 10 })}`)
520
533
  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.`)
534
+ 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
535
  return r
523
536
  }
524
- await commands.start({ ...args, _: [], scenario: scenarioFile, env: env.name })
537
+ await commands.start({ ...args, _: [], scenario: scenarioFile, env: env.name, json: false })
525
538
  const current = await resolveRun({ root, output: config.output })
526
539
  await commands.walk({ _: [], run: current.dir })
527
540
  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 +547,7 @@ async function scenarioIdOf (args, config, root) {
534
547
  return args.scenario
535
548
  }
536
549
  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.')
550
+ throw new UsageError('--scenario <id|file> is required when there is no current run.')
538
551
  }
539
552
 
540
553
  /**
@@ -943,7 +956,7 @@ const commands = {
943
956
  if (!exists) {
944
957
  let tpl = await readFile(join(packageRoot, 'templates', 'kaloko.config.yml'), 'utf8')
945
958
  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, -).')
959
+ 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
960
  tpl = tpl.replace(/^org: .*$/m, `org: ${args.org}`)
948
961
  }
949
962
  if (detected) {
@@ -956,7 +969,7 @@ const commands = {
956
969
  } else if (!existsSync(join(cwd, LEGACY_CONFIG_FILE))) say(`${CONFIG_FILE} already exists; kept.`)
957
970
  // the visual changelog (docs/DOCS-CHANGELOG.md): asked once; --docs=off|changelog|full for scripts and agents
958
971
  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.')
972
+ if (docsMode && !['off', 'changelog', 'full'].includes(docsMode)) throw new UsageError('--docs must be off, changelog or full.')
960
973
  if (!docsMode && process.stdin.isTTY && process.stdout.isTTY && !process.env.CI && !process.env.NODE_TEST_CONTEXT) {
961
974
  const rl = (await import('node:readline/promises')).createInterface({ input: process.stdin, output: process.stdout })
962
975
  const a = (await rl.question('Keep a visual changelog? (recommended) [Y/n] ')).trim().toLowerCase(); rl.close()
@@ -987,7 +1000,7 @@ const commands = {
987
1000
  }
988
1001
  } else if (!docsMode) say('Visual changelog: kaloko init --docs=changelog adds CHANGELOG.md and the config block (recommended).')
989
1002
  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.')
1003
+ if (!['claude', 'codex', 'all', 'none'].includes(agent)) throw new UsageError('--agent must be claude, codex, all or none.')
991
1004
  const { cp, mkdir, appendFile, rm } = await import('node:fs/promises')
992
1005
  const skill = join(packageRoot, 'skills', 'kaloko')
993
1006
  const targets = { claude: ['.claude/skills/kaloko'], codex: ['skills/kaloko'], all: ['.claude/skills/kaloko', 'skills/kaloko'], none: [] }[agent]
@@ -1047,7 +1060,7 @@ const commands = {
1047
1060
  },
1048
1061
 
1049
1062
  async validate (args) {
1050
- if (!args._.length) throw new UserError('Usage: kaloko validate <scenario.yml>...')
1063
+ if (!args._.length) throw new UsageError('Usage: kaloko validate <scenario.yml>...')
1051
1064
  let failed = 0
1052
1065
  for (const file of args._) {
1053
1066
  let scenario
@@ -1078,7 +1091,7 @@ const commands = {
1078
1091
 
1079
1092
  async start (args) {
1080
1093
  const { config, root } = await context(args)
1081
- if (!args.scenario || !args.env) throw new UserError('Usage: kaloko start --scenario <file> --env <name>')
1094
+ if (!args.scenario || !args.env) throw new UsageError('Usage: kaloko start --scenario <file> --env <name>')
1082
1095
  const scenarioFile = resolve(args.scenario)
1083
1096
  const scenario = await loadScenario(scenarioFile)
1084
1097
  const env = environment(config, args.env)
@@ -1099,9 +1112,11 @@ const commands = {
1099
1112
  // the product the flow documents (visual changelog): its tag, or the only product of the config
1100
1113
  const product = scenario.product || (Object.keys(config.products || {}).length === 1 ? Object.keys(config.products)[0] : null)
1101
1114
  const { dir, meta } = await createRun({ output: config.output, scenario, scenarioFile, env, scope, git, executor, root, project, issue, appVersion: appVer, product, pii })
1115
+ const addr = recipient(env.mail, meta.short)
1116
+ // --json: what a script needs to pin the run (KALOKO_RUN) and find its files
1117
+ 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
1118
  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
1119
  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
1120
  if (addr) say(` test e-mail: ${addr}`)
1106
1121
  say(` other agents may start runs here too: pin this one with --run ${meta.id} or KALOKO_RUN=${meta.id}`)
1107
1122
  },
@@ -1501,7 +1516,7 @@ const commands = {
1501
1516
  */
1502
1517
  async import (args) {
1503
1518
  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,…"]')
1519
+ 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
1520
  const { config, root } = await context(args)
1506
1521
  let source
1507
1522
  try { source = await readSource(resolve(from), typeof args.format === 'string' ? args.format : 'auto') } catch (e) { throw new UserError(`kaloko import: ${e.message}`) }
@@ -1581,7 +1596,7 @@ const commands = {
1581
1596
  console.log(preview.url)
1582
1597
  return
1583
1598
  }
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]')
1599
+ 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
1600
  const { config, root, file } = await context(args)
1586
1601
  if (args.release) {
1587
1602
  // release-tool integrations: record each published release on Kaloko (docs/DOCS-CHANGELOG.md)
@@ -1666,7 +1681,7 @@ const commands = {
1666
1681
  say(rows.length ? rows.join('\n') : 'No accounts in kaloko.config.yml (environments.<env>.accounts).')
1667
1682
  return
1668
1683
  }
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')
1684
+ 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
1685
  const env = environment(config, args.env)
1671
1686
  if (env.readonly) throw new UserError(`Environment ${env.name} is readonly; saved sign-ins are for accounts on non-production environments.`)
1672
1687
  const account = accountFor(env, args.account, { root, config })
@@ -1690,7 +1705,7 @@ const commands = {
1690
1705
  },
1691
1706
 
1692
1707
  async session (args) {
1693
- if (args._[0] !== 'reset') throw new UserError('Usage: kaloko session reset')
1708
+ if (args._[0] !== 'reset') throw new UsageError('Usage: kaloko session reset')
1694
1709
  const { config, root, env } = await runContext(args)
1695
1710
  const conn = await connectBrowser({ root, config, env })
1696
1711
  if (!conn) throw new UserError('Start the browser first: kaloko browser start')
@@ -1701,6 +1716,18 @@ const commands = {
1701
1716
  async status (args) {
1702
1717
  const { config, root, run, scenario, env } = await runContext(args)
1703
1718
  const caps = await readCaptures(run.dir)
1719
+ if (args.json && env.kind !== 'design') {
1720
+ // per step and locale: the viewports captured so far (stack steps: the pages)
1721
+ const steps = scenario.nodes.filter((n) => n.kind !== 'decision').map((node) => ({
1722
+ id: node.id, kind: node.kind, strategy: node.execution?.strategy || null,
1723
+ captured: Object.fromEntries(run.meta.scope.locales.map((l) => {
1724
+ const mine = caps.filter((c) => c.type === 'capture' && c.node === node.id && c.locale === l)
1725
+ 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))]]
1726
+ }))
1727
+ }))
1728
+ say(JSON.stringify({ run: run.meta.id, dir: run.dir, env: run.meta.environment.name, locales: run.meta.scope.locales, steps }, null, 2))
1729
+ return
1730
+ }
1704
1731
  say(`Run ${run.meta.id} · ${run.meta.environment.name} · ${run.meta.scope.locales.join(', ')}`)
1705
1732
  if (env.kind === 'design') {
1706
1733
  // per step: unchanged / changed here / changed in the draft, from the folder and the last sync (#42)
@@ -1889,7 +1916,7 @@ const commands = {
1889
1916
 
1890
1917
  async design (args) {
1891
1918
  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]')
1919
+ 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
1920
  const { config, root } = await context(args)
1894
1921
  let run = null
1895
1922
  try { run = await resolveRun({ root, output: config.output, run: args.run }) } catch {}
@@ -2035,7 +2062,7 @@ const commands = {
2035
2062
  const { config, root } = await context(args)
2036
2063
  let runId = args._[0] || (typeof args.run === 'string' ? args.run : null)
2037
2064
  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>')
2065
+ if (!runId) throw new UsageError('Usage: kaloko signoff <run-id> [--out dir] | kaloko signoff --verify <file.json|file.pdf>')
2039
2066
  return signoffCmd({ config, args, say, client: service(config), runId })
2040
2067
  },
2041
2068
 
@@ -2072,7 +2099,7 @@ const commands = {
2072
2099
  if (typeof args.reopen === 'string') { await client.request('POST', `${base}/${encodeURIComponent(args.reopen)}/reopen`, {}); say(`✓ reopened ${args.reopen}`); return }
2073
2100
  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
2101
  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 "…".')
2102
+ if (!note.trim()) throw new UsageError('Write the comment with --note "…".')
2076
2103
  const status = ['rejected', 'approved'].includes(args.status) ? args.status : 'comment'
2077
2104
  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
2105
  const c = await client.request('POST', base, body)
@@ -2083,8 +2110,11 @@ const commands = {
2083
2110
  const { config } = await context(args)
2084
2111
  const client = service(config)
2085
2112
  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' })
2113
+ const envName = envOption(args)
2114
+ const q = new URLSearchParams({ ...(all ? {} : { latest: '1' }), ...(typeof args.scenario === 'string' ? { scenario: args.scenario } : {}), ...(envName ? { environment: envName } : {}), limit: String(RUNS_CAP) })
2087
2115
  const runs = await client.request('GET', `/orgs/${config.org}/runs?${q}`)
2116
+ // the service answers at most RUNS_CAP: say so (on stderr, so --json stays one JSON array)
2117
+ if (runs.length >= RUNS_CAP) console.error(`Showing the newest ${RUNS_CAP}; there may be more. Narrow the list with --scenario <id> or --env <name>.`)
2088
2118
  if (args.json) { say(JSON.stringify(runs, null, 2)); return }
2089
2119
  if (!runs.length) { say('No shared runs match.'); return }
2090
2120
  for (const r of runs) {
@@ -2096,14 +2126,14 @@ const commands = {
2096
2126
 
2097
2127
  async trash (args) {
2098
2128
  const { config } = await context(args)
2099
- if (!args._.length) throw new UserError('Usage: kaloko trash <run-id>...')
2129
+ if (!args._.length) throw new UsageError('Usage: kaloko trash <run-id>...')
2100
2130
  const client = service(config)
2101
2131
  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
2132
  },
2103
2133
 
2104
2134
  async restore (args) {
2105
2135
  const { config } = await context(args)
2106
- if (args._.length !== 1) throw new UserError('Usage: kaloko restore <run-id>')
2136
+ if (args._.length !== 1) throw new UsageError('Usage: kaloko restore <run-id>')
2107
2137
  await service(config).request('POST', `/orgs/${config.org}/runs/${encodeURIComponent(args._[0])}/restore`, {})
2108
2138
  say(`✓ ${args._[0]} restored`)
2109
2139
  },
@@ -2111,8 +2141,10 @@ const commands = {
2111
2141
  async prune (args) {
2112
2142
  const { config } = await context(args)
2113
2143
  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 } : {}) }
2144
+ if (days != null && !Number.isInteger(days)) throw new UsageError('--older-than takes days, e.g. 14d.')
2145
+ 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.')
2146
+ const envName = envOption(args)
2147
+ 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
2148
  const r = await service(config).request('POST', `/orgs/${config.org}/runs/prune`, body)
2117
2149
  if (args.json) { say(JSON.stringify(r, null, 2)); return }
2118
2150
  if (!r.trash.length) { say(`Nothing to clean up (keeping the newest ${r.keep_latest} per scenario and environment).`); return }
@@ -2125,23 +2157,25 @@ const commands = {
2125
2157
 
2126
2158
  async keep (args) {
2127
2159
  const { config } = await context(args)
2128
- if (args._.length !== 1) throw new UserError('Usage: kaloko keep <run-id> [--off]')
2160
+ if (args._.length !== 1) throw new UsageError('Usage: kaloko keep <run-id> [--off]')
2129
2161
  const r = await service(config).request('POST', `/orgs/${config.org}/runs/${encodeURIComponent(args._[0])}/keep`, { keep: !args.off })
2130
2162
  say(r.kept ? `✓ ${args._[0]} is kept: it never expires and is never pruned` : `✓ ${args._[0]} is no longer kept`)
2131
2163
  },
2132
2164
 
2133
2165
  async archive (args) {
2134
2166
  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)`)
2167
+ if (args._.length !== 1) throw new UsageError('Usage: kaloko archive <scenario-id> [--off]')
2168
+ // --off undoes it, as with keep and public; --undo is the older name
2169
+ const off = !!(args.off || args.undo)
2170
+ await service(config).request('POST', `/orgs/${config.org}/scenarios/${encodeURIComponent(args._[0])}/${off ? 'unarchive' : 'archive'}`, {})
2171
+ 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
2172
  },
2139
2173
 
2140
2174
  async login (args) {
2141
2175
  // a project is optional: it only names the service URL, the organization and the token variable
2142
2176
  let config = {}
2143
2177
  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 }
2178
+ try { ({ config } = await context({ ...args, org: undefined })) } catch (e) { if (!(e instanceof UserError)) throw e }
2145
2179
  const tokenEnv = config.share?.token_env || 'KALOKO_TOKEN'
2146
2180
  const inShell = shell[tokenEnv]
2147
2181
  const base = typeof args.url === 'string' ? args.url : serviceUrl(config)
@@ -2218,7 +2252,7 @@ const commands = {
2218
2252
  async draft (args) {
2219
2253
  const { root } = await context(args).catch(() => ({ root: process.cwd() }))
2220
2254
  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)')
2255
+ if (!urls.length) throw new UsageError('Usage: kaloko draft <url> [url…] (public pages of one site)')
2222
2256
  const pages = []
2223
2257
  for (const url of urls) {
2224
2258
  let facts = {}
@@ -2344,7 +2378,7 @@ const commands = {
2344
2378
  /** `kaloko changelog draft`: the Unreleased entry from merged work and the screens accepted runs show differently. */
2345
2379
  async changelog (args) {
2346
2380
  const sub = args._[0]
2347
- if (sub !== 'draft') throw new UserError('Usage: kaloko changelog draft [--product p] [--since <tag|version>] [--apply] [--json] [--offline]')
2381
+ if (sub !== 'draft') throw new UsageError('Usage: kaloko changelog draft [--product p] [--since <tag|version>] [--apply] [--json] [--offline]')
2348
2382
  const { config, root } = await context(args)
2349
2383
  const { pickProduct, draftChangelog, draftText, applyToChangelog } = await import('./lib/changelog.mjs')
2350
2384
  const product = pickProduct(config, root, args.product)
@@ -2366,7 +2400,7 @@ const commands = {
2366
2400
  /** `kaloko docs publish|check`: the docs folder on Kaloko per version, and what in it needs attention. */
2367
2401
  async docs (args) {
2368
2402
  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]')
2403
+ 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
2404
  const { config, root } = await context(args)
2371
2405
  const { pickProduct } = await import('./lib/changelog.mjs')
2372
2406
  const docs = await import('./lib/docs.mjs')
@@ -2397,7 +2431,7 @@ const commands = {
2397
2431
  /** `kaloko products sync|list`: the products of the config on Kaloko, without a release; what the service has. */
2398
2432
  async products (args) {
2399
2433
  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]')
2434
+ if (!['sync', 'list'].includes(sub)) throw new UsageError('Usage: kaloko products sync [--product p] [--dry-run] [--json]\n kaloko products list [--json]')
2401
2435
  const { config, root } = await context(args)
2402
2436
  const lib = await import('./lib/changelog.mjs')
2403
2437
  if (sub === 'list') {
@@ -2442,18 +2476,43 @@ const commands = {
2442
2476
  say(lines.join('\n'))
2443
2477
  },
2444
2478
 
2445
- async help () { say(HELP) }
2479
+ /** `kaloko help [command]`: everything, or the entries of one command with the common options. */
2480
+ async help (args = { _: [] }) {
2481
+ const topic = args._?.[0]
2482
+ if (!topic) { say(HELP); return }
2483
+ const text = commands[topic] && topic !== 'help' ? helpFor(HELP, topic, COMMON_HELP) : null
2484
+ if (text) { say(text); return }
2485
+ unknownCommand(topic)
2486
+ }
2487
+ }
2488
+
2489
+ /** The version of this kaloko (package.json). */
2490
+ async function packageVersion () {
2491
+ return JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')).version
2492
+ }
2493
+
2494
+ /** "There is no command": the nearest command when it looks like a typo, the full help, exit code 2. */
2495
+ function unknownCommand (name) {
2496
+ const guess = closest(name, Object.keys(commands))
2497
+ console.error(`There is no command "${name}".${guess ? ` Did you mean kaloko ${guess}?` : ''} The commands are below; kaloko help <command> explains one.\n`)
2498
+ say(HELP)
2499
+ process.exitCode = 2
2446
2500
  }
2447
2501
 
2448
2502
  export async function main (argv) {
2449
- const args = parse(argv)
2503
+ const args = parseArgs(argv)
2504
+ if ((!args._.length && args.version) || args._[0] === 'version') { say(await packageVersion()); return }
2450
2505
  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 }
2506
+ const cmd = commands[name]
2507
+ if (!cmd) { if (args.help) say(HELP); else unknownCommand(name); return }
2508
+ // kaloko <command> --help: the command's help instead of running it
2509
+ if (args.help && name !== 'help') { say(helpFor(HELP, name, COMMON_HELP) || HELP); return }
2453
2510
  try {
2511
+ for (const line of checkOptions(HELP, name, args)) console.error(line)
2454
2512
  await cmd(args)
2455
2513
  } catch (error) {
2456
- if (error instanceof UserError) { console.error(`${error.message}\n${SUPPORT_LINE}`); process.exitCode = 1 } else {
2514
+ // a usage mistake: the command's help says what is wrong; no support line, exit code 2
2515
+ if (error instanceof UsageError) { console.error(`${error.message}${/kaloko help /.test(error.message) ? '' : `\nkaloko help ${name} shows how to use it.`}`); process.exitCode = 2 } else if (error instanceof UserError) { console.error(`${error.message}\n${SUPPORT_LINE}`); process.exitCode = 1 } else {
2457
2516
  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
2517
  throw error
2459
2518
  }
@@ -0,0 +1,139 @@
1
+ // The command line of kaloko: arguments, the help of one command and the options each command takes. Both come from
2
+ // the help text (src/cli.mjs HELP), so a documented option is an accepted one; HIDDEN adds aliases and options kept
3
+ // for older scripts that the help no longer shows.
4
+ import { UsageError } from './util.mjs'
5
+
6
+ /** Options every command takes (Common options in the help). */
7
+ export const COMMON_OPTIONS = ['run', 'config', 'org', 'json', 'help']
8
+
9
+ /**
10
+ * Accepted options the help does not show: aliases of renamed ones (--environment is --env, --undo is --off) and
11
+ * options of sub-commands or older scripts. Each name here is read by the command's code.
12
+ */
13
+ export const HIDDEN = {
14
+ prune: ['environment', 'project'],
15
+ runs: ['environment'],
16
+ screens: ['environment', 'q', 'source', 'limit'],
17
+ archive: ['undo'],
18
+ start: ['note', 'color_schemes', 'reduced_motion', 'forced_colors', 'display_modes', 'token_modes'],
19
+ design: ['dir', 'token', 'name', 'notes', 'branch', 'allow-pii', 'env'],
20
+ export: ['server-pdf', 'branch'],
21
+ walk: ['items', 'viewports', 'no-cache', 'record-quality', 'trace', 'offline', 'files'],
22
+ affected: ['no-upstream'],
23
+ open: ['page'],
24
+ mail: ['viewports'],
25
+ issue: ['locale', 'viewport', 'target', 'state'],
26
+ video: ['quality', 'card-seconds', 'viewport'],
27
+ spec: ['remote', 'scenario', 'version', 'step'],
28
+ drift: ['locale', 'viewport', 'version', 'scenario', 'step'],
29
+ history: ['lang'],
30
+ tokens: ['env', 'version'],
31
+ stability: ['scenario'],
32
+ status: ['branch'],
33
+ feedback: ['branch'],
34
+ share: ['branch', 'name', 'notes', 'notes-preview', 'strict', 'mine', 'theirs', 'lang'],
35
+ sync: ['mine', 'theirs', 'branch', 'strict'],
36
+ reference: ['branch'],
37
+ branch: ['from', 'git', 'take-branch', 'keep-main', 'scenario'],
38
+ schedule: ['force'],
39
+ comment: ['locale'],
40
+ capture: ['stability'],
41
+ import: ['executor', 'config', 'locales', 'viewports', 'id', 'title', 'force'],
42
+ browser: ['env', 'run'],
43
+ ci: ['wait', 'github-env', 'dotenv']
44
+ }
45
+
46
+ /** Commands whose unknown options stop the command instead of a warning: they delete, publish or change access. */
47
+ export const STRICT = new Set(['prune', 'trash', 'restore', 'keep', 'archive', 'public', 'release', 'comment'])
48
+
49
+ /**
50
+ * argv → { _: positional, <option>: value | true }. `--key=value` splits at the first `=` only; `--key value` takes
51
+ * the next word unless it is another option; `-h` and `-v` are --help and --version.
52
+ */
53
+ export function parseArgs (argv) {
54
+ const args = { _: [] }
55
+ const short = { '-h': 'help', '-v': 'version' }
56
+ for (let i = 0; i < argv.length; i++) {
57
+ const a = argv[i]
58
+ if (a.startsWith('--')) {
59
+ const eq = a.indexOf('=')
60
+ const k = eq < 0 ? a.slice(2) : a.slice(2, eq)
61
+ const next = argv[i + 1]
62
+ if (eq >= 0) args[k] = a.slice(eq + 1)
63
+ else if (next && !next.startsWith('--') && !short[next]) args[k] = argv[++i]
64
+ else args[k] = true
65
+ } else if (short[a]) args[short[a]] = true
66
+ else args._.push(a)
67
+ }
68
+ return args
69
+ }
70
+
71
+ /**
72
+ * The help text as entries: each " <command> …" line with its continuation lines, the commands it documents (the
73
+ * first word, and words after " | " such as "trash … | restore") and its section heading.
74
+ */
75
+ export function helpEntries (help) {
76
+ const out = []
77
+ let section = ''
78
+ let cur = null
79
+ for (const line of help.split('\n')) {
80
+ if (/^[A-Z]/.test(line)) { section = line; cur = null; continue }
81
+ const m = line.match(/^ {2}([a-z][a-z-]*)/)
82
+ if (m) {
83
+ cur = { section, commands: new Set([m[1], ...[...line.matchAll(/ \| ([a-z][a-z-]*)/g)].map((x) => x[1])]), lines: [line] }
84
+ out.push(cur)
85
+ } else if (cur && /^ {3,}\S/.test(line)) cur.lines.push(line)
86
+ else cur = null
87
+ }
88
+ return out
89
+ }
90
+
91
+ /** The help of one command: its entries under their section headings, then `common`; null for an unknown one. */
92
+ export function helpFor (help, name, common = '') {
93
+ const entries = helpEntries(help).filter((e) => e.commands.has(name))
94
+ if (!entries.length) return null
95
+ const parts = []
96
+ let section = null
97
+ for (const e of entries) {
98
+ if (e.section !== section) { section = e.section; parts.push(`${parts.length ? '\n' : ''}${section}`) }
99
+ parts.push(...e.lines)
100
+ }
101
+ return `Usage: kaloko ${name} [options]\n\n${parts.join('\n')}\n${common ? `\n${common}` : ''}`
102
+ }
103
+
104
+ /** The options a command takes: the ones its help names, the hidden ones and the common ones. */
105
+ export function optionsFor (help, name) {
106
+ const named = helpEntries(help).filter((e) => e.commands.has(name)).flatMap((e) => e.lines.join('\n').match(/--[a-z][a-z0-9-]*/g) || [])
107
+ return new Set([...named.map((f) => f.slice(2)), ...(HIDDEN[name] || []), ...COMMON_OPTIONS])
108
+ }
109
+
110
+ /** Edit distance (insertions, deletions, substitutions) between two short words. */
111
+ export function distance (a, b) {
112
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j)
113
+ for (let i = 1; i <= a.length; i++) {
114
+ const row = [i]
115
+ for (let j = 1; j <= b.length; j++) row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
116
+ prev = row
117
+ }
118
+ return prev[b.length]
119
+ }
120
+
121
+ /** The closest of `words` to `word` within 2 edits (a typo), or null. */
122
+ export function closest (word, words) {
123
+ let best = null; let d = 3
124
+ for (const w of words) { const x = distance(word, w); if (x < d) { d = x; best = w } }
125
+ return best
126
+ }
127
+
128
+ /**
129
+ * Options `args` has that `name` does not take: a UsageError for commands in STRICT (nothing has happened yet), a
130
+ * list of warning lines for the others (they run as before, without the unknown option).
131
+ */
132
+ export function checkOptions (help, name, args) {
133
+ const known = optionsFor(help, name)
134
+ const unknown = Object.keys(args).filter((k) => k !== '_' && k !== '' && !known.has(k))
135
+ if (!unknown.length) return []
136
+ const lines = unknown.map((k) => { const guess = closest(k, [...known]); return `kaloko ${name} does not take --${k}.${guess ? ` Did you mean --${guess}?` : ''}` })
137
+ if (STRICT.has(name)) throw new UsageError(`${lines.join('\n')}\nNothing was changed. kaloko help ${name} lists its options.`)
138
+ return lines.map((l) => `! ${l} It is ignored (kaloko help ${name} lists the options).`)
139
+ }