@credda/cli 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * looks up and offline-verifies EXISTING, already-computed trust facts.
8
8
  * `mint`/`revoke` manage a share token (a capability, not a score write).
9
9
  */
10
- export const VERSION = '0.0.1';
10
+ export const VERSION = '0.1.2';
11
11
  export const HELP = `credda — portable trust from the terminal
12
12
 
13
13
  Public (no API key):
@@ -18,22 +18,133 @@ Public (no API key):
18
18
  export bundle (auto-detected). '-' = stdin.
19
19
  credda registry Federated trust registry
20
20
  credda did Issuer DID document
21
+ credda benchmarks Cohort-benchmark catalog (dimensions + k-anonymity)
22
+ credda reason-codes Adverse-action reason-code catalog (ECOA / Reg B)
23
+ credda badges list Open Badges 3.0 achievements this issuer signs
24
+ credda badges get <badgeId> One achievement definition
25
+ credda professional-record public <token>
26
+ The professional record behind a share token
27
+ (the subject's own consent to present it)
21
28
 
22
29
  Platform (needs CREDDA_API_KEY):
23
30
  credda score <userId> Current score
24
31
  credda explain <userId> Factor-level score explanation
25
32
  credda components <userId> Six named 0-100 score components
26
33
  credda risk <userId> Advisory risk signals
27
- credda usage [days] Your platform's metered API usage
34
+ credda trust-summary <userId> [--narrative]
35
+ Deterministic, evidence-based trust summary
36
+ (explains; never a verdict). --narrative adds an
37
+ advisory AI retelling when the server has AI on.
38
+ credda benchmark <userId> [--dimension <d>]
39
+ Where a subject sits within its cohort:
40
+ percentile + the cohort distribution
41
+ credda distribution [--dimension <d>] [--cohort <c>]
42
+ Aggregate, k-anonymised cohort distribution.
43
+ Omit --cohort for every cohort on the dimension.
44
+ credda users [--score-min <n>] [--score-max <n>] [--band <b>] [--subject-type <PERSON|AGENT>]
45
+ [--active-since <iso>] [--verified] [--min-verified <n>]
46
+ [--sort <score|lastActivity|registered|externalId>] [--order <asc|desc>]
47
+ [--cursor <c>] [--limit <n>]
48
+ Query + export your book of subjects
49
+ credda usage [days] [--from <date> --to <date>] [--csv <outfile>]
50
+ Your platform's metered API usage. Either a
51
+ trailing [days] window OR an inclusive
52
+ --from/--to date range (YYYY-MM-DD), not both.
53
+ --csv writes the flat CSV statement to a file.
54
+ credda activity [--action <A>] [--from <t> --to <t>] [--cursor <c>] [--limit <n>]
55
+ Your platform's own activity/audit log,
56
+ newest-first, cursor-paginated
57
+ credda verified-profile <userId>
58
+ How much of a subject's CLAIMED record
59
+ (education/skills/certifications/employment) is
60
+ third-party verified. Counts whether a claim is
61
+ verified, never how prestigious it is — and it
62
+ can never move the Reliability Score.
63
+ credda qualify <userId> --category <education|skill|certification|employment>
64
+ [--label <l>] [--issuer <i>] [--verified-by <witness>]
65
+ Record a qualification claim. Always recorded;
66
+ counts as VERIFIED only with a genuine
67
+ third-party --verified-by witness.
68
+ credda professional-record get <userId>
69
+ Résumé-shaped summary of a VERIFIED work record.
70
+ Describes a record — not a hiring verdict, a
71
+ background check, or a consumer report.
72
+ credda professional-record credential <userId> [--ttl <seconds>]
73
+ Mint the signed, offline-verifiable Professional
74
+ Record Credential (+ an "Add to LinkedIn" link)
28
75
  credda mint <userId> Mint a share token for a user
29
76
  credda revoke <userId> Revoke a user's share token
30
77
 
78
+ Confirmation requests — the counterparty-confirmation primitive. You PROPOSE an
79
+ outcome and deliver the one-time token to the counterparty over YOUR OWN channel;
80
+ the event is written, verified, only when that distinct party confirms:
81
+ credda confirmations create --user <externalId> --type <eventType>
82
+ --counterparty <ref> [--counterparty-name <n>] [--description <d>]
83
+ [--stake <HIGH|MEDIUM|LOW>] [--value <n>] [--due <iso>] [--completed <iso>]
84
+ [--return-url <url>] [--expires-in <days>] [--idempotency-key <k>]
85
+ Needs CREDDA_API_KEY. Token shown ONCE.
86
+ credda confirmations list [--status <s>] [--cursor <c>] [--limit <n>]
87
+ credda confirmations get <id>
88
+ credda confirmations cancel <id>
89
+ credda confirmations preview <id> --token <t>
90
+ What the counterparty is asked to confirm.
91
+ NO API key — the token is the capability.
92
+ credda confirmations respond <id> --token <t> (--confirm | --decline)
93
+ The counterparty's decision. NO API key.
94
+ --confirm writes the event; --decline writes
95
+ nothing. Single-use either way.
96
+
97
+ Threshold policies (needs CREDDA_API_KEY) — declarative "tell me when this line
98
+ is crossed"; delivers policy.threshold_crossed through your webhooks. Config
99
+ only: a policy never reads into, blocks, or changes a score:
100
+ credda policies list [--cursor <c>] [--limit <n>]
101
+ credda policies get <id>
102
+ credda policies create --name <n> (--user <externalId> | --all)
103
+ --metric <score|component|band|verified_events>
104
+ [--direction <up|down|enter|leave>] [--threshold <n>]
105
+ [--component <reliability|timeliness|trustworthiness|verification|consistency|momentum>]
106
+ [--band <b>]
107
+ credda policies update <id> [--name <n>] [--direction <d>] [--threshold <n>]
108
+ [--component <c>] [--band <b>] [--activate | --deactivate]
109
+ The metric is immutable — delete + recreate.
110
+ credda policies delete <id>
111
+
112
+ Score monitors (needs CREDDA_API_KEY) — edge-triggered watches that deliver
113
+ "monitor.triggered" through your webhooks; notification config only, a
114
+ monitor never affects a score:
115
+ credda monitors list [--cursor <c>] [--limit <n>]
116
+ credda monitors get <id>
117
+ credda monitors create --user <externalId> [--below <score>] [--above <score>] [--band-change]
118
+ At least one condition required. --below fires
119
+ on a downward crossing (and on a first score
120
+ already below it), --above on an upward
121
+ crossing, --band-change on any band change.
122
+ credda monitors delete <id>
123
+
124
+ Bulk screenings (needs CREDDA_API_KEY) — async batch score reads, up to
125
+ 10,000 ids per job, strictly read-only:
126
+ credda screen <ids...> Submit ids (comma/space separated), or:
127
+ credda screen --file <path> One id per line, or a CSV whose FIRST column
128
+ is the id (a leading "id"/"userId"/
129
+ "externalId" header row is skipped).
130
+ [--wait] Poll until the job finishes, then print the
131
+ summary (exit 1 if the job FAILED).
132
+ credda screenings list [--cursor <c>] [--limit <n>]
133
+ credda screenings get <id> Job status + summary
134
+ credda screenings results <id> [--csv <outfile>]
135
+ Per-user results (JSON; --csv writes the CSV
136
+ attachment to a file instead)
137
+
31
138
  Webhooks (needs CREDDA_API_KEY):
32
139
  credda webhooks list Your webhook subscriptions
33
140
  credda webhooks create <url> <event..> Subscribe (secret shown ONCE)
34
141
  credda webhooks delete <id> Remove a webhook
35
142
  credda webhooks test <id> Send a synthetic signed delivery
36
143
  credda webhooks deliveries <id> Recent delivery attempts (incl. retries)
144
+ credda webhooks recent [event..] Recent events across ALL your endpoints
145
+ (sample data for automation platforms;
146
+ falls back to catalog examples, flagged
147
+ isExample, when nothing has fired yet)
37
148
 
38
149
  Local development:
39
150
  credda listen [port] Local webhook receiver: verifies each delivery's
@@ -63,6 +174,134 @@ function requireArg(args, name) {
63
174
  function show(ctx, value) {
64
175
  ctx.out(JSON.stringify(value, null, 2));
65
176
  }
177
+ /**
178
+ * Extra stderr lines for a failed command.
179
+ *
180
+ * The important one is the **request id**: it is the single fastest way for
181
+ * Credda to diagnose a failure, and a CLI user has nowhere else to find it.
182
+ * Also surfaces the machine code (so it can be looked up in
183
+ * `GET /api/v1/errors`) and any `Retry-After` the server asked for.
184
+ *
185
+ * Duck-typed rather than `instanceof CreddaError` on purpose — the router
186
+ * imports only TYPES from the SDK, so it stays pure and trivially mockable.
187
+ * Pure and exported for testing.
188
+ */
189
+ export function errorHints(e) {
190
+ if (!e || typeof e !== 'object')
191
+ return [];
192
+ const err = e;
193
+ const lines = [];
194
+ if (typeof err.code === 'string' && err.code) {
195
+ lines.push(` code: ${err.code} (see https://api.credda.io/api/v1/errors)`);
196
+ }
197
+ if (typeof err.requestId === 'string' && err.requestId) {
198
+ lines.push(` requestId: ${err.requestId} (quote this to support)`);
199
+ }
200
+ if (typeof err.retryAfterMs === 'number' && err.retryAfterMs > 0) {
201
+ lines.push(` retry in: ${Math.ceil(err.retryAfterMs / 1000)}s`);
202
+ }
203
+ return lines;
204
+ }
205
+ /**
206
+ * Tiny flag parser: `--name value` for valued flags, bare `--name` for
207
+ * booleans, everything else positional. Unknown `--flags` are an error rather
208
+ * than silently becoming positionals.
209
+ */
210
+ export function parseFlags(args, spec = {}) {
211
+ const valued = new Set(spec.valued ?? []);
212
+ const bools = new Set(spec.boolean ?? []);
213
+ const positional = [];
214
+ const flags = {};
215
+ for (let i = 0; i < args.length; i++) {
216
+ const a = args[i];
217
+ if (a.startsWith('--')) {
218
+ const name = a.slice(2);
219
+ if (bools.has(name)) {
220
+ flags[name] = true;
221
+ }
222
+ else if (valued.has(name)) {
223
+ const v = args[++i];
224
+ if (v === undefined)
225
+ throw new Error(`--${name} needs a value — see "credda help"`);
226
+ flags[name] = v;
227
+ }
228
+ else {
229
+ throw new Error(`unknown flag --${name} — see "credda help"`);
230
+ }
231
+ }
232
+ else {
233
+ positional.push(a);
234
+ }
235
+ }
236
+ return { positional, flags };
237
+ }
238
+ function numFlag(flags, name) {
239
+ const v = flags[name];
240
+ if (v === undefined)
241
+ return undefined;
242
+ const n = Number(v);
243
+ if (typeof v !== 'string' || v === '' || !Number.isFinite(n)) {
244
+ throw new Error(`--${name} must be a number`);
245
+ }
246
+ return n;
247
+ }
248
+ function intFlag(flags, name) {
249
+ const n = numFlag(flags, name);
250
+ if (n !== undefined && (!Number.isInteger(n) || n < 1)) {
251
+ throw new Error(`--${name} must be a positive integer`);
252
+ }
253
+ return n;
254
+ }
255
+ function strFlag(flags, name) {
256
+ const v = flags[name];
257
+ return typeof v === 'string' ? v : undefined;
258
+ }
259
+ /** `--cursor` / `--limit` shared by the paginated list commands. */
260
+ function pageQuery(flags) {
261
+ return { limit: intFlag(flags, 'limit'), cursor: strFlag(flags, 'cursor') };
262
+ }
263
+ const ID_HEADER_NAMES = /^(id|userid|user_id|externalid|external_id)$/i;
264
+ /**
265
+ * Parse the ids for `credda screen`. Inline args may be comma- and/or
266
+ * space-separated. A file is one id per line — or a CSV, in which case only
267
+ * the FIRST column is read (a leading header row named id/userId/externalId
268
+ * is skipped). Deduped, order-preserving. Deliberately simple: no quoted-CSV
269
+ * handling — an id containing a comma isn't a valid external id anyway.
270
+ */
271
+ export function parseIdList(input) {
272
+ const raw = [];
273
+ if (input.inline) {
274
+ for (const chunk of input.inline)
275
+ raw.push(...chunk.split(/[\s,]+/));
276
+ }
277
+ if (input.fileText !== undefined) {
278
+ const lines = input.fileText.split(/\r?\n/);
279
+ for (let i = 0; i < lines.length; i++) {
280
+ const cell = lines[i].split(',')[0].trim();
281
+ if (!cell)
282
+ continue;
283
+ if (i === 0 && ID_HEADER_NAMES.test(cell))
284
+ continue; // CSV header row
285
+ raw.push(cell);
286
+ }
287
+ }
288
+ const seen = new Set();
289
+ const ids = [];
290
+ for (const id of raw) {
291
+ const trimmed = id.trim();
292
+ if (trimmed && !seen.has(trimmed)) {
293
+ seen.add(trimmed);
294
+ ids.push(trimmed);
295
+ }
296
+ }
297
+ return ids;
298
+ }
299
+ function requireCsvIo(ctx) {
300
+ if (!ctx.fetchCsv || !ctx.writeFile) {
301
+ throw new Error('CSV output is not available in this environment');
302
+ }
303
+ return { fetchCsv: ctx.fetchCsv, writeFile: ctx.writeFile };
304
+ }
66
305
  /** Classify verify input: trust-export bundle JSON, VC-JWT, or compact credential. */
