@ishlabs/cli 0.8.1
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/LICENSE +6 -0
- package/README.md +69 -0
- package/dist/auth.d.ts +17 -0
- package/dist/auth.js +102 -0
- package/dist/commands/config.d.ts +5 -0
- package/dist/commands/config.js +82 -0
- package/dist/commands/iteration.d.ts +5 -0
- package/dist/commands/iteration.js +134 -0
- package/dist/commands/simulation.d.ts +10 -0
- package/dist/commands/simulation.js +647 -0
- package/dist/commands/study.d.ts +5 -0
- package/dist/commands/study.js +283 -0
- package/dist/commands/tester-profile.d.ts +5 -0
- package/dist/commands/tester-profile.js +109 -0
- package/dist/commands/tester.d.ts +5 -0
- package/dist/commands/tester.js +73 -0
- package/dist/commands/workspace.d.ts +5 -0
- package/dist/commands/workspace.js +133 -0
- package/dist/config.d.ts +13 -0
- package/dist/config.js +25 -0
- package/dist/connect.d.ts +4 -0
- package/dist/connect.js +573 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +89 -0
- package/dist/lib/alias-store.d.ts +49 -0
- package/dist/lib/alias-store.js +138 -0
- package/dist/lib/api-client.d.ts +58 -0
- package/dist/lib/api-client.js +177 -0
- package/dist/lib/auth.d.ts +8 -0
- package/dist/lib/auth.js +73 -0
- package/dist/lib/command-helpers.d.ts +28 -0
- package/dist/lib/command-helpers.js +131 -0
- package/dist/lib/local-sim/actions.d.ts +22 -0
- package/dist/lib/local-sim/actions.js +379 -0
- package/dist/lib/local-sim/browser.d.ts +63 -0
- package/dist/lib/local-sim/browser.js +332 -0
- package/dist/lib/local-sim/debug-report.d.ts +21 -0
- package/dist/lib/local-sim/debug-report.js +186 -0
- package/dist/lib/local-sim/debug.d.ts +44 -0
- package/dist/lib/local-sim/debug.js +103 -0
- package/dist/lib/local-sim/install.d.ts +25 -0
- package/dist/lib/local-sim/install.js +72 -0
- package/dist/lib/local-sim/loop.d.ts +60 -0
- package/dist/lib/local-sim/loop.js +526 -0
- package/dist/lib/local-sim/types.d.ts +232 -0
- package/dist/lib/local-sim/types.js +8 -0
- package/dist/lib/local-sim/upload.d.ts +6 -0
- package/dist/lib/local-sim/upload.js +24 -0
- package/dist/lib/output.d.ts +34 -0
- package/dist/lib/output.js +675 -0
- package/dist/lib/types.d.ts +179 -0
- package/dist/lib/types.js +12 -0
- package/dist/lib/upload.d.ts +47 -0
- package/dist/lib/upload.js +178 -0
- package/dist/upgrade.d.ts +1 -0
- package/dist/upgrade.js +94 -0
- package/package.json +43 -0
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output formatting utilities.
|
|
3
|
+
* Supports both human-readable and JSON output modes.
|
|
4
|
+
*
|
|
5
|
+
* JSON output is lean by default (strips UUIDs, nulls, timestamps) to
|
|
6
|
+
* minimize context-window usage for AI agent consumers. Use --verbose
|
|
7
|
+
* for the full API response.
|
|
8
|
+
*/
|
|
9
|
+
import { ApiError } from "./api-client.js";
|
|
10
|
+
import { deterministicAlias, getAliasMap, ALIAS_PREFIX } from "./alias-store.js";
|
|
11
|
+
// --- Lean JSON: strip noise for agent-friendly output ---
|
|
12
|
+
let _verbose = false;
|
|
13
|
+
let _fields;
|
|
14
|
+
/** Set by withClient() based on global flags. */
|
|
15
|
+
export function setVerbose(v) { _verbose = v; }
|
|
16
|
+
export function setFields(fields) { _fields = fields; }
|
|
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
|
+
const TIMESTAMP_KEYS = new Set(["created_at", "updated_at"]);
|
|
19
|
+
/**
|
|
20
|
+
* Strip UUID-valued fields, null/undefined values, and timestamps.
|
|
21
|
+
* Preserves alias, name, label, status, and other meaningful fields.
|
|
22
|
+
*/
|
|
23
|
+
function leanJson(data) {
|
|
24
|
+
if (data === null || data === undefined)
|
|
25
|
+
return undefined;
|
|
26
|
+
if (Array.isArray(data)) {
|
|
27
|
+
return data.map(leanJson).filter((v) => v !== undefined);
|
|
28
|
+
}
|
|
29
|
+
if (typeof data !== "object")
|
|
30
|
+
return data;
|
|
31
|
+
const obj = data;
|
|
32
|
+
const result = {};
|
|
33
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
34
|
+
// Keep alias always
|
|
35
|
+
if (key === "alias") {
|
|
36
|
+
result[key] = value;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
// Strip null/undefined
|
|
40
|
+
if (value === null || value === undefined)
|
|
41
|
+
continue;
|
|
42
|
+
// Strip timestamp fields
|
|
43
|
+
if (TIMESTAMP_KEYS.has(key))
|
|
44
|
+
continue;
|
|
45
|
+
// Strip UUID-valued fields (but keep non-UUID string fields)
|
|
46
|
+
if (typeof value === "string" && UUID_RE.test(value))
|
|
47
|
+
continue;
|
|
48
|
+
// Recurse into objects/arrays
|
|
49
|
+
if (typeof value === "object") {
|
|
50
|
+
const cleaned = leanJson(value);
|
|
51
|
+
if (cleaned !== undefined && !(Array.isArray(cleaned) && cleaned.length === 0)) {
|
|
52
|
+
result[key] = cleaned;
|
|
53
|
+
}
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
result[key] = value;
|
|
57
|
+
}
|
|
58
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Pick only specified fields from an object. Applied after lean transform.
|
|
62
|
+
*/
|
|
63
|
+
function pickFields(data, fields) {
|
|
64
|
+
if (Array.isArray(data)) {
|
|
65
|
+
return data.map((item) => pickFields(item, fields));
|
|
66
|
+
}
|
|
67
|
+
if (typeof data === "object" && data !== null) {
|
|
68
|
+
const obj = data;
|
|
69
|
+
// Unwrap { items: [...] } wrapper before picking fields
|
|
70
|
+
if (Array.isArray(obj.items)) {
|
|
71
|
+
return pickFields(obj.items, fields);
|
|
72
|
+
}
|
|
73
|
+
const result = {};
|
|
74
|
+
for (const field of fields) {
|
|
75
|
+
if (field in obj)
|
|
76
|
+
result[field] = obj[field];
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
return data;
|
|
81
|
+
}
|
|
82
|
+
/** Serialize data as JSON, applying lean transform and field selection. */
|
|
83
|
+
function jsonOutput(data) {
|
|
84
|
+
let out = _verbose ? data : leanJson(data);
|
|
85
|
+
if (_fields && _fields.length > 0) {
|
|
86
|
+
out = pickFields(out, _fields);
|
|
87
|
+
}
|
|
88
|
+
return JSON.stringify(out, null, 2);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Save aliases for a list of items and inject `alias` into each item.
|
|
92
|
+
* Call this before both JSON and human output so aliases are always available.
|
|
93
|
+
*/
|
|
94
|
+
function injectAliases(items, prefix, idField = "id") {
|
|
95
|
+
for (const item of items) {
|
|
96
|
+
const id = String(item[idField] || "");
|
|
97
|
+
if (id) {
|
|
98
|
+
item.alias = deterministicAlias(prefix, id);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// --- JSON mode ---
|
|
103
|
+
export function output(data, json) {
|
|
104
|
+
if (json) {
|
|
105
|
+
console.log(jsonOutput(data));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (data === null || data === undefined)
|
|
109
|
+
return;
|
|
110
|
+
if (Array.isArray(data)) {
|
|
111
|
+
outputList(data, json);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (typeof data === "object") {
|
|
115
|
+
printKeyValue(data);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
console.log(String(data));
|
|
119
|
+
}
|
|
120
|
+
export function outputList(rows, json) {
|
|
121
|
+
if (json) {
|
|
122
|
+
console.log(jsonOutput(rows));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (rows.length === 0) {
|
|
126
|
+
console.log("No results.");
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
// Generic list: show name + key fields
|
|
130
|
+
for (const row of rows) {
|
|
131
|
+
if (typeof row === "object" && row !== null) {
|
|
132
|
+
const obj = row;
|
|
133
|
+
const parts = [];
|
|
134
|
+
if (obj.name)
|
|
135
|
+
parts.push(String(obj.name));
|
|
136
|
+
if (obj.status)
|
|
137
|
+
parts.push(`[${obj.status}]`);
|
|
138
|
+
if (obj.modality)
|
|
139
|
+
parts.push(String(obj.modality));
|
|
140
|
+
console.log(" " + parts.join(" "));
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
console.log(" " + String(row));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Error with valid options — used for content_type and similar validation.
|
|
149
|
+
* Surfaces valid_options in JSON so agents can self-correct.
|
|
150
|
+
*/
|
|
151
|
+
export class ValidationError extends Error {
|
|
152
|
+
valid_options;
|
|
153
|
+
constructor(message, valid_options) {
|
|
154
|
+
super(message);
|
|
155
|
+
this.valid_options = valid_options;
|
|
156
|
+
this.name = "ValidationError";
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Map error codes to actionable suggestions so agents can self-recover.
|
|
161
|
+
*/
|
|
162
|
+
function suggestionsForError(err) {
|
|
163
|
+
if (err instanceof ApiError) {
|
|
164
|
+
switch (err.error_code) {
|
|
165
|
+
case "auth_failed":
|
|
166
|
+
return ["Run `ish login` to authenticate"];
|
|
167
|
+
case "forbidden":
|
|
168
|
+
return ["Check that your account has access to this resource"];
|
|
169
|
+
case "not_found":
|
|
170
|
+
return [
|
|
171
|
+
"Run a list command to see available resources",
|
|
172
|
+
"Check that the alias or ID is correct",
|
|
173
|
+
];
|
|
174
|
+
case "insufficient_credits":
|
|
175
|
+
return ["Purchase more credits at https://app.ishlabs.io"];
|
|
176
|
+
case "validation_error":
|
|
177
|
+
return ["Check the command help: add --help to see required options"];
|
|
178
|
+
case "rate_limited":
|
|
179
|
+
return ["Wait a moment and retry the command"];
|
|
180
|
+
default:
|
|
181
|
+
return err.retryable ? ["Retry the command in a moment"] : [];
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (err instanceof Error) {
|
|
185
|
+
if (/no auth token|run "ish login"|session expired/i.test(err.message)) {
|
|
186
|
+
return ["Run `ish login` to authenticate"];
|
|
187
|
+
}
|
|
188
|
+
if (/no workspace set/i.test(err.message)) {
|
|
189
|
+
return ["Run `ish workspace list` then `ish workspace use <alias>`"];
|
|
190
|
+
}
|
|
191
|
+
if (/no study set/i.test(err.message)) {
|
|
192
|
+
return ["Run `ish study list` then `ish study use <alias>`"];
|
|
193
|
+
}
|
|
194
|
+
if (/invalid id/i.test(err.message)) {
|
|
195
|
+
return ["Run a list command to see available aliases"];
|
|
196
|
+
}
|
|
197
|
+
if (/file not found|cannot read/i.test(err.message)) {
|
|
198
|
+
return ["Check that the file path exists and is readable"];
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
export function outputError(err, json) {
|
|
204
|
+
const suggestions = suggestionsForError(err);
|
|
205
|
+
if (err instanceof ApiError) {
|
|
206
|
+
if (json) {
|
|
207
|
+
console.error(JSON.stringify({
|
|
208
|
+
error: err.message,
|
|
209
|
+
error_code: err.error_code,
|
|
210
|
+
status: err.status,
|
|
211
|
+
retryable: err.retryable,
|
|
212
|
+
...(suggestions.length > 0 && { suggestions }),
|
|
213
|
+
}));
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
if (err.status === 402) {
|
|
217
|
+
console.error("Error: Insufficient credits. Purchase more at https://app.ishlabs.io");
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
console.error(`Error: ${err.message}`);
|
|
221
|
+
}
|
|
222
|
+
for (const s of suggestions)
|
|
223
|
+
console.error(` → ${s}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
else if (err instanceof ValidationError) {
|
|
227
|
+
if (json) {
|
|
228
|
+
console.error(JSON.stringify({
|
|
229
|
+
error: err.message,
|
|
230
|
+
error_code: "validation_error",
|
|
231
|
+
retryable: false,
|
|
232
|
+
valid_options: err.valid_options,
|
|
233
|
+
...(suggestions.length > 0 && { suggestions }),
|
|
234
|
+
}));
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
console.error(`Error: ${err.message}`);
|
|
238
|
+
for (const s of suggestions)
|
|
239
|
+
console.error(` → ${s}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
else if (err instanceof Error) {
|
|
243
|
+
if (json) {
|
|
244
|
+
console.error(JSON.stringify({
|
|
245
|
+
error: err.message,
|
|
246
|
+
error_code: "client_error",
|
|
247
|
+
retryable: false,
|
|
248
|
+
...(suggestions.length > 0 && { suggestions }),
|
|
249
|
+
}));
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
console.error(`Error: ${err.message}`);
|
|
253
|
+
for (const s of suggestions)
|
|
254
|
+
console.error(` → ${s}`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
if (json) {
|
|
259
|
+
console.error(JSON.stringify({ error: String(err), error_code: "unknown_error", retryable: false }));
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
console.error(`Error: ${err}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// --- Entity-specific formatters (human mode) ---
|
|
267
|
+
export function printTable(headers, rows) {
|
|
268
|
+
const colWidths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] || "").length)));
|
|
269
|
+
console.log(" " + headers.map((h, i) => h.padEnd(colWidths[i])).join(" "));
|
|
270
|
+
for (const row of rows) {
|
|
271
|
+
console.log(" " + row.map((cell, i) => (cell || "").padEnd(colWidths[i])).join(" "));
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
export function printKeyValue(obj, indent = " ") {
|
|
275
|
+
const entries = Object.entries(obj).filter(([, v]) => v !== null && v !== undefined);
|
|
276
|
+
if (entries.length === 0)
|
|
277
|
+
return;
|
|
278
|
+
const maxKeyLen = Math.max(...entries.map(([k]) => k.length));
|
|
279
|
+
for (const [key, value] of entries) {
|
|
280
|
+
const label = formatLabel(key);
|
|
281
|
+
const displayValue = typeof value === "object" && value !== null
|
|
282
|
+
? JSON.stringify(value)
|
|
283
|
+
: String(value);
|
|
284
|
+
console.log(`${indent}${label.padEnd(maxKeyLen + 2)}${displayValue}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
function formatLabel(key) {
|
|
288
|
+
return key
|
|
289
|
+
.replace(/_/g, " ")
|
|
290
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
291
|
+
}
|
|
292
|
+
// --- Workspace formatting ---
|
|
293
|
+
export function formatWorkspaceList(workspaces, json) {
|
|
294
|
+
if (workspaces.length === 0) {
|
|
295
|
+
if (json)
|
|
296
|
+
console.log("[]");
|
|
297
|
+
else
|
|
298
|
+
console.log("No workspaces.");
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
injectAliases(workspaces, ALIAS_PREFIX.workspace);
|
|
302
|
+
if (json) {
|
|
303
|
+
console.log(jsonOutput(workspaces));
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
const aliasMap = getAliasMap(ALIAS_PREFIX.workspace);
|
|
307
|
+
printTable(["#", "NAME", "CREATED"], workspaces.map((w) => [
|
|
308
|
+
aliasMap.get(String(w.id)) || String(w.id || ""),
|
|
309
|
+
String(w.name || ""),
|
|
310
|
+
formatDate(w.created_at),
|
|
311
|
+
]));
|
|
312
|
+
}
|
|
313
|
+
export function formatWorkspaceDetail(workspace, json) {
|
|
314
|
+
if (json) {
|
|
315
|
+
console.log(jsonOutput(workspace));
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const display = {
|
|
319
|
+
ID: workspace.id || "-",
|
|
320
|
+
Name: workspace.name,
|
|
321
|
+
Description: workspace.description || "-",
|
|
322
|
+
"Base URL": workspace.base_url || "-",
|
|
323
|
+
Created: formatDate(workspace.created_at),
|
|
324
|
+
};
|
|
325
|
+
printKeyValue(display);
|
|
326
|
+
}
|
|
327
|
+
// --- Study formatting ---
|
|
328
|
+
export function formatStudyList(studies, json) {
|
|
329
|
+
if (studies.length === 0) {
|
|
330
|
+
if (json)
|
|
331
|
+
console.log("[]");
|
|
332
|
+
else
|
|
333
|
+
console.log("No studies.");
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
injectAliases(studies, ALIAS_PREFIX.study);
|
|
337
|
+
if (json) {
|
|
338
|
+
console.log(jsonOutput(studies));
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
const aliasMap = getAliasMap(ALIAS_PREFIX.study);
|
|
342
|
+
printTable(["#", "NAME", "MODALITY", "TYPE", "STATUS", "TESTERS"], studies.map((s) => [
|
|
343
|
+
aliasMap.get(String(s.id)) || String(s.id || ""),
|
|
344
|
+
String(s.name || ""),
|
|
345
|
+
String(s.modality || "-"),
|
|
346
|
+
String(s.content_type || "-"),
|
|
347
|
+
String(s.status || "draft"),
|
|
348
|
+
String(s.tester_count ?? "0"),
|
|
349
|
+
]));
|
|
350
|
+
}
|
|
351
|
+
export function formatStudyDetail(study, json) {
|
|
352
|
+
if (json) {
|
|
353
|
+
console.log(jsonOutput(study));
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
// Header
|
|
357
|
+
console.log(`${study.name || "Untitled"} (${study.id || ""})`);
|
|
358
|
+
if (study.description)
|
|
359
|
+
console.log(String(study.description));
|
|
360
|
+
const modalityParts = [study.modality || "-"];
|
|
361
|
+
if (study.content_type)
|
|
362
|
+
modalityParts.push(String(study.content_type));
|
|
363
|
+
modalityParts.push(String(study.status || "draft"), formatDate(study.created_at));
|
|
364
|
+
console.log(modalityParts.join(" · "));
|
|
365
|
+
// Assignments
|
|
366
|
+
const assignments = Array.isArray(study.assignments) ? study.assignments : [];
|
|
367
|
+
if (assignments.length > 0) {
|
|
368
|
+
console.log("\nAssignments:");
|
|
369
|
+
for (let i = 0; i < assignments.length; i++) {
|
|
370
|
+
const a = assignments[i];
|
|
371
|
+
console.log(` ${i + 1}. ${a.name || "Untitled"}`);
|
|
372
|
+
if (a.instructions) {
|
|
373
|
+
console.log(` ${truncate(String(a.instructions), 80)}`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
// Interview Questions
|
|
378
|
+
const questions = Array.isArray(study.interview_questions) ? study.interview_questions : [];
|
|
379
|
+
if (questions.length > 0) {
|
|
380
|
+
console.log("\nInterview Questions:");
|
|
381
|
+
for (let i = 0; i < questions.length; i++) {
|
|
382
|
+
const q = questions[i];
|
|
383
|
+
const typeStr = formatQuestionType(q);
|
|
384
|
+
console.log(` ${i + 1}. ${q.question || "Untitled"} ${typeStr}`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
// Testers summary
|
|
388
|
+
const allTesters = collectTesters(study);
|
|
389
|
+
if (allTesters.length > 0) {
|
|
390
|
+
console.log(`\nTesters (${allTesters.length}):`);
|
|
391
|
+
printTable(["#", "NAME", "ITERATION", "STATUS", "INTERACTIONS"], allTesters.map((t) => [
|
|
392
|
+
t.id ? deterministicAlias(ALIAS_PREFIX.tester, t.id) : t.id,
|
|
393
|
+
t.name,
|
|
394
|
+
t.iterationLabel,
|
|
395
|
+
t.status,
|
|
396
|
+
String(t.interactionCount),
|
|
397
|
+
]));
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
export function formatStudyResults(study, json) {
|
|
401
|
+
if (json) {
|
|
402
|
+
console.log(jsonOutput(study));
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const allTesters = collectTesters(study);
|
|
406
|
+
const totalInteractions = allTesters.reduce((sum, t) => sum + t.interactionCount, 0);
|
|
407
|
+
// Header
|
|
408
|
+
console.log(`${study.name || "Untitled"} — Results`);
|
|
409
|
+
console.log(`${allTesters.length} tester${allTesters.length !== 1 ? "s" : ""} · ${totalInteractions} total interactions`);
|
|
410
|
+
// Sentiment summary
|
|
411
|
+
const totalSentiment = {};
|
|
412
|
+
for (const t of allTesters) {
|
|
413
|
+
for (const [label, count] of Object.entries(t.sentimentCounts)) {
|
|
414
|
+
totalSentiment[label] = (totalSentiment[label] || 0) + count;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
const sentimentParts = Object.entries(totalSentiment).map(([label, count]) => `${count} ${label.toLowerCase()}`);
|
|
418
|
+
console.log(`\nSentiment: ${sentimentParts.length > 0 ? sentimentParts.join(", ") : "none"}`);
|
|
419
|
+
// Interview Answers grouped by question
|
|
420
|
+
const questions = Array.isArray(study.interview_questions) ? study.interview_questions : [];
|
|
421
|
+
if (questions.length > 0) {
|
|
422
|
+
console.log("\nInterview Answers:");
|
|
423
|
+
for (const q of questions) {
|
|
424
|
+
const qObj = q;
|
|
425
|
+
const typeStr = formatQuestionType(qObj);
|
|
426
|
+
console.log(`\n "${qObj.question}" ${typeStr}`);
|
|
427
|
+
for (const t of allTesters) {
|
|
428
|
+
const answer = t.interviewAnswers.find((a) => a.questionId === qObj.id);
|
|
429
|
+
if (answer) {
|
|
430
|
+
const answerStr = typeof answer.answer === "string"
|
|
431
|
+
? `"${truncate(answer.answer, 70)}"`
|
|
432
|
+
: String(answer.answer);
|
|
433
|
+
console.log(` ${t.name} (${t.iterationLabel}): ${answerStr}`);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
// Testers table
|
|
439
|
+
if (allTesters.length > 0) {
|
|
440
|
+
console.log("\nTesters:");
|
|
441
|
+
printTable(["#", "NAME", "ITERATION", "STATUS", "INTERACTIONS", "SENTIMENT"], allTesters.map((t) => {
|
|
442
|
+
const parts = Object.entries(t.sentimentCounts).map(([label, count]) => `${count} ${label.toLowerCase()}`);
|
|
443
|
+
return [
|
|
444
|
+
t.id ? deterministicAlias(ALIAS_PREFIX.tester, t.id) : t.id,
|
|
445
|
+
t.name,
|
|
446
|
+
t.iterationLabel,
|
|
447
|
+
t.status,
|
|
448
|
+
String(t.interactionCount),
|
|
449
|
+
parts.length > 0 ? parts.join(", ") : "-",
|
|
450
|
+
];
|
|
451
|
+
}));
|
|
452
|
+
console.log("\nRun `ish tester get <id> --json` for full interaction details.");
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
function collectTesters(study) {
|
|
456
|
+
const iterations = Array.isArray(study.iterations) ? study.iterations : [];
|
|
457
|
+
const testers = [];
|
|
458
|
+
for (const iter of iterations) {
|
|
459
|
+
const it = iter;
|
|
460
|
+
const iterLabel = String(it.label || it.name || "-");
|
|
461
|
+
const iterTesters = Array.isArray(it.testers) ? it.testers : [];
|
|
462
|
+
for (const tester of iterTesters) {
|
|
463
|
+
const t = tester;
|
|
464
|
+
const profile = t.tester_profile;
|
|
465
|
+
const interactions = Array.isArray(t.interactions) ? t.interactions : [];
|
|
466
|
+
const sentimentCounts = {};
|
|
467
|
+
for (const interaction of interactions) {
|
|
468
|
+
const ix = interaction;
|
|
469
|
+
const sentiment = ix.sentiment;
|
|
470
|
+
if (sentiment?.label) {
|
|
471
|
+
const label = String(sentiment.label);
|
|
472
|
+
sentimentCounts[label] = (sentimentCounts[label] || 0) + 1;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
const answers = Array.isArray(t.interview_answers) ? t.interview_answers : [];
|
|
476
|
+
testers.push({
|
|
477
|
+
id: String(t.id || ""),
|
|
478
|
+
name: String(profile?.name || t.instance_name || "Unknown"),
|
|
479
|
+
iterationLabel: iterLabel,
|
|
480
|
+
status: String(t.status || "-"),
|
|
481
|
+
interactionCount: interactions.length,
|
|
482
|
+
sentimentCounts,
|
|
483
|
+
interviewAnswers: answers.map((a) => ({
|
|
484
|
+
questionId: String(a.question_id || ""),
|
|
485
|
+
answer: a.answer,
|
|
486
|
+
})),
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return testers;
|
|
491
|
+
}
|
|
492
|
+
function formatQuestionType(q) {
|
|
493
|
+
if (!q.type)
|
|
494
|
+
return "";
|
|
495
|
+
if (q.type === "slider" && q.min !== undefined && q.max !== undefined) {
|
|
496
|
+
return `[${q.type} ${q.min}-${q.max}]`;
|
|
497
|
+
}
|
|
498
|
+
return `[${q.type}]`;
|
|
499
|
+
}
|
|
500
|
+
function truncate(str, maxLen) {
|
|
501
|
+
if (str.length <= maxLen)
|
|
502
|
+
return str;
|
|
503
|
+
return str.slice(0, maxLen - 3) + "...";
|
|
504
|
+
}
|
|
505
|
+
// --- Iteration formatting ---
|
|
506
|
+
export function formatIterationList(iterations, json) {
|
|
507
|
+
if (iterations.length === 0) {
|
|
508
|
+
if (json)
|
|
509
|
+
console.log("[]");
|
|
510
|
+
else
|
|
511
|
+
console.log("No iterations.");
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
injectAliases(iterations, ALIAS_PREFIX.iteration);
|
|
515
|
+
if (json) {
|
|
516
|
+
console.log(jsonOutput(iterations));
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
const aliasMap = getAliasMap(ALIAS_PREFIX.iteration);
|
|
520
|
+
printTable(["#", "LABEL", "NAME", "TESTERS", "CREATED"], iterations.map((it) => {
|
|
521
|
+
const testers = Array.isArray(it.testers) ? it.testers.length : 0;
|
|
522
|
+
return [
|
|
523
|
+
aliasMap.get(String(it.id)) || String(it.id || ""),
|
|
524
|
+
String(it.label || "-"),
|
|
525
|
+
String(it.name || ""),
|
|
526
|
+
String(testers),
|
|
527
|
+
formatDate(it.created_at),
|
|
528
|
+
];
|
|
529
|
+
}));
|
|
530
|
+
}
|
|
531
|
+
// --- Tester formatting ---
|
|
532
|
+
export function formatTesterDetail(tester, json) {
|
|
533
|
+
if (json) {
|
|
534
|
+
console.log(jsonOutput(tester));
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
const profile = tester.tester_profile;
|
|
538
|
+
const profileName = profile?.name ? String(profile.name) : "Unknown";
|
|
539
|
+
const interactions = Array.isArray(tester.interactions) ? tester.interactions : [];
|
|
540
|
+
// Count sentiments
|
|
541
|
+
const sentimentCounts = {};
|
|
542
|
+
for (const interaction of interactions) {
|
|
543
|
+
const it = interaction;
|
|
544
|
+
const sentiment = it.sentiment;
|
|
545
|
+
if (sentiment?.label) {
|
|
546
|
+
const label = String(sentiment.label);
|
|
547
|
+
sentimentCounts[label] = (sentimentCounts[label] || 0) + 1;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
const sentimentParts = Object.entries(sentimentCounts).map(([label, count]) => `${count} ${label.toLowerCase()}`);
|
|
551
|
+
const display = {
|
|
552
|
+
ID: tester.id || "-",
|
|
553
|
+
Profile: profileName,
|
|
554
|
+
Status: tester.status || "-",
|
|
555
|
+
Platform: tester.platform || "-",
|
|
556
|
+
Language: tester.language || "-",
|
|
557
|
+
Interactions: `${interactions.length} interactions`,
|
|
558
|
+
...(sentimentParts.length > 0 && {
|
|
559
|
+
Sentiment: sentimentParts.join(", "),
|
|
560
|
+
}),
|
|
561
|
+
};
|
|
562
|
+
printKeyValue(display);
|
|
563
|
+
}
|
|
564
|
+
// --- Tester Profile formatting ---
|
|
565
|
+
export function formatTesterProfileList(profiles, json, limit) {
|
|
566
|
+
// The API may return { items: [...], total, limit, offset } or a flat array
|
|
567
|
+
const wrapper = profiles;
|
|
568
|
+
const fullList = Array.isArray(profiles) ? profiles
|
|
569
|
+
: Array.isArray(wrapper?.items) ? wrapper.items
|
|
570
|
+
: Array.isArray(wrapper?.profiles) ? wrapper.profiles
|
|
571
|
+
: null;
|
|
572
|
+
if (!Array.isArray(fullList) || fullList.length === 0) {
|
|
573
|
+
if (json)
|
|
574
|
+
console.log(JSON.stringify(profiles, null, 2));
|
|
575
|
+
else
|
|
576
|
+
console.log("No tester profiles.");
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
// Client-side limit (server may not enforce it)
|
|
580
|
+
const list = limit ? fullList.slice(0, limit) : fullList;
|
|
581
|
+
injectAliases(list, ALIAS_PREFIX.testerProfile);
|
|
582
|
+
if (json) {
|
|
583
|
+
console.log(jsonOutput(profiles));
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
printTable(["#", "NAME", "OCCUPATION", "COUNTRY", "GENDER", "AGE"], list.map((p) => [
|
|
587
|
+
String(p.alias || p.id || ""),
|
|
588
|
+
String(p.name || ""),
|
|
589
|
+
String(p.occupation || "-"),
|
|
590
|
+
String(p.country || "-"),
|
|
591
|
+
String(p.gender || "-"),
|
|
592
|
+
formatAge(p.date_of_birth),
|
|
593
|
+
]));
|
|
594
|
+
if (fullList.length > list.length) {
|
|
595
|
+
console.log(`\n Showing ${list.length} of ${fullList.length} profiles. Use --limit and --offset for more.`);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
// --- Simulation poll formatting ---
|
|
599
|
+
export function formatSimulationPoll(results, json, isMedia = false) {
|
|
600
|
+
if (results.length === 0) {
|
|
601
|
+
if (json)
|
|
602
|
+
console.log(jsonOutput([]));
|
|
603
|
+
else
|
|
604
|
+
console.log("No simulations found.");
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
injectAliases(results, ALIAS_PREFIX.tester);
|
|
608
|
+
if (json) {
|
|
609
|
+
console.log(jsonOutput(results));
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
const aliasMap = getAliasMap(ALIAS_PREFIX.tester);
|
|
613
|
+
const countHeader = isMedia ? "SEGMENTS" : "INTERACTIONS";
|
|
614
|
+
printTable(["#", "TESTER", "STATUS", countHeader], results.map((r) => {
|
|
615
|
+
const id = String(r.id || r.tester_id || "");
|
|
616
|
+
return [
|
|
617
|
+
aliasMap.get(id) || id,
|
|
618
|
+
String(r.tester_name || "Unknown"),
|
|
619
|
+
String(r.status || "UNKNOWN"),
|
|
620
|
+
String(r.interaction_count ?? "0"),
|
|
621
|
+
];
|
|
622
|
+
}));
|
|
623
|
+
}
|
|
624
|
+
// --- Config formatting ---
|
|
625
|
+
export function formatConfigList(configs, json) {
|
|
626
|
+
if (configs.length === 0) {
|
|
627
|
+
if (json)
|
|
628
|
+
console.log("[]");
|
|
629
|
+
else
|
|
630
|
+
console.log("No simulation configs.");
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
injectAliases(configs, ALIAS_PREFIX.config);
|
|
634
|
+
if (json) {
|
|
635
|
+
console.log(jsonOutput(configs));
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
const aliasMap = getAliasMap(ALIAS_PREFIX.config);
|
|
639
|
+
printTable(["#", "NAME", "SOURCE", "CREATED"], configs.map((c) => [
|
|
640
|
+
aliasMap.get(String(c.id)) || String(c.id || ""),
|
|
641
|
+
String(c.name || ""),
|
|
642
|
+
String(c.source_type || "manual"),
|
|
643
|
+
formatDate(c.created_at),
|
|
644
|
+
]));
|
|
645
|
+
}
|
|
646
|
+
// --- Helpers ---
|
|
647
|
+
function formatAge(dob) {
|
|
648
|
+
if (!dob)
|
|
649
|
+
return "-";
|
|
650
|
+
try {
|
|
651
|
+
const birth = new Date(String(dob));
|
|
652
|
+
if (isNaN(birth.getTime()))
|
|
653
|
+
return "-";
|
|
654
|
+
const now = new Date();
|
|
655
|
+
let age = now.getFullYear() - birth.getFullYear();
|
|
656
|
+
const monthDiff = now.getMonth() - birth.getMonth();
|
|
657
|
+
if (monthDiff < 0 || (monthDiff === 0 && now.getDate() < birth.getDate()))
|
|
658
|
+
age--;
|
|
659
|
+
return String(age);
|
|
660
|
+
}
|
|
661
|
+
catch {
|
|
662
|
+
return "-";
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
function formatDate(value) {
|
|
666
|
+
if (!value)
|
|
667
|
+
return "-";
|
|
668
|
+
const str = String(value);
|
|
669
|
+
try {
|
|
670
|
+
return new Date(str).toLocaleDateString("en-CA"); // YYYY-MM-DD
|
|
671
|
+
}
|
|
672
|
+
catch {
|
|
673
|
+
return str.slice(0, 10);
|
|
674
|
+
}
|
|
675
|
+
}
|