callkaro-cli-unofficial 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +196 -0
- package/dist/commands/accounts.js +275 -0
- package/dist/commands/agentPhone.js +50 -0
- package/dist/commands/agentVersions.js +228 -0
- package/dist/commands/agents.js +455 -0
- package/dist/commands/analytics.js +77 -0
- package/dist/commands/analyzers.js +130 -0
- package/dist/commands/auditStrategies.js +178 -0
- package/dist/commands/batches.js +189 -0
- package/dist/commands/calls.js +193 -0
- package/dist/commands/chatAgents.js +251 -0
- package/dist/commands/jsonInput.js +56 -0
- package/dist/commands/kpi.js +681 -0
- package/dist/commands/models.js +99 -0
- package/dist/commands/numbers.js +154 -0
- package/dist/commands/ongoing.js +39 -0
- package/dist/commands/secrets.js +124 -0
- package/dist/commands/setJson.js +42 -0
- package/dist/commands/simulations.js +139 -0
- package/dist/commands/skills.js +318 -0
- package/dist/commands/transcribers.js +95 -0
- package/dist/commands/update.js +86 -0
- package/dist/commands/voices.js +211 -0
- package/dist/data/catalog.json +1978 -0
- package/dist/endpoints.json +8 -0
- package/dist/index.js +684 -0
- package/dist/lib/agentImport.js +60 -0
- package/dist/lib/agentUpdate.js +262 -0
- package/dist/lib/api.js +287 -0
- package/dist/lib/browserAuth.js +93 -0
- package/dist/lib/catalog.js +136 -0
- package/dist/lib/config.js +354 -0
- package/dist/lib/dashboardApi.js +197 -0
- package/dist/lib/dashboardAuth.js +339 -0
- package/dist/lib/dbFields.js +30 -0
- package/dist/lib/logQuery.js +140 -0
- package/dist/lib/looseJson.js +198 -0
- package/dist/lib/session.js +17 -0
- package/dist/lib/types.js +15 -0
- package/dist/lib/ui.js +91 -0
- package/dist/lib/versions.js +19 -0
- package/package.json +52 -0
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.kpiAgentsCommand = kpiAgentsCommand;
|
|
4
|
+
exports.kpiShowCommand = kpiShowCommand;
|
|
5
|
+
exports.kpiTasksCommand = kpiTasksCommand;
|
|
6
|
+
exports.kpiHistoryCommand = kpiHistoryCommand;
|
|
7
|
+
exports.kpiCreateCommand = kpiCreateCommand;
|
|
8
|
+
exports.kpiMoveCommand = kpiMoveCommand;
|
|
9
|
+
exports.kpiSetCommand = kpiSetCommand;
|
|
10
|
+
exports.kpiCommentCommand = kpiCommentCommand;
|
|
11
|
+
exports.kpiAssignCommand = kpiAssignCommand;
|
|
12
|
+
exports.kpiAssigneesCommand = kpiAssigneesCommand;
|
|
13
|
+
exports.kpiTargetCommand = kpiTargetCommand;
|
|
14
|
+
exports.kpiRcaCommand = kpiRcaCommand;
|
|
15
|
+
const dashboardApi_1 = require("../lib/dashboardApi");
|
|
16
|
+
const ui_1 = require("../lib/ui");
|
|
17
|
+
/** Table cells are one line each; these keep a long prompt from wrapping a row. */
|
|
18
|
+
function clip(value, max = 60) {
|
|
19
|
+
const text = value === undefined || value === null ? '' : String(value).replace(/\s+/g, ' ').trim();
|
|
20
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
21
|
+
}
|
|
22
|
+
/** `--limit` with a sane floor, so `--limit 0` or garbage cannot dump 1220 rows. */
|
|
23
|
+
function pageSize(raw, fallback = 25) {
|
|
24
|
+
const n = Number(raw);
|
|
25
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Date fields are plain `YYYY-MM-DD` strings, so string comparison orders them
|
|
29
|
+
* correctly. Accepts `2026-09-01`, `today`, `-7d`, `30d`, or `thisweek`.
|
|
30
|
+
*/
|
|
31
|
+
function dateBound(spec, endOfDay) {
|
|
32
|
+
const day = 86_400_000;
|
|
33
|
+
const now = new Date();
|
|
34
|
+
if (spec === 'today')
|
|
35
|
+
return now.toISOString().slice(0, 10);
|
|
36
|
+
if (/^-?\d+d$/.test(spec)) {
|
|
37
|
+
return new Date(now.getTime() - Number(spec.replace('d', '')) * day).toISOString().slice(0, 10);
|
|
38
|
+
}
|
|
39
|
+
if (spec === 'thisweek') {
|
|
40
|
+
// Monday-anchored, matching how the board's own weekly rollup is labelled.
|
|
41
|
+
const back = (now.getUTCDay() + 6) % 7;
|
|
42
|
+
return new Date(now.getTime() - back * day).toISOString().slice(0, 10);
|
|
43
|
+
}
|
|
44
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(spec))
|
|
45
|
+
return spec;
|
|
46
|
+
void endOfDay;
|
|
47
|
+
throw new Error(`Unrecognised date "${spec}".\n` +
|
|
48
|
+
' Use YYYY-MM-DD (2026-09-01), a relative window (7d, 30d), or today / thisweek.');
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Every filter the board actually supports, checked against a real 1220-record
|
|
52
|
+
* response. `/pending-tasks` takes no parameters, so all of this runs locally —
|
|
53
|
+
* which is also why each filter is ANDed and the count is always reported.
|
|
54
|
+
*/
|
|
55
|
+
function buildFilters(opts) {
|
|
56
|
+
const filters = [];
|
|
57
|
+
if (opts.severity) {
|
|
58
|
+
// The board uses four values; case-insensitive match, and comma = any-of.
|
|
59
|
+
const want = String(opts.severity).split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
60
|
+
filters.push({ label: `severity in ${want.join(',')}`, keep: (t) => want.includes(String(t.severity || '').toLowerCase()) });
|
|
61
|
+
}
|
|
62
|
+
if (opts.agent) {
|
|
63
|
+
// Match the id OR the name, because "Retail Negotiations" is what a person
|
|
64
|
+
// reads off the board and the id is what they get from `cku kpi agents`.
|
|
65
|
+
const want = String(opts.agent).toLowerCase();
|
|
66
|
+
filters.push({
|
|
67
|
+
label: `agent ~ ${opts.agent}`,
|
|
68
|
+
keep: (t) => String(t.agentId || '').toLowerCase() === want ||
|
|
69
|
+
String(t.agentName || '').toLowerCase() === want ||
|
|
70
|
+
String(t.agentName || '').toLowerCase().includes(want),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
if (opts.since) {
|
|
74
|
+
const from = dateBound(String(opts.since), false);
|
|
75
|
+
filters.push({ label: `rcaDate >= ${from}`, keep: (t) => String(t.rcaDate || t.createdAt || '') >= from });
|
|
76
|
+
}
|
|
77
|
+
if (opts.until) {
|
|
78
|
+
const to = dateBound(String(opts.until), true);
|
|
79
|
+
filters.push({ label: `rcaDate <= ${to}`, keep: (t) => String(t.rcaDate || t.createdAt || '') <= to });
|
|
80
|
+
}
|
|
81
|
+
if (opts.assigned) {
|
|
82
|
+
const want = String(opts.assigned).toLowerCase();
|
|
83
|
+
filters.push({
|
|
84
|
+
label: `assigned to ${opts.assigned}`,
|
|
85
|
+
keep: (t) => want === 'none'
|
|
86
|
+
? !t.assignedToName
|
|
87
|
+
: String(t.assignedToName || '').toLowerCase().includes(want),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
if (opts.source) {
|
|
91
|
+
const want = String(opts.source).toLowerCase();
|
|
92
|
+
filters.push({ label: `source ${opts.source}`, keep: (t) => String(t.reportSource || '').toLowerCase() === want });
|
|
93
|
+
}
|
|
94
|
+
if (opts.search) {
|
|
95
|
+
const want = String(opts.search).toLowerCase();
|
|
96
|
+
filters.push({
|
|
97
|
+
label: `text ~ ${opts.search}`,
|
|
98
|
+
keep: (t) => [t.cause, t.recommendedFix, t.description, t.agentName].some((f) => String(f || '').toLowerCase().includes(want)),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
if (opts.hasComments) {
|
|
102
|
+
filters.push({ label: 'has comments', keep: (t) => Array.isArray(t.comments) && t.comments.length > 0 });
|
|
103
|
+
}
|
|
104
|
+
if (opts.withCalls) {
|
|
105
|
+
filters.push({ label: 'has calls', keep: (t) => Array.isArray(t.callIds) && t.callIds.length > 0 });
|
|
106
|
+
}
|
|
107
|
+
if (opts.column) {
|
|
108
|
+
// Comma = any-of, so `--column in_progress,done` is the "still open" query.
|
|
109
|
+
// Resolved through columnId() so a label works here too, and a typo fails
|
|
110
|
+
// here rather than matching nothing and looking like an empty board.
|
|
111
|
+
const want = String(opts.column).split(',').map((s) => columnId(s));
|
|
112
|
+
filters.push({ label: `column in ${want.join(',')}`, keep: (t) => want.includes(String(t.columnId || '')) });
|
|
113
|
+
}
|
|
114
|
+
return filters;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The board's columns, in board order. Read out of the dashboard bundle, which
|
|
118
|
+
* is where the ids come from — they are not the labels, so guessing "In
|
|
119
|
+
* Progress" as an id would 404 on every call.
|
|
120
|
+
*/
|
|
121
|
+
const COLUMNS = [
|
|
122
|
+
{ id: 'action_items', label: 'Action Items' },
|
|
123
|
+
{ id: 'in_progress', label: 'In Progress' },
|
|
124
|
+
{ id: 'testing_ab', label: 'Testing in A/B' },
|
|
125
|
+
{ id: 'live_prod', label: 'Live in Prod' },
|
|
126
|
+
{ id: 'done', label: 'Done' },
|
|
127
|
+
{ id: 'rejected', label: 'Rejected' },
|
|
128
|
+
];
|
|
129
|
+
/**
|
|
130
|
+
* Severity order, worst first. Six values, not four: the dashboard also renders
|
|
131
|
+
* "Very Low" and "Negligible", and omitting them meant `--severity "Very Low"`
|
|
132
|
+
* matched nothing at all while the worst-severity rollup ranked them below Low.
|
|
133
|
+
*/
|
|
134
|
+
const SEVERITY_ORDER = ['Very High', 'High', 'Medium', 'Low', 'Very Low', 'Negligible'];
|
|
135
|
+
/**
|
|
136
|
+
* Columns accept the id, the label, or a slug of either, case-insensitively.
|
|
137
|
+
*
|
|
138
|
+
* Every side goes through the same normaliser. Normalising only the right-hand
|
|
139
|
+
* side is a silent mismatch: `slug('In Progress')` is `inprogress`, which never
|
|
140
|
+
* equals the raw `in progress`, so a label would be rejected. The id is tried
|
|
141
|
+
* as-written first, because that is what the server actually keys on.
|
|
142
|
+
*/
|
|
143
|
+
function columnId(raw) {
|
|
144
|
+
const text = String(raw || '').trim().toLowerCase();
|
|
145
|
+
const hit = COLUMNS.find((c) => c.id === text || slug(c.label) === slug(text) || slug(c.id) === slug(text));
|
|
146
|
+
if (!hit) {
|
|
147
|
+
throw new Error(`Unknown column "${raw}".\n` +
|
|
148
|
+
` Columns: ${COLUMNS.map((c) => `${c.id} (${c.label})`).join(', ')}`);
|
|
149
|
+
}
|
|
150
|
+
return hit.id;
|
|
151
|
+
}
|
|
152
|
+
/** Accepts "Very High", "very-high", "very_high" or "veryhigh" alike. */
|
|
153
|
+
function severity(raw) {
|
|
154
|
+
const want = slug(String(raw || '').toLowerCase());
|
|
155
|
+
const hit = SEVERITY_ORDER.find((s) => slug(s) === want);
|
|
156
|
+
if (!hit) {
|
|
157
|
+
throw new Error(`Unknown severity "${raw}".\n Severities: ${SEVERITY_ORDER.join(', ')}`);
|
|
158
|
+
}
|
|
159
|
+
return hit;
|
|
160
|
+
}
|
|
161
|
+
/** Lowercase, letters and digits only. Folds "Testing in A/B" to "testinginab". */
|
|
162
|
+
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '');
|
|
163
|
+
/**
|
|
164
|
+
* True when `candidate` outranks `current`.
|
|
165
|
+
*
|
|
166
|
+
* An empty `current` has to rank BELOW every real severity, not use
|
|
167
|
+
* `indexOf('')` — that returns -1, which loses to "Low" and leaves the
|
|
168
|
+
* worst-severity column blank forever.
|
|
169
|
+
*/
|
|
170
|
+
function worse(candidate, current) {
|
|
171
|
+
const a = SEVERITY_ORDER.indexOf(candidate || '');
|
|
172
|
+
if (a === -1)
|
|
173
|
+
return false; // unknown severity never wins
|
|
174
|
+
if (!current)
|
|
175
|
+
return true; // nothing recorded yet
|
|
176
|
+
const b = SEVERITY_ORDER.indexOf(current);
|
|
177
|
+
return b === -1 || a < b; // an unrecognised `current` is replaced
|
|
178
|
+
}
|
|
179
|
+
/** `cku kpi agents` — the agents on the board, with names for `--agent`. */
|
|
180
|
+
async function kpiAgentsCommand(opts) {
|
|
181
|
+
try {
|
|
182
|
+
const api = (0, dashboardApi_1.dashboardApi)();
|
|
183
|
+
const [idsRes, tasksRes] = await Promise.all([api.kpiAgents(), api.pendingTasks()]);
|
|
184
|
+
const ids = Array.isArray(idsRes?.agentIds) ? idsRes.agentIds : [];
|
|
185
|
+
const tasks = Array.isArray(tasksRes?.tasks) ? tasksRes.tasks : [];
|
|
186
|
+
// The board carries agentName per task; roll it up so `--agent` can take a
|
|
187
|
+
// readable name instead of forcing the user to look up an ObjectId.
|
|
188
|
+
const names = new Map();
|
|
189
|
+
for (const t of tasks) {
|
|
190
|
+
if (!t.agentId)
|
|
191
|
+
continue;
|
|
192
|
+
const row = names.get(t.agentId) || { name: t.agentName || '', tasks: 0, worst: '', counts: {} };
|
|
193
|
+
row.tasks += 1;
|
|
194
|
+
const sev = t.severity || '';
|
|
195
|
+
row.counts[sev] = (row.counts[sev] || 0) + 1;
|
|
196
|
+
// `if (worse(...))` — never `row.worst = worse(...) ? sev : row.worst || ''`.
|
|
197
|
+
// That idiom discards the current value whenever worse() is correctly
|
|
198
|
+
// false, which is most calls, and leaves the column blank.
|
|
199
|
+
if (worse(sev, row.worst))
|
|
200
|
+
row.worst = sev;
|
|
201
|
+
names.set(t.agentId, row);
|
|
202
|
+
}
|
|
203
|
+
if (opts.json)
|
|
204
|
+
return void console.log(JSON.stringify({ agentIds: ids, agents: [...names].map(([id, v]) => ({ id, ...v })) }, null, 2));
|
|
205
|
+
if (!names.size)
|
|
206
|
+
return void (0, ui_1.info)(ids.length ? `${ids.length} agent id(s): ${ids.join(', ')}` : 'No agents on the KPI board.');
|
|
207
|
+
(0, ui_1.table)(['AGENT ID', 'AGENT', 'TASKS', 'WORST', 'VH', 'H', 'M', 'L'], [...names]
|
|
208
|
+
.sort((a, b) => b[1].tasks - a[1].tasks)
|
|
209
|
+
.map(([id, v]) => [
|
|
210
|
+
id,
|
|
211
|
+
v.name || '(unnamed)',
|
|
212
|
+
v.tasks,
|
|
213
|
+
v.worst || '',
|
|
214
|
+
v.counts['Very High'] || '',
|
|
215
|
+
v.counts['High'] || '',
|
|
216
|
+
v.counts['Medium'] || '',
|
|
217
|
+
v.counts['Low'] || '',
|
|
218
|
+
]));
|
|
219
|
+
(0, ui_1.info)('\nUse the id or the name with `cku kpi tasks --agent <value>`. VH/H/M/L = severity counts.');
|
|
220
|
+
if (ids.length > names.size)
|
|
221
|
+
(0, ui_1.info)(`${ids.length - names.size} scored agent(s) have no tasks yet.`);
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
(0, ui_1.handleError)(err);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* `cku kpi show <taskId>` — one task in full.
|
|
229
|
+
*
|
|
230
|
+
* The table deliberately truncates `cause` and hides `recommendedFix`,
|
|
231
|
+
* `metricImpact`, `comments` and `attachmentUrls`; this is where they are
|
|
232
|
+
* readable. taskId looks like `<agentId>:<rcaDate>:<n>`, so quote it in shells.
|
|
233
|
+
*/
|
|
234
|
+
async function kpiShowCommand(taskId, opts) {
|
|
235
|
+
try {
|
|
236
|
+
const res = await (0, dashboardApi_1.dashboardApi)().pendingTasks();
|
|
237
|
+
const tasks = Array.isArray(res?.tasks) ? res.tasks : [];
|
|
238
|
+
const found = tasks.find((t) => t.taskId === taskId);
|
|
239
|
+
if (!found) {
|
|
240
|
+
return (0, ui_1.handleError)(new Error(`No pending task with id "${taskId}".\n` +
|
|
241
|
+
' Ids look like <agentId>:<date>:<n> — find one with: cku kpi tasks --limit 50\n' +
|
|
242
|
+
` ${tasks.length} task(s) currently on the board.`));
|
|
243
|
+
}
|
|
244
|
+
if (opts.json)
|
|
245
|
+
return void console.log(JSON.stringify(found, null, 2));
|
|
246
|
+
(0, ui_1.keyValue)({
|
|
247
|
+
taskId: found.taskId,
|
|
248
|
+
agent: found.agentName || found.agentId,
|
|
249
|
+
severity: found.severity,
|
|
250
|
+
rcaDate: found.rcaDate,
|
|
251
|
+
source: found.reportSource,
|
|
252
|
+
assignedTo: found.assignedToName || '(unassigned)',
|
|
253
|
+
assignedOn: found.assignedOn,
|
|
254
|
+
updated: found.updatedAt,
|
|
255
|
+
});
|
|
256
|
+
// `rank` is a zero-padded position index whose width grows with the record
|
|
257
|
+
// count (t4 on 2026-09-10, 0001 on 09-28, 21 digits on 09-30). It is a
|
|
258
|
+
// sort key, not an identifier, so it is labelled as such and kept out of the
|
|
259
|
+
// task list — taskId is what you pass back to this command.
|
|
260
|
+
if (found.rank)
|
|
261
|
+
(0, ui_1.info)(`\nboard position key: ${found.rank}`);
|
|
262
|
+
(0, ui_1.info)(`\nCause:\n${found.cause || '(none)'}`);
|
|
263
|
+
if (found.recommendedFix)
|
|
264
|
+
(0, ui_1.info)(`\nRecommended fix:\n${found.recommendedFix}`);
|
|
265
|
+
if (found.metricImpact?.length) {
|
|
266
|
+
(0, ui_1.info)('\nMetric impact:');
|
|
267
|
+
(0, ui_1.table)([...new Set(found.metricImpact.flatMap((m) => Object.keys(m || {})))], found.metricImpact.map((m) => Object.values(m || {}).map(String)));
|
|
268
|
+
}
|
|
269
|
+
if (found.callIds?.length)
|
|
270
|
+
(0, ui_1.info)(`\nCall ids (${found.callIds.length}): ${found.callIds.slice(0, 10).join(', ')}${found.callIds.length > 10 ? ' …' : ''}`);
|
|
271
|
+
if (found.comments?.length) {
|
|
272
|
+
(0, ui_1.info)('\nComments:');
|
|
273
|
+
for (const c of found.comments)
|
|
274
|
+
(0, ui_1.info)(` ${c?.author || c?.user?.name || '?'}: ${c?.text || c?.comment || JSON.stringify(c)}`);
|
|
275
|
+
}
|
|
276
|
+
if (found.attachmentUrls?.length)
|
|
277
|
+
(0, ui_1.info)(`\nAttachments:\n ${found.attachmentUrls.join('\n ')}`);
|
|
278
|
+
}
|
|
279
|
+
catch (err) {
|
|
280
|
+
(0, ui_1.handleError)(err);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/** `cku kpi tasks` — the pending-task board, filtered and paged. */
|
|
284
|
+
async function kpiTasksCommand(opts) {
|
|
285
|
+
try {
|
|
286
|
+
const res = await (0, dashboardApi_1.dashboardApi)().pendingTasks();
|
|
287
|
+
const all = Array.isArray(res?.tasks) ? res.tasks : [];
|
|
288
|
+
let filters;
|
|
289
|
+
try {
|
|
290
|
+
filters = buildFilters(opts);
|
|
291
|
+
}
|
|
292
|
+
catch (err) {
|
|
293
|
+
return (0, ui_1.handleError)(err);
|
|
294
|
+
}
|
|
295
|
+
const rows = filters.length ? all.filter((t) => filters.every((f) => f.keep(t))) : all;
|
|
296
|
+
if (opts.json)
|
|
297
|
+
return void console.log(JSON.stringify({ total: rows.length, tasks: rows }, null, 2));
|
|
298
|
+
const limit = pageSize(opts.limit);
|
|
299
|
+
(0, ui_1.info)(`Pending tasks · ${rows.length} of ${all.length}` +
|
|
300
|
+
(filters.length ? ` (${filters.map((f) => f.label).join(' AND ')})` : '') +
|
|
301
|
+
` · showing ${Math.min(limit, rows.length)}\n`);
|
|
302
|
+
if (!rows.length) {
|
|
303
|
+
return void (0, ui_1.info)(all.length
|
|
304
|
+
? 'Nothing matches. Filters are case-insensitive and combine with AND; --help lists them.'
|
|
305
|
+
: 'Nothing pending.');
|
|
306
|
+
}
|
|
307
|
+
(0, ui_1.table)(['TASK ID', 'COLUMN', 'DATE', 'SEVERITY', 'AGENT', 'ASSIGNED TO', 'CAUSE', 'CALLS'], rows.slice(0, limit).map((t) => [
|
|
308
|
+
t.taskId || '',
|
|
309
|
+
columnLabel(t.columnId),
|
|
310
|
+
t.rcaDate || t.createdAt || '',
|
|
311
|
+
t.severity || '',
|
|
312
|
+
t.agentName || t.agentId || '',
|
|
313
|
+
t.assignedToName || '',
|
|
314
|
+
clip(t.cause, 54),
|
|
315
|
+
Array.isArray(t.callIds) ? t.callIds.length : '',
|
|
316
|
+
]));
|
|
317
|
+
if (rows.length > limit) {
|
|
318
|
+
(0, ui_1.info)(`\n${rows.length - limit} more. Raise with --limit <n>, or use --json to page it yourself.`);
|
|
319
|
+
}
|
|
320
|
+
(0, ui_1.info)('\nRead one in full: cku kpi show "<taskId>" (quote it — it contains colons).');
|
|
321
|
+
(0, ui_1.info)('Move one: cku kpi move "<taskId>" <column> Filter: --column action_items');
|
|
322
|
+
(0, ui_1.info)('`recommendedFix`, `metricImpact`, `comments` and `attachmentUrls` are only in --json.');
|
|
323
|
+
}
|
|
324
|
+
catch (err) {
|
|
325
|
+
(0, ui_1.handleError)(err);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
/** `cku kpi history --agents <ids>` — metric history for the given agents. */
|
|
329
|
+
async function kpiHistoryCommand(opts) {
|
|
330
|
+
if (!opts.agents?.trim()) {
|
|
331
|
+
return (0, ui_1.handleError)(new Error('--agents <id,id,...> is required.\n Get the list with: cku kpi agents'));
|
|
332
|
+
}
|
|
333
|
+
try {
|
|
334
|
+
const agentIds = opts.agents.split(',').map((s) => s.trim()).filter(Boolean);
|
|
335
|
+
if (!agentIds.length) {
|
|
336
|
+
return (0, ui_1.handleError)(new Error(`No agent ids in --agents "${opts.agents}".\n Use a comma-separated list, e.g. --agents abc123,def456`));
|
|
337
|
+
}
|
|
338
|
+
const res = await (0, dashboardApi_1.dashboardApi)().kpiHistory({ agentIds: agentIds.join(',') });
|
|
339
|
+
if (opts.json)
|
|
340
|
+
return void console.log(JSON.stringify(res, null, 2));
|
|
341
|
+
// Deliberately generic: this response's shape was not confirmed field by field,
|
|
342
|
+
// so rendering it structurally is safer than asserting columns that may not exist.
|
|
343
|
+
if (Array.isArray(res)) {
|
|
344
|
+
if (!res.length)
|
|
345
|
+
return void (0, ui_1.info)('No history for those agents.');
|
|
346
|
+
const columns = [...new Set(res.flatMap((r) => (r && typeof r === 'object' ? Object.keys(r) : [])))];
|
|
347
|
+
(0, ui_1.table)(columns, res.map((row) => columns.map((c) => clip(row?.[c], 28))));
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (res && typeof res === 'object') {
|
|
351
|
+
for (const [key, value] of Object.entries(res)) {
|
|
352
|
+
(0, ui_1.info)(`\n${key}`);
|
|
353
|
+
(0, ui_1.keyValue)(typeof value === 'object' && value ? value : { value });
|
|
354
|
+
}
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
(0, ui_1.info)(String(res));
|
|
358
|
+
}
|
|
359
|
+
catch (err) {
|
|
360
|
+
(0, ui_1.handleError)(err);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
// --- writes -----------------------------------------------------------------
|
|
364
|
+
//
|
|
365
|
+
// Routes and bodies below come from the dashboard's own bundle (see
|
|
366
|
+
// `DashboardApi`'s comment on the write methods). Two constraints that reading
|
|
367
|
+
// settled, and that shape every command here:
|
|
368
|
+
//
|
|
369
|
+
// * There is no delete. A card is retired by moving it to `rejected`, which is
|
|
370
|
+
// reversible; nothing here destroys a card.
|
|
371
|
+
// * PATCH writes `description` only. cause / recommendedFix / severity are set
|
|
372
|
+
// at create and are not editable afterwards, so `kpi set` says so rather than
|
|
373
|
+
// silently dropping the field the user asked for.
|
|
374
|
+
/** `cku kpi create` — add a card to the board. */
|
|
375
|
+
async function kpiCreateCommand(opts) {
|
|
376
|
+
try {
|
|
377
|
+
const cause = String(opts.cause || '').trim();
|
|
378
|
+
if (!cause) {
|
|
379
|
+
return (0, ui_1.handleError)(new Error('--cause <text> is required. It is the only field the server insists on.'));
|
|
380
|
+
}
|
|
381
|
+
const body = {
|
|
382
|
+
columnId: columnId(opts.column || 'action_items'),
|
|
383
|
+
cause,
|
|
384
|
+
recommendedFix: String(opts.fix || '').trim(),
|
|
385
|
+
description: String(opts.description || '').trim(),
|
|
386
|
+
severity: opts.severity ? severity(opts.severity) : '',
|
|
387
|
+
estimatedImpactPct: numeric(opts.impact, '--impact'),
|
|
388
|
+
callIds: splitList(opts.calls),
|
|
389
|
+
};
|
|
390
|
+
const res = await (0, dashboardApi_1.dashboardApi)().createPendingTask(body);
|
|
391
|
+
const task = res?.task;
|
|
392
|
+
if (!task) {
|
|
393
|
+
return (0, ui_1.handleError)(new Error('The server accepted the request but returned no task. Nothing was created.'));
|
|
394
|
+
}
|
|
395
|
+
(0, ui_1.success)(`Created ${task.taskId} in ${columnLabel(task.columnId)}.`);
|
|
396
|
+
if (task.taskId)
|
|
397
|
+
(0, ui_1.info)(` Read it: cku kpi show "${task.taskId}"`);
|
|
398
|
+
}
|
|
399
|
+
catch (err) {
|
|
400
|
+
(0, ui_1.handleError)(err);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
/** `cku kpi move <taskId> <column>` — the board's only way to retire a card. */
|
|
404
|
+
async function kpiMoveCommand(taskId, to, opts) {
|
|
405
|
+
try {
|
|
406
|
+
const target = columnId(to);
|
|
407
|
+
const api = (0, dashboardApi_1.dashboardApi)();
|
|
408
|
+
// Read the board first: it is the only way to know the card exists and which
|
|
409
|
+
// column it is in, and a no-op move should say so instead of silently
|
|
410
|
+
// re-writing a card the user thinks they moved.
|
|
411
|
+
const res = await api.pendingTasks();
|
|
412
|
+
const tasks = Array.isArray(res?.tasks) ? res.tasks : [];
|
|
413
|
+
const task = tasks.find((t) => t.taskId === taskId);
|
|
414
|
+
if (!task) {
|
|
415
|
+
return (0, ui_1.handleError)(new Error(`No pending task with id "${taskId}".\n` +
|
|
416
|
+
' Ids look like <agentId>:<date>:<n> — find one with: cku kpi tasks --limit 50'));
|
|
417
|
+
}
|
|
418
|
+
if (task.columnId === target) {
|
|
419
|
+
return (0, ui_1.handleError)(new Error(`${taskId} is already in ${columnLabel(target)}; nothing to do.`));
|
|
420
|
+
}
|
|
421
|
+
const moved = await api.movePendingTask(taskId, target);
|
|
422
|
+
const after = moved?.task?.columnId || target;
|
|
423
|
+
if (opts.json)
|
|
424
|
+
return void console.log(JSON.stringify(moved?.task || { taskId, columnId: after }, null, 2));
|
|
425
|
+
(0, ui_1.success)(`Moved ${taskId}: ${columnLabel(task.columnId)} → ${columnLabel(after)}`);
|
|
426
|
+
if (target === 'rejected') {
|
|
427
|
+
(0, ui_1.info)(` The card is retained, not deleted — move it back with: cku kpi move "${taskId}" action_items`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
catch (err) {
|
|
431
|
+
(0, ui_1.handleError)(err);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
/** `cku kpi set <taskId> --description <text>` — the one editable field. */
|
|
435
|
+
async function kpiSetCommand(taskId, opts) {
|
|
436
|
+
try {
|
|
437
|
+
const description = String(opts.description ?? '').trim();
|
|
438
|
+
if (!description) {
|
|
439
|
+
return (0, ui_1.handleError)(new Error('--description <text> is required.\n' +
|
|
440
|
+
' description is the only field the board lets you edit. cause, --fix and\n' +
|
|
441
|
+
' --severity are set at create and cannot be changed afterwards — to correct\n' +
|
|
442
|
+
' one, create a replacement and move the original to rejected:\n' +
|
|
443
|
+
' cku kpi create --cause "…" --fix "…" --severity "…"\n' +
|
|
444
|
+
` cku kpi move "${taskId}" rejected`));
|
|
445
|
+
}
|
|
446
|
+
const res = await (0, dashboardApi_1.dashboardApi)().updatePendingTask(taskId, { description });
|
|
447
|
+
const task = res?.task;
|
|
448
|
+
if (opts.json)
|
|
449
|
+
return void console.log(JSON.stringify(task || { taskId, description }, null, 2));
|
|
450
|
+
(0, ui_1.success)(`Updated the description on ${taskId}.`);
|
|
451
|
+
}
|
|
452
|
+
catch (err) {
|
|
453
|
+
(0, ui_1.handleError)(err);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
/** `cku kpi comment <taskId> <text>` — append a comment. */
|
|
457
|
+
async function kpiCommentCommand(taskId, text, opts = {}) {
|
|
458
|
+
try {
|
|
459
|
+
const body = String(text || '').trim();
|
|
460
|
+
if (!body) {
|
|
461
|
+
return (0, ui_1.handleError)(new Error('Comment text is required, e.g. cku kpi comment "t1:2026-09-30:1" "fixed in v3"'));
|
|
462
|
+
}
|
|
463
|
+
const res = await (0, dashboardApi_1.dashboardApi)().commentOnPendingTask(taskId, body);
|
|
464
|
+
const task = res?.task;
|
|
465
|
+
if (opts.json)
|
|
466
|
+
return void console.log(JSON.stringify(task || { taskId, text: body }, null, 2));
|
|
467
|
+
const total = Array.isArray(task?.comments) ? task.comments.length : undefined;
|
|
468
|
+
(0, ui_1.success)(`Comment added to ${taskId}.`);
|
|
469
|
+
if (total)
|
|
470
|
+
(0, ui_1.info)(` ${total} comment(s) on the card now.`);
|
|
471
|
+
}
|
|
472
|
+
catch (err) {
|
|
473
|
+
(0, ui_1.handleError)(err);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* `cku kpi assign <taskId> --to <who>` — hand a card to a person, or to AI FDE.
|
|
478
|
+
*
|
|
479
|
+
* AI FDE is a different animal: the server queues the change behind whatever
|
|
480
|
+
* that version is already doing, so a queued result is a success, not a failure.
|
|
481
|
+
*/
|
|
482
|
+
async function kpiAssignCommand(taskId, opts) {
|
|
483
|
+
try {
|
|
484
|
+
let to = String(opts.to || '').trim();
|
|
485
|
+
if (!to) {
|
|
486
|
+
return (0, ui_1.handleError)(new Error('--to <who> is required.\n' +
|
|
487
|
+
' Use a team member, or "ai_fde" to hand it to AI FDE.\n' +
|
|
488
|
+
' List people with: cku kpi assignees'));
|
|
489
|
+
}
|
|
490
|
+
const api = (0, dashboardApi_1.dashboardApi)();
|
|
491
|
+
const isFde = to.toLowerCase() === 'ai_fde';
|
|
492
|
+
const agentId = String(opts.agent || '').trim();
|
|
493
|
+
const versionIds = splitList(opts.versions);
|
|
494
|
+
if (isFde) {
|
|
495
|
+
// The server rejects an AI FDE assignment with no target versions, so
|
|
496
|
+
// fail here where the fix is legible rather than as a 400.
|
|
497
|
+
if (!agentId) {
|
|
498
|
+
return (0, ui_1.handleError)(new Error('Assigning to ai_fde needs --agent <id> — which agent should change.\n' +
|
|
499
|
+
' List them with: cku kpi agents'));
|
|
500
|
+
}
|
|
501
|
+
if (!versionIds.length) {
|
|
502
|
+
return (0, ui_1.handleError)(new Error('Assigning to ai_fde needs --versions <id,id,...> — which versions to change.\n' +
|
|
503
|
+
' Leaving it out would ask AI FDE to act on nothing.'));
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
// Resolve the label server-side style: the payload carries both id and
|
|
507
|
+
// label, and team members are the only source of a readable name.
|
|
508
|
+
let label = to;
|
|
509
|
+
if (!isFde) {
|
|
510
|
+
let members = [];
|
|
511
|
+
try {
|
|
512
|
+
const team = await api.teamMembers();
|
|
513
|
+
members = Array.isArray(team?.teamMembers) ? team.teamMembers : Array.isArray(team) ? team : [];
|
|
514
|
+
}
|
|
515
|
+
catch {
|
|
516
|
+
// Not being able to read the roster should not block the write; the id
|
|
517
|
+
// is what the server keys on, and the label is only for display.
|
|
518
|
+
}
|
|
519
|
+
const hit = members.find((m) => String(m._id || m.id) === to || String(m.name || '').toLowerCase() === to.toLowerCase());
|
|
520
|
+
if (members.length && !hit) {
|
|
521
|
+
return (0, ui_1.handleError)(new Error(`No team member matching "${to}".\n` +
|
|
522
|
+
` Known: ${members.map((m) => m.name).filter(Boolean).join(', ') || '(none)'}\n` +
|
|
523
|
+
' Or hand it to AI FDE with --to ai_fde'));
|
|
524
|
+
}
|
|
525
|
+
if (hit) {
|
|
526
|
+
label = hit.name;
|
|
527
|
+
to = String(hit._id || hit.id);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
else {
|
|
531
|
+
label = 'AI FDE';
|
|
532
|
+
}
|
|
533
|
+
const res = await api.savePendingAssignments([
|
|
534
|
+
{
|
|
535
|
+
taskId,
|
|
536
|
+
assignTo: to,
|
|
537
|
+
assignToLabel: label,
|
|
538
|
+
versionIds,
|
|
539
|
+
versionNames: splitList(opts.versionNames),
|
|
540
|
+
agentId: isFde ? agentId : String(opts.agent || ''),
|
|
541
|
+
abSplit: Boolean(opts.abSplit),
|
|
542
|
+
applyToPublished: Boolean(opts.applyToPublished),
|
|
543
|
+
},
|
|
544
|
+
]);
|
|
545
|
+
const result = Array.isArray(res?.results) ? res.results[0] : undefined;
|
|
546
|
+
if (result && result.success === false) {
|
|
547
|
+
return (0, ui_1.handleError)(new Error(result.error || 'The server refused the assignment.'));
|
|
548
|
+
}
|
|
549
|
+
if (opts.json)
|
|
550
|
+
return void console.log(JSON.stringify(res, null, 2));
|
|
551
|
+
(0, ui_1.success)(`${taskId} assigned to ${label}.`);
|
|
552
|
+
if (result?.queued) {
|
|
553
|
+
(0, ui_1.info)(' Queued: AI FDE is already working this version, so the change starts when that finishes.');
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
catch (err) {
|
|
557
|
+
(0, ui_1.handleError)(err);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
/** `cku kpi assignees` — the people a card can be handed to. */
|
|
561
|
+
async function kpiAssigneesCommand(opts) {
|
|
562
|
+
try {
|
|
563
|
+
const res = await (0, dashboardApi_1.dashboardApi)().teamMembers();
|
|
564
|
+
const members = Array.isArray(res?.teamMembers) ? res.teamMembers
|
|
565
|
+
: Array.isArray(res) ? res : [];
|
|
566
|
+
if (opts.json)
|
|
567
|
+
return void console.log(JSON.stringify(members, null, 2));
|
|
568
|
+
if (!members.length)
|
|
569
|
+
return void (0, ui_1.info)('No team members returned.');
|
|
570
|
+
(0, ui_1.table)(['ID', 'NAME', 'EMAIL'], members.map((m) => [m._id || m.id || '', m.name || '', m.email || '']));
|
|
571
|
+
(0, ui_1.info)('\nAssign: cku kpi assign "<taskId>" --to <id or name> (or --to ai_fde)');
|
|
572
|
+
}
|
|
573
|
+
catch (err) {
|
|
574
|
+
(0, ui_1.handleError)(err);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* `cku kpi target --agent <id> --value <n>` — set the KPI Manager target.
|
|
579
|
+
*
|
|
580
|
+
* The server replaces the whole `kpi_metrics` array and the RCA config together,
|
|
581
|
+
* so the current values are read first and sent back. Omitting them would clear
|
|
582
|
+
* a configured RCA prompt on an account that has one.
|
|
583
|
+
*/
|
|
584
|
+
async function kpiTargetCommand(opts) {
|
|
585
|
+
try {
|
|
586
|
+
const agentId = String(opts.agent || '').trim();
|
|
587
|
+
if (!agentId) {
|
|
588
|
+
return (0, ui_1.handleError)(new Error('--agent <id> is required. List them with: cku kpi agents'));
|
|
589
|
+
}
|
|
590
|
+
if (opts.value === undefined || String(opts.value).trim() === '') {
|
|
591
|
+
return (0, ui_1.handleError)(new Error('--value <n> is required — the target percentage.\n' +
|
|
592
|
+
' See the current one with: cku kpi target --agent ' + agentId + ' --show'));
|
|
593
|
+
}
|
|
594
|
+
const api = (0, dashboardApi_1.dashboardApi)();
|
|
595
|
+
let current = {};
|
|
596
|
+
try {
|
|
597
|
+
current = (await api.kpiAgentConfig(agentId)) || {};
|
|
598
|
+
}
|
|
599
|
+
catch {
|
|
600
|
+
// No config yet is a normal state, not a failure: the PUT creates it.
|
|
601
|
+
current = {};
|
|
602
|
+
}
|
|
603
|
+
const metricName = 'connected_to_conversion_percentage';
|
|
604
|
+
const body = {
|
|
605
|
+
kpi_metrics: [{ name: metricName, source: metricName, value: String(opts.value).trim() }],
|
|
606
|
+
};
|
|
607
|
+
// Only send the RCA fields when the user names them, or when the account
|
|
608
|
+
// already has them and would otherwise be wiped.
|
|
609
|
+
const rcaModel = opts.rcaModel ?? current.rca_model;
|
|
610
|
+
const rcaPrompt = opts.rcaPrompt ?? current.rca_prompt;
|
|
611
|
+
if (rcaModel)
|
|
612
|
+
body.rca_model = String(rcaModel);
|
|
613
|
+
if (rcaPrompt !== undefined)
|
|
614
|
+
body.rca_prompt = String(rcaPrompt);
|
|
615
|
+
const res = await api.setKpiAgentConfig(agentId, body);
|
|
616
|
+
if (opts.json)
|
|
617
|
+
return void console.log(JSON.stringify(res ?? body, null, 2));
|
|
618
|
+
(0, ui_1.success)(`KPI Manager target for ${agentId} set to ${String(opts.value).trim()}.`);
|
|
619
|
+
}
|
|
620
|
+
catch (err) {
|
|
621
|
+
(0, ui_1.handleError)(err);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
/** `cku kpi rca --agent <id> --since <date> --until <date>` — start an RCA run. */
|
|
625
|
+
async function kpiRcaCommand(opts) {
|
|
626
|
+
try {
|
|
627
|
+
const agentId = String(opts.agent || '').trim();
|
|
628
|
+
if (!agentId) {
|
|
629
|
+
return (0, ui_1.handleError)(new Error('--agent <id> is required. List them with: cku kpi agents'));
|
|
630
|
+
}
|
|
631
|
+
if (agentId.includes(',')) {
|
|
632
|
+
return (0, ui_1.handleError)(new Error('RCA runs against one agent at a time; --agent took more than one id.\n' +
|
|
633
|
+
' Run it once per agent.'));
|
|
634
|
+
}
|
|
635
|
+
if (!opts.since || !opts.until) {
|
|
636
|
+
return (0, ui_1.handleError)(new Error('--since <date> and --until <date> are both required — RCA is scoped to a range.\n' +
|
|
637
|
+
' e.g. --since 2026-09-01 --until 2026-09-30 (also 7d / 30d / today / thisweek)'));
|
|
638
|
+
}
|
|
639
|
+
let filters;
|
|
640
|
+
try {
|
|
641
|
+
filters = {
|
|
642
|
+
agent_id: agentId,
|
|
643
|
+
start_date: dateBound(String(opts.since), false),
|
|
644
|
+
end_date: dateBound(String(opts.until), true),
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
catch (err) {
|
|
648
|
+
return (0, ui_1.handleError)(err);
|
|
649
|
+
}
|
|
650
|
+
const res = await (0, dashboardApi_1.dashboardApi)().runKpiRca(agentId, filters);
|
|
651
|
+
if (opts.json)
|
|
652
|
+
return void console.log(JSON.stringify(res ?? {}, null, 2));
|
|
653
|
+
(0, ui_1.success)('RCA started.');
|
|
654
|
+
(0, ui_1.info)(' Results appear under RCA History on the dashboard, and as new cards on the board.');
|
|
655
|
+
}
|
|
656
|
+
catch (err) {
|
|
657
|
+
(0, ui_1.handleError)(err);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
/** A human label for a column id, falling back to the id itself. */
|
|
661
|
+
function columnLabel(id) {
|
|
662
|
+
const hit = COLUMNS.find((c) => c.id === id);
|
|
663
|
+
return hit ? hit.label : String(id ?? '?');
|
|
664
|
+
}
|
|
665
|
+
/** `--impact` as a number, or undefined. A bad value is an error, not a NaN. */
|
|
666
|
+
function numeric(raw, flag) {
|
|
667
|
+
if (raw === undefined || raw === null || raw === '')
|
|
668
|
+
return undefined;
|
|
669
|
+
const n = Number(raw);
|
|
670
|
+
if (!Number.isFinite(n)) {
|
|
671
|
+
throw new Error(`${flag} must be a number (got "${raw}").`);
|
|
672
|
+
}
|
|
673
|
+
return n;
|
|
674
|
+
}
|
|
675
|
+
/** `--calls a,b` / `--versions a,b` into a clean array. */
|
|
676
|
+
function splitList(raw) {
|
|
677
|
+
return String(raw || '')
|
|
678
|
+
.split(',')
|
|
679
|
+
.map((s) => s.trim())
|
|
680
|
+
.filter(Boolean);
|
|
681
|
+
}
|