67
306
  export function classifyCredentialInput(raw) {
68
307
  const trimmed = raw.trim();
@@ -131,6 +370,32 @@ export async function runCli(argv, ctx) {
131
370
  case 'did':
132
371
  show(ctx, await ctx.client.getDidDocument());
133
372
  return 0;
373
+ case 'benchmarks':
374
+ // Public catalog: cohort dimensions + the k-anonymity floor. A benchmark
375
+ // is a distribution fact, never a verdict.
376
+ show(ctx, await ctx.client.getBenchmarks());
377
+ return 0;
378
+ case 'reason-codes':
379
+ // Public adverse-action reason-code catalog (ECOA / Reg B). Credda
380
+ // supplies the attribution only — it is not a creditor.
381
+ show(ctx, await ctx.client.getReasonCodes());
382
+ return 0;
383
+ case 'badges': {
384
+ // Public: the closed set of Open Badges 3.0 achievements this issuer
385
+ // will sign. A verifier reads the criteria from the ISSUER rather than
386
+ // trusting the narrative inside the document it is checking.
387
+ const [sub, ...rest] = args;
388
+ switch (sub) {
389
+ case 'list':
390
+ show(ctx, await ctx.client.getOpenBadgeAchievements());
391
+ return 0;
392
+ case 'get':
393
+ show(ctx, await ctx.client.getOpenBadgeAchievement(requireArg(rest, 'badgeId')));
394
+ return 0;
395
+ default:
396
+ throw new Error(`unknown badges subcommand "${sub ?? ''}" — see "credda help"`);
397
+ }
398
+ }
134
399
  case 'score':
135
400
  show(ctx, await ctx.client.getScore(requireArg(args, 'userId'), requireKey(ctx)));
136
401
  return 0;
@@ -143,14 +408,436 @@ export async function runCli(argv, ctx) {
143
408
  case 'risk':
144
409
  show(ctx, await ctx.client.getRisk(requireArg(args, 'userId'), requireKey(ctx)));
145
410
  return 0;
411
+ case 'trust-summary': {
412
+ const { positional, flags } = parseFlags(args, { boolean: ['narrative'] });
413
+ const userId = requireArg(positional, 'userId');
414
+ show(ctx, await ctx.client.getTrustSummary(userId, requireKey(ctx), {
415
+ narrative: flags.narrative === true,
416
+ }));
417
+ return 0;
418
+ }
419
+ case 'benchmark': {
420
+ // Where one subject sits within a cohort — percentile + distribution.
421
+ const { positional, flags } = parseFlags(args, { valued: ['dimension'] });
422
+ const userId = requireArg(positional, 'userId');
423
+ show(ctx, await ctx.client.getUserBenchmark(userId, requireKey(ctx), {
424
+ dimension: strFlag(flags, 'dimension'),
425
+ }));
426
+ return 0;
427
+ }
428
+ case 'distribution': {
429
+ // Aggregate, k-anonymised cohort distribution (or a whole dimension).
430
+ const { positional, flags } = parseFlags(args, { valued: ['dimension', 'cohort'] });
431
+ if (positional.length > 0) {
432
+ throw new Error(`distribution takes no positional arguments (got "${positional[0]}") — see "credda help"`);
433
+ }
434
+ show(ctx, await ctx.client.getBenchmarkDistribution(requireKey(ctx), {
435
+ dimension: strFlag(flags, 'dimension'),
436
+ cohort: strFlag(flags, 'cohort'),
437
+ }));
438
+ return 0;
439
+ }
440
+ case 'users': {
441
+ // Query + export your book of subjects (closed filter set).
442
+ const { positional, flags } = parseFlags(args, {
443
+ valued: [
444
+ 'score-min', 'score-max', 'band', 'subject-type', 'active-since',
445
+ 'min-verified', 'sort', 'order', 'cursor', 'limit',
446
+ ],
447
+ boolean: ['verified'],
448
+ });
449
+ if (positional.length > 0) {
450
+ throw new Error(`users takes no positional arguments (got "${positional[0]}") — see "credda help"`);
451
+ }
452
+ const query = {
453
+ scoreMin: numFlag(flags, 'score-min'),
454
+ scoreMax: numFlag(flags, 'score-max'),
455
+ band: strFlag(flags, 'band'),
456
+ subjectType: strFlag(flags, 'subject-type'),
457
+ activeSince: strFlag(flags, 'active-since'),
458
+ minVerifiedEvents: intFlag(flags, 'min-verified'),
459
+ sort: strFlag(flags, 'sort'),
460
+ order: strFlag(flags, 'order'),
461
+ ...pageQuery(flags),
462
+ };
463
+ if (flags.verified === true)
464
+ query.hasVerifiedEvents = true;
465
+ show(ctx, await ctx.client.listUsers(requireKey(ctx), query));
466
+ return 0;
467
+ }
146
468
  case 'usage': {
147
- const days = args[0] ? Number(args[0]) : undefined;
469
+ const { positional, flags } = parseFlags(args, { valued: ['from', 'to', 'csv'] });
470
+ const days = positional[0] ? Number(positional[0]) : undefined;
148
471
  if (days !== undefined && (!Number.isInteger(days) || days < 1)) {
149
472
  throw new Error('usage [days] — days must be a positive integer');
150
473
  }
151
- show(ctx, await ctx.client.getUsage(requireKey(ctx), days));
474
+ const from = strFlag(flags, 'from');
475
+ const to = strFlag(flags, 'to');
476
+ if (days !== undefined && (from || to)) {
477
+ throw new Error('usage — use either a [days] window or --from/--to, not both');
478
+ }
479
+ const key = requireKey(ctx);
480
+ const outfile = strFlag(flags, 'csv');
481
+ if (outfile) {
482
+ const { fetchCsv, writeFile } = requireCsvIo(ctx);
483
+ const qs = new URLSearchParams({ format: 'csv' });
484
+ if (days !== undefined)
485
+ qs.set('days', String(days));
486
+ if (from)
487
+ qs.set('from', from);
488
+ if (to)
489
+ qs.set('to', to);
490
+ await writeFile(outfile, await fetchCsv(`/usage?${qs.toString()}`, key));
491
+ show(ctx, { written: outfile });
492
+ return 0;
493
+ }
494
+ const window = from || to ? { from, to } : days;
495
+ show(ctx, await ctx.client.getUsage(key, window));
496
+ return 0;
497
+ }
498
+ case 'activity': {
499
+ const { positional, flags } = parseFlags(args, {
500
+ valued: ['action', 'from', 'to', 'cursor', 'limit'],
501
+ });
502
+ if (positional.length > 0) {
503
+ throw new Error(`activity takes no positional arguments (got "${positional[0]}") — see "credda help"`);
504
+ }
505
+ show(ctx, await ctx.client.getActivity(requireKey(ctx), {
506
+ ...pageQuery(flags),
507
+ action: strFlag(flags, 'action'),
508
+ from: strFlag(flags, 'from'),
509
+ to: strFlag(flags, 'to'),
510
+ }));
511
+ return 0;
512
+ }
513
+ case 'verified-profile':
514
+ // A SECOND measure over the same ledger — it can never move a score.
515
+ show(ctx, await ctx.client.getVerifiedProfile(requireArg(args, 'userId'), requireKey(ctx)));
516
+ return 0;
517
+ case 'qualify': {
518
+ // The claim is ALWAYS recorded; --verified-by decides whether it counts
519
+ // as verified. Never assert it yourself — name the witness.
520
+ const { positional, flags } = parseFlags(args, {
521
+ valued: ['category', 'label', 'issuer', 'verified-by'],
522
+ });
523
+ const userId = requireArg(positional, 'userId');
524
+ const category = strFlag(flags, 'category');
525
+ if (!category) {
526
+ throw new Error('qualify needs --category <education|skill|certification|employment>');
527
+ }
528
+ show(ctx, await ctx.client.recordQualification(userId, {
529
+ category: category,
530
+ label: strFlag(flags, 'label'),
531
+ issuer: strFlag(flags, 'issuer'),
532
+ verifiedBy: strFlag(flags, 'verified-by'),
533
+ }, requireKey(ctx)));
534
+ return 0;
535
+ }
536
+ case 'professional-record': {
537
+ const [sub, ...rest] = args;
538
+ switch (sub) {
539
+ case 'get':
540
+ show(ctx, await ctx.client.getProfessionalRecord(requireArg(rest, 'userId'), requireKey(ctx)));
541
+ return 0;
542
+ case 'credential': {
543
+ const { positional, flags } = parseFlags(rest, { valued: ['ttl'] });
544
+ const userId = requireArg(positional, 'userId');
545
+ const ttlSeconds = intFlag(flags, 'ttl');
546
+ const minted = await ctx.client.mintProfessionalRecordCredential(userId, requireKey(ctx), ttlSeconds !== undefined ? { ttlSeconds } : {});
547
+ show(ctx, minted);
548
+ return 0;
549
+ }
550
+ case 'public':
551
+ // Public: the token IS the subject's consent to present the record.
552
+ show(ctx, await ctx.client.getPublicProfessionalRecord(requireArg(rest, 'token')));
553
+ return 0;
554
+ default:
555
+ throw new Error(`unknown professional-record subcommand "${sub ?? ''}" — see "credda help"`);
556
+ }
557
+ }
558
+ case 'confirmations': {
559
+ // ⚠️ Auth is ASYMMETRIC here: preview/respond are the COUNTERPARTY's
560
+ // calls and take no API key at all, so requireKey() is deliberately
561
+ // scoped per-subcommand rather than hoisted for the whole group.
562
+ const [sub, ...rest] = args;
563
+ switch (sub) {
564
+ case 'create': {
565
+ const { flags } = parseFlags(rest, {
566
+ valued: [
567
+ 'user', 'type', 'counterparty', 'counterparty-name', 'description',
568
+ 'stake', 'value', 'due', 'completed', 'return-url', 'expires-in',
569
+ 'idempotency-key',
570
+ ],
571
+ });
572
+ const userId = strFlag(flags, 'user');
573
+ const eventType = strFlag(flags, 'type');
574
+ const counterpartyRef = strFlag(flags, 'counterparty');
575
+ if (!userId || !eventType || !counterpartyRef) {
576
+ throw new Error('confirmations create needs --user <externalId> (the subject), --type <eventType> and --counterparty <ref> (your key for the party being asked to confirm)');
577
+ }
578
+ const stake = strFlag(flags, 'stake');
579
+ const value = numFlag(flags, 'value');
580
+ const expiresInDays = intFlag(flags, 'expires-in');
581
+ const created = await ctx.client.createConfirmationRequest({
582
+ userId,
583
+ eventType: eventType,
584
+ counterpartyRef,
585
+ ...(strFlag(flags, 'counterparty-name') ? { counterpartyName: strFlag(flags, 'counterparty-name') } : {}),
586
+ ...(strFlag(flags, 'description') ? { description: strFlag(flags, 'description') } : {}),
587
+ ...(stake ? { stakeLevel: stake } : {}),
588
+ ...(value !== undefined ? { transactionValue: value } : {}),
589
+ ...(strFlag(flags, 'due') ? { dueDate: strFlag(flags, 'due') } : {}),
590
+ ...(strFlag(flags, 'completed') ? { completedAt: strFlag(flags, 'completed') } : {}),
591
+ ...(strFlag(flags, 'return-url') ? { returnUrl: strFlag(flags, 'return-url') } : {}),
592
+ ...(expiresInDays !== undefined ? { expiresInDays } : {}),
593
+ }, requireKey(ctx), { idempotencyKey: strFlag(flags, 'idempotency-key') });
594
+ ctx.err('NOTE: the confirmationToken below is shown ONCE — deliver it to the counterparty over your own channel.');
595
+ show(ctx, created);
596
+ return 0;
597
+ }
598
+ case 'list': {
599
+ const { flags } = parseFlags(rest, { valued: ['status', 'cursor', 'limit'] });
600
+ const status = strFlag(flags, 'status');
601
+ show(ctx, await ctx.client.listConfirmations(requireKey(ctx), {
602
+ ...pageQuery(flags),
603
+ ...(status ? { status: status.toUpperCase() } : {}),
604
+ }));
605
+ return 0;
606
+ }
607
+ case 'get':
608
+ show(ctx, await ctx.client.getConfirmation(requireArg(rest, 'id'), requireKey(ctx)));
609
+ return 0;
610
+ case 'cancel':
611
+ show(ctx, await ctx.client.cancelConfirmation(requireArg(rest, 'id'), requireKey(ctx)));
612
+ return 0;
613
+ case 'preview': {
614
+ // KEYLESS on purpose — the counterparty holds a token, not a key.
615
+ const { positional, flags } = parseFlags(rest, { valued: ['token'] });
616
+ const id = requireArg(positional, 'id');
617
+ const token = strFlag(flags, 'token');
618
+ if (!token)
619
+ throw new Error('confirmations preview needs --token <t> (the one-time token you were sent)');
620
+ show(ctx, await ctx.client.previewConfirmation(id, token));
621
+ return 0;
622
+ }
623
+ case 'respond': {
624
+ // KEYLESS on purpose. The decision is explicit: there is no default,
625
+ // because confirming an outcome you did not witness is the one thing
626
+ // this primitive exists to prevent.
627
+ const { positional, flags } = parseFlags(rest, {
628
+ valued: ['token'],
629
+ boolean: ['confirm', 'decline'],
630
+ });
631
+ const id = requireArg(positional, 'id');
632
+ const token = strFlag(flags, 'token');
633
+ if (!token)
634
+ throw new Error('confirmations respond needs --token <t> (the one-time token you were sent)');
635
+ const confirm = flags.confirm === true;
636
+ const decline = flags.decline === true;
637
+ if (confirm === decline) {
638
+ throw new Error('confirmations respond needs exactly one of --confirm or --decline');
639
+ }
640
+ show(ctx, await ctx.client.respondToConfirmation(id, token, confirm ? 'confirm' : 'decline'));
641
+ return 0;
642
+ }
643
+ default:
644
+ throw new Error(`unknown confirmations subcommand "${sub ?? ''}" — see "credda help"`);
645
+ }
646
+ }
647
+ case 'policies': {
648
+ const [sub, ...rest] = args;
649
+ const key = requireKey(ctx);
650
+ switch (sub) {
651
+ case 'list': {
652
+ const { flags } = parseFlags(rest, { valued: ['cursor', 'limit'] });
653
+ show(ctx, await ctx.client.listPolicies(key, pageQuery(flags)));
654
+ return 0;
655
+ }
656
+ case 'get':
657
+ show(ctx, await ctx.client.getPolicy(requireArg(rest, 'id'), key));
658
+ return 0;
659
+ case 'create': {
660
+ const { flags } = parseFlags(rest, {
661
+ valued: ['name', 'user', 'metric', 'direction', 'threshold', 'component', 'band'],
662
+ boolean: ['all'],
663
+ });
664
+ const name = strFlag(flags, 'name');
665
+ const metric = strFlag(flags, 'metric');
666
+ if (!name || !metric) {
667
+ throw new Error('policies create needs --name <n> and --metric <score|component|band|verified_events>');
668
+ }
669
+ const userId = strFlag(flags, 'user');
670
+ const appliesToAll = flags.all === true;
671
+ if (Boolean(userId) === appliesToAll) {
672
+ throw new Error('policies create needs exactly one of --user <externalId> (watch one subject) or --all (watch all your subjects)');
673
+ }
674
+ const threshold = numFlag(flags, 'threshold');
675
+ const input = {
676
+ name,
677
+ metric: metric,
678
+ ...(userId ? { userId } : { appliesToAll: true }),
679
+ };
680
+ const direction = strFlag(flags, 'direction');
681
+ if (direction)
682
+ input.direction = direction;
683
+ if (threshold !== undefined)
684
+ input.threshold = threshold;
685
+ const component = strFlag(flags, 'component');
686
+ if (component)
687
+ input.component = component;
688
+ const band = strFlag(flags, 'band');
689
+ if (band)
690
+ input.band = band;
691
+ show(ctx, await ctx.client.createPolicy(input, key));
692
+ return 0;
693
+ }
694
+ case 'update': {
695
+ const { positional, flags } = parseFlags(rest, {
696
+ valued: ['name', 'direction', 'threshold', 'component', 'band'],
697
+ boolean: ['activate', 'deactivate'],
698
+ });
699
+ const id = requireArg(positional, 'id');
700
+ if (flags.activate === true && flags.deactivate === true) {
701
+ throw new Error('policies update — pass at most one of --activate / --deactivate');
702
+ }
703
+ const patch = {};
704
+ const name = strFlag(flags, 'name');
705
+ if (name)
706
+ patch.name = name;
707
+ const direction = strFlag(flags, 'direction');
708
+ if (direction)
709
+ patch.direction = direction;
710
+ const threshold = numFlag(flags, 'threshold');
711
+ if (threshold !== undefined)
712
+ patch.threshold = threshold;
713
+ const component = strFlag(flags, 'component');
714
+ if (component)
715
+ patch.component = component;
716
+ const band = strFlag(flags, 'band');
717
+ if (band)
718
+ patch.band = band;
719
+ if (flags.activate === true)
720
+ patch.isActive = true;
721
+ if (flags.deactivate === true)
722
+ patch.isActive = false;
723
+ if (Object.keys(patch).length === 0) {
724
+ throw new Error('policies update needs at least one field to change — see "credda help"');
725
+ }
726
+ show(ctx, await ctx.client.updatePolicy(id, patch, key));
727
+ return 0;
728
+ }
729
+ case 'delete':
730
+ await ctx.client.deletePolicy(requireArg(rest, 'id'), key);
731
+ show(ctx, { deleted: true });
732
+ return 0;
733
+ default:
734
+ throw new Error(`unknown policies subcommand "${sub ?? ''}" — see "credda help"`);
735
+ }
736
+ }
737
+ case 'monitors': {
738
+ const [sub, ...rest] = args;
739
+ const key = requireKey(ctx);
740
+ switch (sub) {
741
+ case 'list': {
742
+ const { flags } = parseFlags(rest, { valued: ['cursor', 'limit'] });
743
+ show(ctx, await ctx.client.listMonitors(key, pageQuery(flags)));
744
+ return 0;
745
+ }
746
+ case 'get':
747
+ show(ctx, await ctx.client.getMonitor(requireArg(rest, 'id'), key));
748
+ return 0;
749
+ case 'create': {
750
+ const { flags } = parseFlags(rest, {
751
+ valued: ['user', 'below', 'above'],
752
+ boolean: ['band-change'],
753
+ });
754
+ const userId = strFlag(flags, 'user');
755
+ if (!userId) {
756
+ throw new Error('monitors create needs --user <externalId> (the user to watch)');
757
+ }
758
+ const belowScore = numFlag(flags, 'below');
759
+ const aboveScore = numFlag(flags, 'above');
760
+ const onBandChange = flags['band-change'] === true;
761
+ if (belowScore === undefined && aboveScore === undefined && !onBandChange) {
762
+ throw new Error('monitors create needs at least one condition: --below <score> (fires when the score crosses DOWN through it), --above <score> (crosses UP), or --band-change (any band change)');
763
+ }
764
+ show(ctx, await ctx.client.createMonitor({
765
+ userId,
766
+ ...(belowScore !== undefined ? { belowScore } : {}),
767
+ ...(aboveScore !== undefined ? { aboveScore } : {}),
768
+ ...(onBandChange ? { onBandChange } : {}),
769
+ }, key));
770
+ return 0;
771
+ }
772
+ case 'delete':
773
+ await ctx.client.deleteMonitor(requireArg(rest, 'id'), key);
774
+ show(ctx, { deleted: true });
775
+ return 0;
776
+ default:
777
+ throw new Error(`unknown monitors subcommand "${sub ?? ''}" — see "credda help"`);
778
+ }
779
+ }
780
+ case 'screen': {
781
+ const { positional, flags } = parseFlags(args, {
782
+ valued: ['file'],
783
+ boolean: ['wait'],
784
+ });
785
+ const file = strFlag(flags, 'file');
786
+ if (file && positional.length > 0) {
787
+ throw new Error('screen — pass ids inline OR --file <path>, not both');
788
+ }
789
+ const ids = parseIdList(file ? { fileText: await ctx.readInput(file) } : { inline: positional });
790
+ if (ids.length === 0) {
791
+ throw new Error('screen — no ids found. Pass ids (comma/space separated) or --file <path> (one id per line, or a CSV whose first column is the id)');
792
+ }
793
+ const key = requireKey(ctx);
794
+ let job = (await ctx.client.createScreening(ids, key)).screening;
795
+ if (flags.wait === true) {
796
+ const sleep = ctx.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
797
+ const interval = ctx.pollIntervalMs ?? 2000;
798
+ while (job.status === 'QUEUED' || job.status === 'RUNNING') {
799
+ ctx.err(`screening ${job.id}: ${job.status} — waiting…`);
800
+ await sleep(interval);
801
+ job = (await ctx.client.getScreening(job.id, key)).screening;
802
+ }
803
+ show(ctx, { screening: job });
804
+ return job.status === 'COMPLETED' ? 0 : 1;
805
+ }
806
+ show(ctx, { screening: job });
807
+ if (job.status === 'QUEUED' || job.status === 'RUNNING') {
808
+ ctx.err(`still ${job.status} — poll with "credda screenings get ${job.id}" (or re-run with --wait)`);
809
+ }
152
810
  return 0;
153
811
  }
812
+ case 'screenings': {
813
+ const [sub, ...rest] = args;
814
+ const key = requireKey(ctx);
815
+ switch (sub) {
816
+ case 'list': {
817
+ const { flags } = parseFlags(rest, { valued: ['cursor', 'limit'] });
818
+ show(ctx, await ctx.client.listScreenings(key, pageQuery(flags)));
819
+ return 0;
820
+ }
821
+ case 'get':
822
+ show(ctx, await ctx.client.getScreening(requireArg(rest, 'id'), key));
823
+ return 0;
824
+ case 'results': {
825
+ const { positional, flags } = parseFlags(rest, { valued: ['csv'] });
826
+ const id = requireArg(positional, 'id');
827
+ const outfile = strFlag(flags, 'csv');
828
+ if (outfile) {
829
+ const { fetchCsv, writeFile } = requireCsvIo(ctx);
830
+ await writeFile(outfile, await fetchCsv(`/screenings/${encodeURIComponent(id)}/results?format=csv`, key));
831
+ show(ctx, { written: outfile });
832
+ return 0;
833
+ }
834
+ show(ctx, await ctx.client.getScreeningResults(id, key));
835
+ return 0;
836
+ }
837
+ default:
838
+ throw new Error(`unknown screenings subcommand "${sub ?? ''}" — see "credda help"`);
839
+ }
840
+ }
154
841
  case 'webhooks': {
155
842
  const [sub, ...rest] = args;
156
843
  const key = requireKey(ctx);
@@ -162,7 +849,7 @@ export async function runCli(argv, ctx) {
162
849
  const url = requireArg(rest, 'url');
163
850
  const events = rest.slice(1);
164
851
  if (events.length === 0) {
165
- throw new Error('webhooks create <url> <event...> — provide at least one event (score.updated, score.band_changed, dispute.resolved)');
852
+ throw new Error('webhooks create <url> <event...> — provide at least one event (score.updated, score.band_changed, dispute.resolved, monitor.triggered, usage.quota_warning)');
166
853
  }
167
854
  const created = await ctx.client.createWebhook({ url, events: events }, key);
168
855
  ctx.err('NOTE: the signing secret below is shown ONCE — store it now.');
@@ -179,6 +866,13 @@ export async function runCli(argv, ctx) {
179
866
  case 'deliveries':
180
867
  show(ctx, await ctx.client.getWebhookDeliveries(requireArg(rest, 'id'), key, 25));
181
868
  return 0;
869
+ case 'recent': {
870
+ // Sample data across ALL endpoints — falls back to the event
871
+ // catalog's examples (isExample:true) when nothing has fired yet.
872
+ const eventType = rest.length > 0 ? rest : undefined;
873
+ show(ctx, await ctx.client.getRecentWebhookEvents(key, { limit: 25, eventType }));
874
+ return 0;
875
+ }
182
876
  default:
183
877
  throw new Error(`unknown webhooks subcommand "${sub ?? ''}" — see "credda help"`);
184
878
  }
@@ -208,6 +902,8 @@ export async function runCli(argv, ctx) {
208
902
  const message = e instanceof Error ? e.message : String(e);
209
903
  // SDK errors already carry a "credda:" prefix — don't double it.
210
904
  ctx.err(message.startsWith('credda:') ? message : `credda: ${message}`);
905
+ for (const line of errorHints(e))
906
+ ctx.err(line);
211
907
  return 1;
212
908
  }
213
909
  }