@ishlabs/cli 0.8.1 → 0.8.3

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.
Files changed (70) hide show
  1. package/README.md +323 -21
  2. package/dist/auth.d.ts +17 -1
  3. package/dist/auth.js +62 -9
  4. package/dist/commands/ask.d.ts +5 -0
  5. package/dist/commands/ask.js +722 -0
  6. package/dist/commands/config.js +25 -1
  7. package/dist/commands/docs.d.ts +17 -0
  8. package/dist/commands/docs.js +147 -0
  9. package/dist/commands/init.d.ts +16 -0
  10. package/dist/commands/init.js +182 -0
  11. package/dist/commands/iteration.d.ts +5 -1
  12. package/dist/commands/iteration.js +243 -31
  13. package/dist/commands/profile.d.ts +5 -0
  14. package/dist/commands/profile.js +313 -0
  15. package/dist/commands/source.d.ts +10 -0
  16. package/dist/commands/source.js +78 -0
  17. package/dist/commands/study-run.d.ts +11 -0
  18. package/dist/commands/study-run.js +552 -0
  19. package/dist/commands/study-tester.d.ts +8 -0
  20. package/dist/commands/study-tester.js +149 -0
  21. package/dist/commands/study.js +145 -70
  22. package/dist/commands/workspace.js +193 -7
  23. package/dist/config.d.ts +3 -1
  24. package/dist/config.js +10 -10
  25. package/dist/connect.d.ts +4 -1
  26. package/dist/connect.js +127 -94
  27. package/dist/index.js +82 -34
  28. package/dist/lib/alias-store.d.ts +3 -0
  29. package/dist/lib/alias-store.js +9 -7
  30. package/dist/lib/api-client.d.ts +9 -6
  31. package/dist/lib/api-client.js +87 -26
  32. package/dist/lib/ask-questions.d.ts +9 -0
  33. package/dist/lib/ask-questions.js +35 -0
  34. package/dist/lib/ask-variants.d.ts +48 -0
  35. package/dist/lib/ask-variants.js +236 -0
  36. package/dist/lib/auth.d.ts +1 -1
  37. package/dist/lib/auth.js +24 -8
  38. package/dist/lib/colors.d.ts +30 -0
  39. package/dist/lib/colors.js +48 -0
  40. package/dist/lib/command-helpers.d.ts +74 -0
  41. package/dist/lib/command-helpers.js +232 -6
  42. package/dist/lib/docs.d.ts +32 -0
  43. package/dist/lib/docs.js +930 -0
  44. package/dist/lib/local-sim/browser.d.ts +0 -1
  45. package/dist/lib/local-sim/browser.js +0 -2
  46. package/dist/lib/local-sim/install.d.ts +2 -12
  47. package/dist/lib/local-sim/install.js +22 -30
  48. package/dist/lib/output.d.ts +25 -3
  49. package/dist/lib/output.js +465 -20
  50. package/dist/lib/paths.d.ts +14 -0
  51. package/dist/lib/paths.js +36 -0
  52. package/dist/lib/profile-sources.d.ts +55 -0
  53. package/dist/lib/profile-sources.js +157 -0
  54. package/dist/lib/site-access.d.ts +80 -0
  55. package/dist/lib/site-access.js +188 -0
  56. package/dist/lib/skill-content.d.ts +31 -0
  57. package/dist/lib/skill-content.js +462 -0
  58. package/dist/lib/study-inputs.d.ts +20 -0
  59. package/dist/lib/study-inputs.js +72 -0
  60. package/dist/lib/types.d.ts +207 -9
  61. package/dist/lib/types.js +7 -0
  62. package/dist/lib/upload.js +2 -2
  63. package/dist/upgrade.js +11 -1
  64. package/package.json +3 -2
  65. package/dist/commands/simulation.d.ts +0 -10
  66. package/dist/commands/simulation.js +0 -647
  67. package/dist/commands/tester-profile.d.ts +0 -5
  68. package/dist/commands/tester-profile.js +0 -109
  69. package/dist/commands/tester.d.ts +0 -5
  70. package/dist/commands/tester.js +0 -73
@@ -16,15 +16,20 @@ export function setVerbose(v) { _verbose = v; }
16
16
  export function setFields(fields) { _fields = fields; }
17
17
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
18
18
  const TIMESTAMP_KEYS = new Set(["created_at", "updated_at"]);
19
+ const PAGINATION_KEYS = new Set(["items", "total", "limit", "offset"]);
19
20
  /**
20
21
  * Strip UUID-valued fields, null/undefined values, and timestamps.
21
22
  * Preserves alias, name, label, status, and other meaningful fields.
23
+ *
24
+ * When `keepIds` is true, `id` and `*_id` keys (and their UUID values) are
25
+ * preserved — used for write-path responses so agents always get a canonical
26
+ * handle without having to set --verbose.
22
27
  */
23
- function leanJson(data) {
28
+ function leanJson(data, keepIds = false) {
24
29
  if (data === null || data === undefined)
25
30
  return undefined;
26
31
  if (Array.isArray(data)) {
27
- return data.map(leanJson).filter((v) => v !== undefined);
32
+ return data.map((v) => leanJson(v, keepIds)).filter((v) => v !== undefined);
28
33
  }
29
34
  if (typeof data !== "object")
30
35
  return data;
@@ -36,6 +41,12 @@ function leanJson(data) {
36
41
  result[key] = value;
37
42
  continue;
38
43
  }
44
+ // Write-path: preserve canonical identifier keys
45
+ if (keepIds && (key === "id" || key.endsWith("_id"))) {
46
+ if (value !== null && value !== undefined)
47
+ result[key] = value;
48
+ continue;
49
+ }
39
50
  // Strip null/undefined
40
51
  if (value === null || value === undefined)
41
52
  continue;
@@ -47,7 +58,7 @@ function leanJson(data) {
47
58
  continue;
48
59
  // Recurse into objects/arrays
49
60
  if (typeof value === "object") {
50
- const cleaned = leanJson(value);
61
+ const cleaned = leanJson(value, keepIds);
51
62
  if (cleaned !== undefined && !(Array.isArray(cleaned) && cleaned.length === 0)) {
52
63
  result[key] = cleaned;
53
64
  }
@@ -58,7 +69,30 @@ function leanJson(data) {
58
69
  return Object.keys(result).length > 0 ? result : undefined;
59
70
  }
60
71
  /**
61
- * Pick only specified fields from an object. Applied after lean transform.
72
+ * Detect a paginated list wrapper: `{items: [...], total?, limit?, offset?}`.
73
+ * Used so `--fields` filters per-item shape without dropping pagination metadata.
74
+ */
75
+ function isListWrapper(data) {
76
+ if (data === null || typeof data !== "object" || Array.isArray(data))
77
+ return false;
78
+ const obj = data;
79
+ if (!Array.isArray(obj.items))
80
+ return false;
81
+ // Conservative: only treat as a wrapper if every top-level key is recognized
82
+ // pagination metadata. Otherwise an entity that happens to expose a nested
83
+ // `items` field would be unwrapped by mistake.
84
+ for (const key of Object.keys(obj)) {
85
+ if (!PAGINATION_KEYS.has(key))
86
+ return false;
87
+ }
88
+ return true;
89
+ }
90
+ /**
91
+ * Pick only specified fields from each item.
92
+ *
93
+ * Rule: `--fields` projects per-item shape; the wrapper (pagination metadata
94
+ * for paginated lists, top-level object for `get`-style responses) stays
95
+ * stable so downstream JSON parsers don't have to branch on flag presence.
62
96
  */
63
97
  function pickFields(data, fields) {
64
98
  if (Array.isArray(data)) {
@@ -66,9 +100,9 @@ function pickFields(data, fields) {
66
100
  }
67
101
  if (typeof data === "object" && data !== null) {
68
102
  const obj = data;
69
- // Unwrap { items: [...] } wrapper before picking fields
70
- if (Array.isArray(obj.items)) {
71
- return pickFields(obj.items, fields);
103
+ // Preserve list wrapper; project items only.
104
+ if (isListWrapper(obj)) {
105
+ return { ...obj, items: pickFields(obj.items, fields) };
72
106
  }
73
107
  const result = {};
74
108
  for (const field of fields) {
@@ -80,8 +114,16 @@ function pickFields(data, fields) {
80
114
  return data;
81
115
  }
82
116
  /** Serialize data as JSON, applying lean transform and field selection. */
83
- function jsonOutput(data) {
84
- let out = _verbose ? data : leanJson(data);
117
+ function jsonOutput(data, options = {}) {
118
+ let out;
119
+ if (_verbose || options.preProjected) {
120
+ // Verbose: full payload. preProjected: caller already chose the fields,
121
+ // so don't strip again (otherwise leanJson would drop e.g. created_at).
122
+ out = data;
123
+ }
124
+ else {
125
+ out = leanJson(data, options.writePath);
126
+ }
85
127
  if (_fields && _fields.length > 0) {
86
128
  out = pickFields(out, _fields);
87
129
  }
@@ -100,9 +142,9 @@ function injectAliases(items, prefix, idField = "id") {
100
142
  }
101
143
  }
102
144
  // --- JSON mode ---
103
- export function output(data, json) {
145
+ export function output(data, json, options = {}) {
104
146
  if (json) {
105
- console.log(jsonOutput(data));
147
+ console.log(jsonOutput(data, options));
106
148
  return;
107
149
  }
108
150
  if (data === null || data === undefined)
@@ -203,13 +245,31 @@ function suggestionsForError(err) {
203
245
  export function outputError(err, json) {
204
246
  const suggestions = suggestionsForError(err);
205
247
  if (err instanceof ApiError) {
248
+ // Surface backend-structured fields when present in the response body
249
+ // (e.g. 422 errors return `errors: [{loc, msg, type, input, allowed_values}]`,
250
+ // and some endpoints attach `suggestions: [...]`). Merging these gives
251
+ // JSON consumers everything the server told us, without losing the
252
+ // client-side suggestion mapping.
253
+ let bodyErrors;
254
+ let bodySuggestions;
255
+ if (err.body && typeof err.body === "object") {
256
+ const body = err.body;
257
+ if (Array.isArray(body.errors))
258
+ bodyErrors = body.errors;
259
+ if (Array.isArray(body.suggestions))
260
+ bodySuggestions = body.suggestions;
261
+ }
262
+ const mergedSuggestions = bodySuggestions
263
+ ? Array.from(new Set([...bodySuggestions.map(String), ...suggestions]))
264
+ : suggestions;
206
265
  if (json) {
207
266
  console.error(JSON.stringify({
208
267
  error: err.message,
209
268
  error_code: err.error_code,
210
269
  status: err.status,
211
270
  retryable: err.retryable,
212
- ...(suggestions.length > 0 && { suggestions }),
271
+ ...(bodyErrors !== undefined && { errors: bodyErrors }),
272
+ ...(mergedSuggestions.length > 0 && { suggestions: mergedSuggestions }),
213
273
  }));
214
274
  }
215
275
  else {
@@ -219,7 +279,20 @@ export function outputError(err, json) {
219
279
  else {
220
280
  console.error(`Error: ${err.message}`);
221
281
  }
222
- for (const s of suggestions)
282
+ if (Array.isArray(bodyErrors)) {
283
+ for (const entry of bodyErrors) {
284
+ if (entry && typeof entry === "object") {
285
+ const e = entry;
286
+ const loc = Array.isArray(e.loc) ? e.loc.join(".") : "";
287
+ const msg = e.msg ? String(e.msg) : "";
288
+ const allowed = Array.isArray(e.allowed_values)
289
+ ? ` (allowed: ${e.allowed_values.join(", ")})`
290
+ : "";
291
+ console.error(` • ${loc}${loc ? ": " : ""}${msg}${allowed}`);
292
+ }
293
+ }
294
+ }
295
+ for (const s of mergedSuggestions)
223
296
  console.error(` → ${s}`);
224
297
  }
225
298
  }
@@ -290,6 +363,22 @@ function formatLabel(key) {
290
363
  .replace(/\b\w/g, (c) => c.toUpperCase());
291
364
  }
292
365
  // --- Workspace formatting ---
366
+ /**
367
+ * Default workspace fields exposed in both TTY tables and JSON output.
368
+ * Anything not in this set (e.g. has_figma_token) is hidden unless --verbose.
369
+ */
370
+ const WORKSPACE_DEFAULT_FIELDS = ["alias", "name", "base_url", "created_at"];
371
+ function projectWorkspace(workspace, options = {}) {
372
+ const result = {};
373
+ if (options.writePath && workspace.id !== null && workspace.id !== undefined) {
374
+ result.id = workspace.id;
375
+ }
376
+ for (const field of WORKSPACE_DEFAULT_FIELDS) {
377
+ if (field in workspace)
378
+ result[field] = workspace[field];
379
+ }
380
+ return result;
381
+ }
293
382
  export function formatWorkspaceList(workspaces, json) {
294
383
  if (workspaces.length === 0) {
295
384
  if (json)
@@ -300,19 +389,32 @@ export function formatWorkspaceList(workspaces, json) {
300
389
  }
301
390
  injectAliases(workspaces, ALIAS_PREFIX.workspace);
302
391
  if (json) {
303
- console.log(jsonOutput(workspaces));
392
+ if (_verbose) {
393
+ console.log(jsonOutput(workspaces));
394
+ }
395
+ else {
396
+ const projected = workspaces.map((w) => projectWorkspace(w));
397
+ console.log(jsonOutput(projected, { preProjected: true }));
398
+ }
304
399
  return;
305
400
  }
306
401
  const aliasMap = getAliasMap(ALIAS_PREFIX.workspace);
307
- printTable(["#", "NAME", "CREATED"], workspaces.map((w) => [
402
+ printTable(["#", "NAME", "BASE URL", "CREATED"], workspaces.map((w) => [
308
403
  aliasMap.get(String(w.id)) || String(w.id || ""),
309
404
  String(w.name || ""),
405
+ String(w.base_url || "-"),
310
406
  formatDate(w.created_at),
311
407
  ]));
312
408
  }
313
- export function formatWorkspaceDetail(workspace, json) {
409
+ export function formatWorkspaceDetail(workspace, json, options = {}) {
314
410
  if (json) {
315
- console.log(jsonOutput(workspace));
411
+ if (_verbose) {
412
+ console.log(jsonOutput(workspace, options));
413
+ }
414
+ else {
415
+ const projected = projectWorkspace(workspace, { writePath: options.writePath });
416
+ console.log(jsonOutput(projected, { ...options, preProjected: true }));
417
+ }
316
418
  return;
317
419
  }
318
420
  const display = {
@@ -324,6 +426,19 @@ export function formatWorkspaceDetail(workspace, json) {
324
426
  };
325
427
  printKeyValue(display);
326
428
  }
429
+ // --- Site-access formatting ---
430
+ export function formatSiteAccessStatus(summary, json) {
431
+ if (json) {
432
+ console.log(jsonOutput(summary));
433
+ return;
434
+ }
435
+ printTable(["METHOD", "STATUS", "ORIGIN"], [
436
+ ["Basic auth", summary.basic_auth.configured ? "configured" : "-", summary.basic_auth.origin || "-"],
437
+ ["Session cookie", summary.session_cookie.configured ? "configured" : "-", summary.session_cookie.origin || "-"],
438
+ ["Login form", summary.login.configured ? "configured" : "-", "-"],
439
+ ["Public", summary.public_affirmed.affirmed ? "affirmed" : "-", summary.public_affirmed.origin || "-"],
440
+ ]);
441
+ }
327
442
  // --- Study formatting ---
328
443
  export function formatStudyList(studies, json) {
329
444
  if (studies.length === 0) {
@@ -348,9 +463,9 @@ export function formatStudyList(studies, json) {
348
463
  String(s.tester_count ?? "0"),
349
464
  ]));
350
465
  }
351
- export function formatStudyDetail(study, json) {
466
+ export function formatStudyDetail(study, json, options = {}) {
352
467
  if (json) {
353
- console.log(jsonOutput(study));
468
+ console.log(jsonOutput(study, options));
354
469
  return;
355
470
  }
356
471
  // Header
@@ -397,9 +512,73 @@ export function formatStudyDetail(study, json) {
397
512
  ]));
398
513
  }
399
514
  }
515
+ /**
516
+ * Stable JSON envelope for `study results`. Schema is fixed regardless of
517
+ * study state — fields default to `null`, `0`, or `[]` when nothing has run.
518
+ * Agents can rely on the keys always being present (M4).
519
+ */
520
+ function buildStudyResultsEnvelope(study) {
521
+ const allTesters = collectTesters(study);
522
+ const studyAlias = study.id
523
+ ? deterministicAlias(ALIAS_PREFIX.study, String(study.id))
524
+ : null;
525
+ const completedCount = allTesters.filter((t) => t.status === "completed" || t.status === "complete").length;
526
+ // Aggregate sentiment across all interactions on all testers.
527
+ const sentimentCounts = {};
528
+ let sentimentTotal = 0;
529
+ for (const t of allTesters) {
530
+ for (const [label, count] of Object.entries(t.sentimentCounts)) {
531
+ sentimentCounts[label] = (sentimentCounts[label] || 0) + count;
532
+ sentimentTotal += count;
533
+ }
534
+ }
535
+ const sentiment = sentimentTotal > 0
536
+ ? {
537
+ counts: sentimentCounts,
538
+ total: sentimentTotal,
539
+ }
540
+ : null;
541
+ // Group interview answers by question for easy parsing.
542
+ const questions = Array.isArray(study.interview_questions) ? study.interview_questions : [];
543
+ const interviewAnswers = questions.map((q) => {
544
+ const qObj = q;
545
+ const answers = [];
546
+ for (const t of allTesters) {
547
+ const a = t.interviewAnswers.find((x) => x.questionId === qObj.id);
548
+ if (a) {
549
+ answers.push({
550
+ tester_alias: t.id ? deterministicAlias(ALIAS_PREFIX.tester, t.id) : null,
551
+ tester_name: t.name,
552
+ iteration: t.iterationLabel,
553
+ answer: a.answer,
554
+ });
555
+ }
556
+ }
557
+ return {
558
+ question: qObj.question || "",
559
+ type: qObj.type || "text",
560
+ answers,
561
+ };
562
+ });
563
+ return {
564
+ study: {
565
+ alias: studyAlias,
566
+ name: study.name || null,
567
+ status: study.status || null,
568
+ modality: study.modality || null,
569
+ },
570
+ tester_count: allTesters.length,
571
+ completed_count: completedCount,
572
+ sentiment,
573
+ interview_answers: interviewAnswers,
574
+ };
575
+ }
400
576
  export function formatStudyResults(study, json) {
401
577
  if (json) {
402
- console.log(jsonOutput(study));
578
+ // preProjected: bypass leanJson so the stable envelope keeps documented
579
+ // empty defaults (sentiment: null, interview_answers[].answers: []) rather
580
+ // than having them stripped by the lean transform.
581
+ console.log(jsonOutput(buildStudyResultsEnvelope(study), { preProjected: true }));
403
582
  return;
404
583
  }
405
584
  const allTesters = collectTesters(study);
@@ -595,6 +774,54 @@ export function formatTesterProfileList(profiles, json, limit) {
595
774
  console.log(`\n Showing ${list.length} of ${fullList.length} profiles. Use --limit and --offset for more.`);
596
775
  }
597
776
  }
777
+ // --- Audience source formatting ---
778
+ export function formatAudienceSource(source, json) {
779
+ if (source.id) {
780
+ source.alias = deterministicAlias(ALIAS_PREFIX.testerProfileSource, String(source.id));
781
+ }
782
+ if (json) {
783
+ console.log(jsonOutput(source));
784
+ return;
785
+ }
786
+ const display = {
787
+ Alias: source.alias || source.id || "-",
788
+ File: source.original_filename || "-",
789
+ Kind: source.kind || "-",
790
+ Status: source.status || "-",
791
+ "Content-Type": source.content_type || "-",
792
+ };
793
+ if (source.extracted_text_length != null) {
794
+ display["Text length"] = source.extracted_text_length;
795
+ }
796
+ if (source.error) {
797
+ display.Error = source.error;
798
+ }
799
+ printKeyValue(display);
800
+ }
801
+ // --- Generated profile list (returned by /tester-profiles/generate) ---
802
+ export function formatGeneratedProfileList(profiles, json) {
803
+ const list = Array.isArray(profiles) ? profiles : [];
804
+ if (list.length === 0) {
805
+ if (json)
806
+ console.log("[]");
807
+ else
808
+ console.log("No profiles generated.");
809
+ return;
810
+ }
811
+ injectAliases(list, ALIAS_PREFIX.testerProfile);
812
+ if (json) {
813
+ console.log(jsonOutput(list));
814
+ return;
815
+ }
816
+ printTable(["#", "NAME", "OCCUPATION", "COUNTRY", "GENDER", "AGE"], list.map((p) => [
817
+ String(p.alias || p.id || ""),
818
+ String(p.name || ""),
819
+ String(p.occupation || "-"),
820
+ String(p.country || "-"),
821
+ String(p.gender || "-"),
822
+ formatAge(p.date_of_birth),
823
+ ]));
824
+ }
598
825
  // --- Simulation poll formatting ---
599
826
  export function formatSimulationPoll(results, json, isMedia = false) {
600
827
  if (results.length === 0) {
@@ -621,6 +848,224 @@ export function formatSimulationPoll(results, json, isMedia = false) {
621
848
  ];
622
849
  }));
623
850
  }
851
+ // --- Ask formatting ---
852
+ function variantLetter(index) {
853
+ if (index < 26)
854
+ return String.fromCharCode(65 + index);
855
+ return `V${index + 1}`;
856
+ }
857
+ export function formatAskList(asks, json) {
858
+ if (asks.length === 0) {
859
+ if (json)
860
+ console.log("[]");
861
+ else
862
+ console.log("No asks.");
863
+ return;
864
+ }
865
+ injectAliases(asks, ALIAS_PREFIX.ask);
866
+ if (json) {
867
+ console.log(jsonOutput(asks));
868
+ return;
869
+ }
870
+ const aliasMap = getAliasMap(ALIAS_PREFIX.ask);
871
+ printTable(["#", "NAME", "AUDIENCE", "ROUNDS", "LAST ROUND", "ARCHIVED"], asks.map((a) => [
872
+ aliasMap.get(String(a.id)) || String(a.id || ""),
873
+ String(a.name || ""),
874
+ String(a.audience_count ?? "0"),
875
+ String(a.round_count ?? "0"),
876
+ formatDate(a.last_round_at),
877
+ a.is_archived ? "yes" : "no",
878
+ ]));
879
+ }
880
+ export function formatAskDetail(ask, json) {
881
+ if (json) {
882
+ console.log(jsonOutput(ask));
883
+ return;
884
+ }
885
+ console.log(`${ask.name || "Untitled"} (${ask.id || ""})`);
886
+ if (ask.description)
887
+ console.log(String(ask.description));
888
+ const meta = [];
889
+ if (ask.is_archived)
890
+ meta.push("archived");
891
+ meta.push(formatDate(ask.created_at));
892
+ console.log(meta.join(" · "));
893
+ const testers = Array.isArray(ask.testers) ? ask.testers : [];
894
+ console.log(`\nAudience (${testers.length}):`);
895
+ if (testers.length > 0) {
896
+ const rows = testers.slice(0, 20).map((t) => {
897
+ const obj = t;
898
+ const profile = obj.tester_profile;
899
+ const name = String(profile?.name || obj.instance_name || "Unknown");
900
+ return [
901
+ obj.id ? deterministicAlias(ALIAS_PREFIX.tester, String(obj.id)) : "-",
902
+ name,
903
+ String(obj.status || "-"),
904
+ ];
905
+ });
906
+ printTable(["#", "NAME", "STATUS"], rows);
907
+ if (testers.length > 20)
908
+ console.log(` … and ${testers.length - 20} more`);
909
+ }
910
+ const rounds = Array.isArray(ask.rounds) ? ask.rounds : [];
911
+ if (rounds.length > 0) {
912
+ console.log(`\nRounds (${rounds.length}):`);
913
+ for (const r of rounds) {
914
+ const round = r;
915
+ const variants = Array.isArray(round.variants) ? round.variants : [];
916
+ const responses = Array.isArray(round.responses) ? round.responses : [];
917
+ const completed = responses.filter((x) => x.status === "completed").length;
918
+ const idx = typeof round.order_index === "number" ? round.order_index : 0;
919
+ console.log(` Round ${idx + 1} [${round.status || "-"}] · ${variants.length} variant(s) · ${completed}/${responses.length} responded`);
920
+ console.log(` "${truncate(String(round.prompt || ""), 100)}"`);
921
+ }
922
+ }
923
+ }
924
+ export function formatRoundDetail(round, json) {
925
+ if (json) {
926
+ console.log(jsonOutput(round));
927
+ return;
928
+ }
929
+ const variants = Array.isArray(round.variants) ? round.variants : [];
930
+ const responses = Array.isArray(round.responses) ? round.responses : [];
931
+ const idx = typeof round.order_index === "number" ? round.order_index : 0;
932
+ console.log(`Round ${idx + 1} [${round.status || "-"}]`);
933
+ console.log(`Prompt: ${round.prompt}`);
934
+ if (variants.length > 0) {
935
+ console.log("\nVariants:");
936
+ variants.forEach((v, i) => {
937
+ const variant = v;
938
+ const letter = variantLetter(i);
939
+ const label = variant.label ? ` "${variant.label}"` : "";
940
+ const preview = variant.kind === "text"
941
+ ? truncate(String(variant.content || ""), 80)
942
+ : `<${variant.kind}>`;
943
+ console.log(` [${letter}]${label} (${variant.kind}): ${preview}`);
944
+ });
945
+ }
946
+ if (responses.length > 0) {
947
+ const completed = responses.filter((r) => r.status === "completed");
948
+ console.log(`\nResponses: ${completed.length}/${responses.length} completed`);
949
+ }
950
+ const summary = round.summary;
951
+ if (summary?.comment) {
952
+ console.log("\nSummary:");
953
+ console.log(` ${summary.comment}`);
954
+ }
955
+ }
956
+ function computeVariantStats(round) {
957
+ const variants = Array.isArray(round.variants) ? round.variants : [];
958
+ const responses = Array.isArray(round.responses) ? round.responses : [];
959
+ const stats = variants.map((v, i) => {
960
+ const variant = v;
961
+ return {
962
+ letter: variantLetter(i),
963
+ label: variant.label ? String(variant.label) : undefined,
964
+ kind: String(variant.kind || "-"),
965
+ pickCount: 0,
966
+ ratingTotal: 0,
967
+ ratingCount: 0,
968
+ preview: variant.kind === "text"
969
+ ? truncate(String(variant.content || ""), 60)
970
+ : `<${variant.kind}>`,
971
+ };
972
+ });
973
+ const idToIndex = new Map();
974
+ variants.forEach((v, i) => {
975
+ const id = v.id;
976
+ if (id)
977
+ idToIndex.set(String(id), i);
978
+ });
979
+ for (const r of responses) {
980
+ const resp = r;
981
+ if (resp.status !== "completed")
982
+ continue;
983
+ const pick = resp.variant_pick_id;
984
+ if (typeof pick === "string") {
985
+ const idx = idToIndex.get(pick);
986
+ if (idx !== undefined)
987
+ stats[idx].pickCount++;
988
+ }
989
+ const ratings = resp.variant_ratings;
990
+ if (ratings && typeof ratings === "object") {
991
+ for (const [vid, val] of Object.entries(ratings)) {
992
+ const idx = idToIndex.get(vid);
993
+ if (idx !== undefined && typeof val === "number") {
994
+ stats[idx].ratingTotal += val;
995
+ stats[idx].ratingCount++;
996
+ }
997
+ }
998
+ }
999
+ }
1000
+ return stats;
1001
+ }
1002
+ export function formatAskResults(ask, json, roundFilter) {
1003
+ const rounds = (Array.isArray(ask.rounds) ? ask.rounds : []);
1004
+ const filtered = roundFilter !== undefined
1005
+ ? rounds.filter((r) => (typeof r.order_index === "number" ? r.order_index : 0) === roundFilter - 1)
1006
+ : rounds;
1007
+ if (json) {
1008
+ const payload = roundFilter !== undefined ? { ...ask, rounds: filtered } : ask;
1009
+ console.log(jsonOutput(payload));
1010
+ return;
1011
+ }
1012
+ console.log(`${ask.name || "Untitled"} — Results`);
1013
+ if (filtered.length === 0) {
1014
+ console.log(roundFilter !== undefined ? `No round ${roundFilter}.` : "No rounds yet.");
1015
+ return;
1016
+ }
1017
+ for (const round of filtered) {
1018
+ const idx = typeof round.order_index === "number" ? round.order_index : 0;
1019
+ const responses = Array.isArray(round.responses) ? round.responses : [];
1020
+ const completed = responses.filter((r) => r.status === "completed");
1021
+ console.log(`\nRound ${idx + 1} [${round.status || "-"}] · ${completed.length}/${responses.length} responded`);
1022
+ console.log(` Prompt: "${truncate(String(round.prompt || ""), 100)}"`);
1023
+ const stats = computeVariantStats(round);
1024
+ if (stats.length > 0 && (round.wants_pick || round.wants_ratings)) {
1025
+ const hasPick = !!round.wants_pick;
1026
+ const hasRatings = !!round.wants_ratings;
1027
+ const headers = ["#", "LABEL", "KIND"];
1028
+ if (hasPick)
1029
+ headers.push("PICKS");
1030
+ if (hasRatings)
1031
+ headers.push("MEAN RATING");
1032
+ headers.push("PREVIEW");
1033
+ const rows = stats.map((s) => {
1034
+ const row = [s.letter, s.label || "-", s.kind];
1035
+ if (hasPick)
1036
+ row.push(String(s.pickCount));
1037
+ if (hasRatings) {
1038
+ row.push(s.ratingCount > 0 ? (s.ratingTotal / s.ratingCount).toFixed(2) : "-");
1039
+ }
1040
+ row.push(s.preview);
1041
+ return row;
1042
+ });
1043
+ console.log("");
1044
+ printTable(headers, rows);
1045
+ }
1046
+ else if (stats.length > 0) {
1047
+ console.log("");
1048
+ printTable(["#", "LABEL", "KIND", "PREVIEW"], stats.map((s) => [s.letter, s.label || "-", s.kind, s.preview]));
1049
+ }
1050
+ if (completed.length > 0) {
1051
+ console.log("\n Comments:");
1052
+ for (const r of completed.slice(0, 10)) {
1053
+ const resp = r;
1054
+ const comment = resp.comment ? truncate(String(resp.comment), 120) : "-";
1055
+ const tester = resp.tester_id ? deterministicAlias(ALIAS_PREFIX.tester, String(resp.tester_id)) : "-";
1056
+ console.log(` ${tester}: ${comment}`);
1057
+ }
1058
+ if (completed.length > 10) {
1059
+ console.log(` … and ${completed.length - 10} more`);
1060
+ }
1061
+ }
1062
+ const summary = round.summary;
1063
+ if (summary?.comment) {
1064
+ console.log("\n Summary:");
1065
+ console.log(` ${summary.comment}`);
1066
+ }
1067
+ }
1068
+ }
624
1069
  // --- Config formatting ---
625
1070
  export function formatConfigList(configs, json) {
626
1071
  if (configs.length === 0) {
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Filesystem paths used by the CLI.
3
+ *
4
+ * Honors the `ISH_HOME` environment variable so users on systems with an
5
+ * XDG layout (or anywhere else) can override the default `~/.ish` root.
6
+ * Mirrors the pattern used by `gh` (GH_CONFIG_DIR) and `aws` (AWS_CONFIG_FILE).
7
+ */
8
+ export declare function ishDir(): string;
9
+ export declare function configPath(): string;
10
+ export declare function aliasesPath(): string;
11
+ export declare function binDir(): string;
12
+ export declare function browsersDir(): string;
13
+ export declare function simulationsDir(): string;
14
+ export declare function cloudflaredBin(): string;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Filesystem paths used by the CLI.
3
+ *
4
+ * Honors the `ISH_HOME` environment variable so users on systems with an
5
+ * XDG layout (or anywhere else) can override the default `~/.ish` root.
6
+ * Mirrors the pattern used by `gh` (GH_CONFIG_DIR) and `aws` (AWS_CONFIG_FILE).
7
+ */
8
+ import * as path from "node:path";
9
+ import * as os from "node:os";
10
+ function rootDir() {
11
+ if (process.env.ISH_HOME)
12
+ return process.env.ISH_HOME;
13
+ return path.join(os.homedir(), ".ish");
14
+ }
15
+ export function ishDir() {
16
+ return rootDir();
17
+ }
18
+ export function configPath() {
19
+ return path.join(rootDir(), "config.json");
20
+ }
21
+ export function aliasesPath() {
22
+ return path.join(rootDir(), "aliases.json");
23
+ }
24
+ export function binDir() {
25
+ return path.join(rootDir(), "bin");
26
+ }
27
+ export function browsersDir() {
28
+ return path.join(rootDir(), "browsers");
29
+ }
30
+ export function simulationsDir() {
31
+ return path.join(rootDir(), "simulations");
32
+ }
33
+ export function cloudflaredBin() {
34
+ const exe = process.platform === "win32" ? "cloudflared.exe" : "cloudflared";
35
+ return path.join(binDir(), exe);
36
+ }