callkaro-cli-unofficial 0.4.1 → 0.5.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/dist/commands/accounts.js +30 -3
- package/dist/commands/contacts.js +378 -0
- package/dist/commands/simulations.js +45 -9
- package/dist/commands/update.js +13 -6
- package/dist/commands/workflows.js +458 -0
- package/dist/index.js +101 -1
- package/dist/lib/api.js +149 -14
- package/dist/lib/config.js +59 -4
- package/dist/lib/workflowEdit.js +183 -0
- package/package.json +1 -1
|
@@ -44,7 +44,19 @@ async function loginCommand(opts) {
|
|
|
44
44
|
if (!email || !password)
|
|
45
45
|
return (0, ui_1.handleError)(new Error('Email and password are required'));
|
|
46
46
|
const res = await api.login(email, password);
|
|
47
|
-
|
|
47
|
+
// The password is kept by default so a token the server stops accepting can
|
|
48
|
+
// be re-issued on its own — the `ck_` token is opaque and has no refresh
|
|
49
|
+
// route, so a dead token is otherwise a dead end. `--no-save-password` opts
|
|
50
|
+
// out and brings back a manual re-login.
|
|
51
|
+
const keep = opts.savePassword !== false;
|
|
52
|
+
(0, ui_1.success)((0, config_1.saveLogin)(res.user?.email || email, { token: res.token, user: res.user, ...(keep ? { password } : {}) }));
|
|
53
|
+
if (keep) {
|
|
54
|
+
(0, ui_1.info)(' Password saved (plain text, mode 0600) so the CLI can renew this token by itself.');
|
|
55
|
+
(0, ui_1.info)(' Drop it with `cku accounts remove <email>`, or sign in with --no-save-password.');
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
(0, ui_1.info)(' Password not saved. The token cannot be renewed, so re-run this if it stops working.');
|
|
59
|
+
}
|
|
48
60
|
}
|
|
49
61
|
catch (err) {
|
|
50
62
|
(0, ui_1.handleError)(err);
|
|
@@ -160,9 +172,24 @@ async function configCommand() {
|
|
|
160
172
|
async function accountsCommand(opts) {
|
|
161
173
|
const accounts = (0, config_1.listAccounts)();
|
|
162
174
|
const active = (0, config_1.activeAccount)();
|
|
163
|
-
// Never print tokens: this output ends up in agent transcripts
|
|
175
|
+
// Never print tokens or passwords: this output ends up in agent transcripts
|
|
176
|
+
// and CI logs. `signedIn` says whether a credential is held, not what it is.
|
|
164
177
|
if (opts.json) {
|
|
165
|
-
return void console.log(JSON.stringify(accounts.map(({ token, ...a }) => ({
|
|
178
|
+
return void console.log(JSON.stringify(accounts.map(({ token, password, dashboard, ...a }) => ({
|
|
179
|
+
...a,
|
|
180
|
+
signedIn: Boolean(token),
|
|
181
|
+
...(dashboard
|
|
182
|
+
? {
|
|
183
|
+
dashboard: {
|
|
184
|
+
email: dashboard.email,
|
|
185
|
+
userId: dashboard.userId,
|
|
186
|
+
expiresAt: dashboard.expiresAt,
|
|
187
|
+
savedAt: dashboard.savedAt,
|
|
188
|
+
signedIn: Boolean(dashboard.token),
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
: {}),
|
|
192
|
+
})), null, 2));
|
|
166
193
|
}
|
|
167
194
|
if (!accounts.length) {
|
|
168
195
|
return void (0, ui_1.info)('No accounts saved. Run `cku login` to add one.');
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.collect = void 0;
|
|
4
|
+
exports.contactsListCommand = contactsListCommand;
|
|
5
|
+
exports.contactsGetCommand = contactsGetCommand;
|
|
6
|
+
exports.contactsCreateCommand = contactsCreateCommand;
|
|
7
|
+
exports.contactsUpdateCommand = contactsUpdateCommand;
|
|
8
|
+
exports.contactsDeleteCommand = contactsDeleteCommand;
|
|
9
|
+
exports.contactsUploadCommand = contactsUploadCommand;
|
|
10
|
+
exports.contactsExportCommand = contactsExportCommand;
|
|
11
|
+
exports.contactsAssignCommand = contactsAssignCommand;
|
|
12
|
+
exports.attributesListCommand = attributesListCommand;
|
|
13
|
+
exports.attributesSetCommand = attributesSetCommand;
|
|
14
|
+
exports.attributesDeleteCommand = attributesDeleteCommand;
|
|
15
|
+
const fs_1 = require("fs");
|
|
16
|
+
const session_1 = require("../lib/session");
|
|
17
|
+
const ui_1 = require("../lib/ui");
|
|
18
|
+
const collect = (val, acc) => {
|
|
19
|
+
acc.push(val);
|
|
20
|
+
return acc;
|
|
21
|
+
};
|
|
22
|
+
exports.collect = collect;
|
|
23
|
+
const ATTRIBUTE_TYPES = [
|
|
24
|
+
'string', 'description', 'enum', 'number', 'date', 'boolean', 'datetime', 'time', 'array', 'object',
|
|
25
|
+
];
|
|
26
|
+
/** ["city=Pune", "age=30"] -> { city: "Pune", age: "30" }. The server coerces by attribute type. */
|
|
27
|
+
function parsePairs(pairs, flag) {
|
|
28
|
+
const out = {};
|
|
29
|
+
for (const pair of pairs || []) {
|
|
30
|
+
const i = pair.indexOf('=');
|
|
31
|
+
if (i < 1)
|
|
32
|
+
throw new Error(`${flag} expects name=value, got "${pair}"`);
|
|
33
|
+
out[pair.slice(0, i).trim()] = pair.slice(i + 1);
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
function filterQuery(o) {
|
|
38
|
+
const attrs = parsePairs(o.attr, '--attr');
|
|
39
|
+
return {
|
|
40
|
+
search: o.search,
|
|
41
|
+
channel: o.channel,
|
|
42
|
+
lastMessageStates: o.lastMessageState,
|
|
43
|
+
createdFrom: o.createdFrom,
|
|
44
|
+
createdTo: o.createdTo,
|
|
45
|
+
updatedFrom: o.updatedFrom,
|
|
46
|
+
updatedTo: o.updatedTo,
|
|
47
|
+
attrs: Object.keys(attrs).length ? JSON.stringify(attrs) : undefined,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** Attribute values are stored as { value, comment, source, ... }; show just the value. */
|
|
51
|
+
const attrValue = (c, name) => {
|
|
52
|
+
const a = c.attributes?.[name];
|
|
53
|
+
const v = a && typeof a === 'object' && 'value' in a ? a.value : a;
|
|
54
|
+
return v === undefined || v === null ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v);
|
|
55
|
+
};
|
|
56
|
+
function output(value, opts, label) {
|
|
57
|
+
if (opts.file) {
|
|
58
|
+
(0, fs_1.writeFileSync)(opts.file, JSON.stringify(value, null, 2));
|
|
59
|
+
(0, ui_1.success)(`Saved ${label} to ${opts.file}`);
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
if (opts.json) {
|
|
63
|
+
console.log(JSON.stringify(value, null, 2));
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
// ---- contacts -------------------------------------------------------------
|
|
69
|
+
async function contactsListCommand(opts) {
|
|
70
|
+
const api = (0, session_1.requireApi)();
|
|
71
|
+
try {
|
|
72
|
+
const { contacts, pagination } = await api.listContacts({
|
|
73
|
+
...filterQuery(opts),
|
|
74
|
+
assignedToMe: opts.assignedToMe ? 'true' : undefined,
|
|
75
|
+
page: opts.page,
|
|
76
|
+
pageSize: opts.pageSize,
|
|
77
|
+
});
|
|
78
|
+
if (output({ contacts, pagination }, opts, 'contacts'))
|
|
79
|
+
return;
|
|
80
|
+
if (!contacts.length)
|
|
81
|
+
return void (0, ui_1.info)('No contacts match.');
|
|
82
|
+
const names = opts.columns
|
|
83
|
+
? String(opts.columns).split(',').map((s) => s.trim()).filter(Boolean)
|
|
84
|
+
: [...new Set(contacts.flatMap((c) => Object.keys(c.attributes || {})))].slice(0, 3);
|
|
85
|
+
(0, ui_1.table)(['PHONE', 'CHANNELS', ...names], contacts.map((c) => [c.phone_number, (c.channels || []).join(','), ...names.map((n) => attrValue(c, n))]));
|
|
86
|
+
(0, ui_1.info)(`Page ${pagination.page}/${pagination.totalPages || 1} — ${pagination.total} contact(s)${pagination.hasNext ? ' (use --page to see more)' : ''}`);
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
(0, ui_1.handleError)(err);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async function contactsGetCommand(phone, opts) {
|
|
93
|
+
const api = (0, session_1.requireApi)();
|
|
94
|
+
try {
|
|
95
|
+
const { contact } = await api.getContact(phone);
|
|
96
|
+
if (output(contact, opts, 'contact'))
|
|
97
|
+
return;
|
|
98
|
+
(0, ui_1.keyValue)({
|
|
99
|
+
Phone: contact.phone_number,
|
|
100
|
+
Channels: (contact.channels || []).join(', '),
|
|
101
|
+
Created: contact.createdAt,
|
|
102
|
+
Updated: contact.updatedAt,
|
|
103
|
+
});
|
|
104
|
+
const names = Object.keys(contact.attributes || {});
|
|
105
|
+
if (names.length) {
|
|
106
|
+
console.log();
|
|
107
|
+
(0, ui_1.table)(['ATTRIBUTE', 'VALUE'], names.map((n) => [n, attrValue(contact, n)]));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
(0, ui_1.handleError)(err);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async function contactsCreateCommand(phone, opts) {
|
|
115
|
+
const api = (0, session_1.requireApi)();
|
|
116
|
+
try {
|
|
117
|
+
const attributes = parsePairs(opts.attr, '--attr');
|
|
118
|
+
const { contact } = await api.saveContact({
|
|
119
|
+
phone_number: phone,
|
|
120
|
+
channels: opts.channel?.length ? opts.channel : undefined,
|
|
121
|
+
});
|
|
122
|
+
if (Object.keys(attributes).length)
|
|
123
|
+
await api.updateContactAttributes(contact.phone_number, attributes);
|
|
124
|
+
(0, ui_1.success)(`Contact ${contact.phone_number} saved.`);
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
(0, ui_1.handleError)(err);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
async function contactsUpdateCommand(phone, opts) {
|
|
131
|
+
const api = (0, session_1.requireApi)();
|
|
132
|
+
try {
|
|
133
|
+
const attributes = parsePairs(opts.attr, '--attr');
|
|
134
|
+
for (const name of opts.clear || [])
|
|
135
|
+
attributes[name] = '';
|
|
136
|
+
if (!Object.keys(attributes).length) {
|
|
137
|
+
throw new Error('Nothing to update: pass --attr name=value and/or --clear name.');
|
|
138
|
+
}
|
|
139
|
+
const { msg } = await api.updateContactAttributes(phone, attributes);
|
|
140
|
+
(0, ui_1.success)(msg || 'Contact updated.');
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
(0, ui_1.handleError)(err);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async function contactsDeleteCommand(phone, opts) {
|
|
147
|
+
const api = (0, session_1.requireApi)();
|
|
148
|
+
try {
|
|
149
|
+
if (!(await (0, ui_1.confirm)(`Delete contact ${phone}? This cannot be undone.`, opts.yes)))
|
|
150
|
+
return;
|
|
151
|
+
await api.deleteContact(phone);
|
|
152
|
+
(0, ui_1.success)('Contact deleted.');
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
(0, ui_1.handleError)(err);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
async function contactsUploadCommand(file, opts) {
|
|
159
|
+
const api = (0, session_1.requireApi)();
|
|
160
|
+
try {
|
|
161
|
+
if (!(0, fs_1.existsSync)(file))
|
|
162
|
+
throw new Error(`File not found: ${file}`);
|
|
163
|
+
const { upload, invalid } = await api.uploadContacts(file, opts.ignoreUnknown);
|
|
164
|
+
(0, ui_1.success)(`${upload.contactsWritten} contact(s) written from ${upload.rowsRead} row(s).`);
|
|
165
|
+
if (upload.ignoredColumns?.length)
|
|
166
|
+
(0, ui_1.info)(`Ignored columns: ${upload.ignoredColumns.join(', ')}`);
|
|
167
|
+
if (upload.skippedCells) {
|
|
168
|
+
(0, ui_1.info)(`${upload.skippedCells} cell(s) skipped:`);
|
|
169
|
+
for (const bad of invalid || []) {
|
|
170
|
+
console.error(` row ${bad.row} ${bad.column}: ${bad.reason}${bad.value ? ` ("${bad.value}")` : ''}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
(0, ui_1.handleError)(err);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
async function contactsExportCommand(opts) {
|
|
179
|
+
const api = (0, session_1.requireApi)();
|
|
180
|
+
try {
|
|
181
|
+
const res = await api.exportContacts({
|
|
182
|
+
...filterQuery(opts),
|
|
183
|
+
export_fields: opts.fields,
|
|
184
|
+
user_email: opts.email,
|
|
185
|
+
});
|
|
186
|
+
if (res.processing)
|
|
187
|
+
return void (0, ui_1.info)(res.msg || `Export started; it will be emailed to ${opts.email}.`);
|
|
188
|
+
if (opts.file) {
|
|
189
|
+
(0, fs_1.writeFileSync)(opts.file, res.csv || '');
|
|
190
|
+
return void (0, ui_1.success)(`Saved contacts to ${opts.file}`);
|
|
191
|
+
}
|
|
192
|
+
process.stdout.write(res.csv || '');
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
(0, ui_1.handleError)(err);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* What `assign` would do, without starting anything. Selects contacts the way the server does:
|
|
200
|
+
* --phones keeps only numbers that are contacts (the rest are silently skipped on a real assign,
|
|
201
|
+
* so say which), otherwise the filters pick the set. The server stops at 20000 contacts.
|
|
202
|
+
*/
|
|
203
|
+
async function assignDryRun(api, opts, phones, q) {
|
|
204
|
+
let target = `voice agent ${opts.agent}`;
|
|
205
|
+
if (opts.workflow) {
|
|
206
|
+
const wf = await api.getWorkflow(opts.workflow);
|
|
207
|
+
const steps = wf.graph?.nodes?.length ?? 0;
|
|
208
|
+
if (wf.type && wf.type !== 'normal')
|
|
209
|
+
throw new Error('This workflow is deprecated and cannot take contacts.');
|
|
210
|
+
if (!steps)
|
|
211
|
+
throw new Error(`Workflow "${wf.workFlowName}" has no steps yet; the server would refuse this assign.`);
|
|
212
|
+
target = `workflow "${wf.workFlowName}" (${steps} nodes)`;
|
|
213
|
+
}
|
|
214
|
+
let total;
|
|
215
|
+
let sample;
|
|
216
|
+
const missing = [];
|
|
217
|
+
if (phones?.length) {
|
|
218
|
+
const wanted = [...new Set(phones.map((p) => p.replace(/\D/g, '')).filter(Boolean))];
|
|
219
|
+
const found = [];
|
|
220
|
+
for (let i = 0; i < wanted.length; i += 8) {
|
|
221
|
+
await Promise.all(wanted.slice(i, i + 8).map(async (p) => {
|
|
222
|
+
try {
|
|
223
|
+
await api.getContact(p);
|
|
224
|
+
found.push(p);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
missing.push(p);
|
|
228
|
+
}
|
|
229
|
+
}));
|
|
230
|
+
}
|
|
231
|
+
total = found.length;
|
|
232
|
+
sample = wanted.filter((p) => found.includes(p)).slice(0, 5);
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
const { contacts, pagination } = await api.listContacts({ ...q, page: 1, pageSize: 5 });
|
|
236
|
+
total = pagination.total;
|
|
237
|
+
sample = contacts.map((c) => c.phone_number);
|
|
238
|
+
}
|
|
239
|
+
(0, ui_1.keyValue)({
|
|
240
|
+
Target: target,
|
|
241
|
+
'Would assign': `${total} contact(s)${total > 20000 ? ' (the server caps one assign at 20000)' : ''}`,
|
|
242
|
+
Sample: sample.join(', ') || undefined,
|
|
243
|
+
'Not contacts (skipped)': missing.length ? missing.join(', ') : undefined,
|
|
244
|
+
Starts: opts.at || 'now',
|
|
245
|
+
'Daily window': opts.window,
|
|
246
|
+
});
|
|
247
|
+
(0, ui_1.info)('Dry run: nothing was started. Contacts already running in the workflow are skipped by a real assign.');
|
|
248
|
+
}
|
|
249
|
+
/** Add contacts to a workflow (--workflow) or call them with a voice agent (--agent). */
|
|
250
|
+
async function contactsAssignCommand(opts) {
|
|
251
|
+
const api = (0, session_1.requireApi)();
|
|
252
|
+
try {
|
|
253
|
+
if (Boolean(opts.workflow) === Boolean(opts.agent)) {
|
|
254
|
+
throw new Error('Pass exactly one of --workflow <id> or --agent <id>.');
|
|
255
|
+
}
|
|
256
|
+
const phones = opts.phones?.split(',').map((p) => p.trim()).filter(Boolean);
|
|
257
|
+
const q = filterQuery(opts);
|
|
258
|
+
const hasFilters = Object.values(q).some((v) => v !== undefined);
|
|
259
|
+
if (opts.dryRun)
|
|
260
|
+
return await assignDryRun(api, opts, phones, q);
|
|
261
|
+
if (!phones?.length && !hasFilters && !(await (0, ui_1.confirm)('No --phones or filters given: assign EVERY contact?', opts.yes))) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
let minTriggerTime;
|
|
265
|
+
let maxTriggerTime;
|
|
266
|
+
if (opts.window) {
|
|
267
|
+
const m = String(opts.window).match(/^(\d{2}:\d{2})-(\d{2}:\d{2})$/);
|
|
268
|
+
if (!m)
|
|
269
|
+
throw new Error('--window must be HH:MM-HH:MM, e.g. 10:00-19:00');
|
|
270
|
+
[, minTriggerTime, maxTriggerTime] = m;
|
|
271
|
+
}
|
|
272
|
+
const res = await api.assignContacts({
|
|
273
|
+
scope: phones?.length ? 'contacts' : 'filtered',
|
|
274
|
+
phones,
|
|
275
|
+
filters: { ...q, attrs: q.attrs ? JSON.parse(q.attrs) : undefined },
|
|
276
|
+
target: opts.workflow ? 'workflow' : 'voice',
|
|
277
|
+
workflowId: opts.workflow,
|
|
278
|
+
agentId: opts.agent,
|
|
279
|
+
name: opts.name,
|
|
280
|
+
language: opts.language,
|
|
281
|
+
scheduleLater: Boolean(opts.at),
|
|
282
|
+
scheduleAt: opts.at,
|
|
283
|
+
minTriggerTime,
|
|
284
|
+
maxTriggerTime,
|
|
285
|
+
numberOfRetries: opts.retries !== undefined ? Number(opts.retries) : undefined,
|
|
286
|
+
gapBetweenRetries: opts.gaps,
|
|
287
|
+
carryOver: opts.carryOver,
|
|
288
|
+
priority: opts.priority !== undefined ? Number(opts.priority) : undefined,
|
|
289
|
+
force: opts.force,
|
|
290
|
+
});
|
|
291
|
+
(res.contactsAssigned === 0 ? ui_1.info : ui_1.success)(res.msg || 'Assigned.');
|
|
292
|
+
if (res.alreadyRunning)
|
|
293
|
+
(0, ui_1.info)(`${res.alreadyRunning} already in this workflow (skipped; use --force to restart them).`);
|
|
294
|
+
if (res.truncated)
|
|
295
|
+
(0, ui_1.info)('The selection was capped at the server limit; narrow the filters to cover the rest.');
|
|
296
|
+
if (res.batchId)
|
|
297
|
+
(0, ui_1.info)(`Batch id: ${res.batchId}`);
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
(0, ui_1.handleError)(err);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
// ---- attribute definitions --------------------------------------------------
|
|
304
|
+
async function attributesListCommand(opts) {
|
|
305
|
+
const api = (0, session_1.requireApi)();
|
|
306
|
+
try {
|
|
307
|
+
const { definitions } = await api.listContactAttributes();
|
|
308
|
+
if (output(definitions, opts, 'attributes'))
|
|
309
|
+
return;
|
|
310
|
+
if (!definitions.length) {
|
|
311
|
+
return void (0, ui_1.info)('No attributes defined yet. Create one with `cku contacts attributes set <name>`.');
|
|
312
|
+
}
|
|
313
|
+
(0, ui_1.table)(['NAME', 'TYPE', 'LABEL', 'OPTIONS', 'AI-UPDATABLE', 'REQUIRED'], definitions.map((d) => [
|
|
314
|
+
d.name,
|
|
315
|
+
d.type,
|
|
316
|
+
d.display_name || '',
|
|
317
|
+
(d.options || []).join(','),
|
|
318
|
+
d.updateOn === 'manual' ? 'no' : 'yes',
|
|
319
|
+
d.required ? 'yes' : '',
|
|
320
|
+
]));
|
|
321
|
+
}
|
|
322
|
+
catch (err) {
|
|
323
|
+
(0, ui_1.handleError)(err);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Create or edit an attribute. Saving replaces the whole definition server-side,
|
|
328
|
+
* so an existing one is read first and only the flags given are changed.
|
|
329
|
+
*/
|
|
330
|
+
async function attributesSetCommand(name, opts) {
|
|
331
|
+
const api = (0, session_1.requireApi)();
|
|
332
|
+
try {
|
|
333
|
+
if (opts.type && !ATTRIBUTE_TYPES.includes(opts.type)) {
|
|
334
|
+
throw new Error(`--type must be one of: ${ATTRIBUTE_TYPES.join(', ')}`);
|
|
335
|
+
}
|
|
336
|
+
if (opts.defaultMode && !['drop', 'type_default', 'custom'].includes(opts.defaultMode)) {
|
|
337
|
+
throw new Error('--default-mode must be drop, type_default or custom');
|
|
338
|
+
}
|
|
339
|
+
const { definitions } = await api.listContactAttributes();
|
|
340
|
+
const old = definitions.find((d) => d.name === name);
|
|
341
|
+
if (!old && !opts.type) {
|
|
342
|
+
throw new Error(`"${name}" does not exist yet; pass --type (${ATTRIBUTE_TYPES.join(', ')}).`);
|
|
343
|
+
}
|
|
344
|
+
const merged = {
|
|
345
|
+
...(old || {}),
|
|
346
|
+
name,
|
|
347
|
+
type: opts.type ?? old?.type,
|
|
348
|
+
display_name: opts.label ?? old?.display_name,
|
|
349
|
+
description: opts.description ?? old?.description ?? '',
|
|
350
|
+
options: opts.options !== undefined
|
|
351
|
+
? String(opts.options).split(',').map((s) => s.trim()).filter(Boolean)
|
|
352
|
+
: old?.options ?? [],
|
|
353
|
+
updateOn: opts.manualOnly ? 'manual' : opts.aiUpdatable ? 'both' : old?.updateOn ?? 'both',
|
|
354
|
+
required: opts.required ? true : opts.optional ? false : old?.required ?? false,
|
|
355
|
+
showOnScreen: opts.hidden ? false : opts.visible ? true : old?.showOnScreen ?? true,
|
|
356
|
+
default_mode: opts.defaultMode ?? old?.default_mode ?? 'custom',
|
|
357
|
+
default_value: opts.defaultValue ?? old?.default_value ?? '',
|
|
358
|
+
};
|
|
359
|
+
const { definition } = await api.saveContactAttribute(merged);
|
|
360
|
+
(0, ui_1.success)(`Attribute "${definition.name}" ${old ? 'updated' : 'created'} (${definition.type}).`);
|
|
361
|
+
}
|
|
362
|
+
catch (err) {
|
|
363
|
+
(0, ui_1.handleError)(err);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
async function attributesDeleteCommand(name, opts) {
|
|
367
|
+
const api = (0, session_1.requireApi)();
|
|
368
|
+
try {
|
|
369
|
+
if (!(await (0, ui_1.confirm)(`Delete attribute "${name}"? Values already on contacts are kept but hidden.`, opts.yes))) {
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
await api.deleteContactAttribute(name);
|
|
373
|
+
(0, ui_1.success)(`Attribute "${name}" deleted.`);
|
|
374
|
+
}
|
|
375
|
+
catch (err) {
|
|
376
|
+
(0, ui_1.handleError)(err);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
@@ -9,6 +9,7 @@ exports.toTestFunctions = toTestFunctions;
|
|
|
9
9
|
exports.simTestsListCommand = simTestsListCommand;
|
|
10
10
|
exports.simCreateCommand = simCreateCommand;
|
|
11
11
|
exports.simUpdateCommand = simUpdateCommand;
|
|
12
|
+
exports.simModelsCommand = simModelsCommand;
|
|
12
13
|
exports.simDeleteCommand = simDeleteCommand;
|
|
13
14
|
exports.waitForBatch = waitForBatch;
|
|
14
15
|
exports.simRunCommand = simRunCommand;
|
|
@@ -66,31 +67,42 @@ async function simCreateCommand(agentId, opts) {
|
|
|
66
67
|
// Parse the JSON flags before prompting, so a typo fails before any typing.
|
|
67
68
|
const testVariables = opts.variables ? toTestVariables(await (0, jsonInput_1.readJsonFlag)(opts.variables, '--variables')) : {};
|
|
68
69
|
const testFunctions = opts.functions ? toTestFunctions(await (0, jsonInput_1.readJsonFlag)(opts.functions, '--functions')) : [];
|
|
69
|
-
let { name, prompt, criteria } = opts;
|
|
70
|
+
let { name, persona, prompt, criteria } = opts;
|
|
70
71
|
if (!name || !prompt || !criteria) {
|
|
71
72
|
(0, ui_1.needTty)('Pass --name, --prompt and --criteria.');
|
|
72
73
|
const answers = await (0, prompts_1.default)([
|
|
73
74
|
{ type: name ? null : 'text', name: 'name', message: 'Test case name' },
|
|
75
|
+
{
|
|
76
|
+
type: persona !== undefined ? null : 'text',
|
|
77
|
+
name: 'persona',
|
|
78
|
+
message: 'Caller persona (who they are, tone, what they know)',
|
|
79
|
+
},
|
|
74
80
|
{ type: prompt ? null : 'text', name: 'prompt', message: 'Simulated user prompt' },
|
|
75
81
|
{ type: criteria ? null : 'text', name: 'criteria', message: 'Success criteria' },
|
|
76
82
|
], { onCancel: () => process.exit(1) });
|
|
77
83
|
name = name || answers.name;
|
|
84
|
+
if (persona === undefined && answers.persona)
|
|
85
|
+
persona = answers.persona;
|
|
78
86
|
prompt = prompt || answers.prompt;
|
|
79
87
|
criteria = criteria || answers.criteria;
|
|
80
88
|
}
|
|
81
89
|
if (!name || !prompt || !criteria) {
|
|
82
90
|
return (0, ui_1.handleError)(new Error('--name, --prompt and --criteria are all required (or answer the prompts)'));
|
|
83
91
|
}
|
|
84
|
-
const
|
|
92
|
+
const payload = {
|
|
85
93
|
agentId,
|
|
86
94
|
testcaseName: name,
|
|
87
95
|
userPrompt: prompt,
|
|
88
96
|
successCriteria: criteria,
|
|
89
|
-
testLLMModel: opts.model || 'gpt-4.1-mini',
|
|
90
97
|
testVariables,
|
|
91
98
|
testFunctions,
|
|
92
|
-
}
|
|
93
|
-
|
|
99
|
+
};
|
|
100
|
+
if (opts.model !== undefined)
|
|
101
|
+
payload.testLLMModel = opts.model;
|
|
102
|
+
if (persona !== undefined)
|
|
103
|
+
payload.userPersona = persona;
|
|
104
|
+
const res = await api.createTestCase(payload);
|
|
105
|
+
(0, ui_1.success)(`Created test case ${res.testCase?._id || ''} (model: ${res.testCase?.testLLMModel || opts.model || 'default'}).`);
|
|
94
106
|
}
|
|
95
107
|
catch (err) {
|
|
96
108
|
(0, ui_1.handleError)(err);
|
|
@@ -107,6 +119,8 @@ async function simUpdateCommand(testCaseId, opts) {
|
|
|
107
119
|
const body = {};
|
|
108
120
|
if (opts.name !== undefined)
|
|
109
121
|
body.testcaseName = opts.name;
|
|
122
|
+
if (opts.persona !== undefined)
|
|
123
|
+
body.userPersona = opts.persona;
|
|
110
124
|
if (opts.prompt !== undefined)
|
|
111
125
|
body.userPrompt = opts.prompt;
|
|
112
126
|
if (opts.criteria !== undefined)
|
|
@@ -118,7 +132,7 @@ async function simUpdateCommand(testCaseId, opts) {
|
|
|
118
132
|
if (opts.functions !== undefined)
|
|
119
133
|
body.testFunctions = toTestFunctions(await (0, jsonInput_1.readJsonFlag)(opts.functions, '--functions'));
|
|
120
134
|
if (!Object.keys(body).length) {
|
|
121
|
-
return (0, ui_1.handleError)(new Error('Nothing to update. Pass at least one of --name, --prompt, --criteria, --model, --variables, --functions.'));
|
|
135
|
+
return (0, ui_1.handleError)(new Error('Nothing to update. Pass at least one of --name, --persona, --prompt, --criteria, --model, --variables, --functions.'));
|
|
122
136
|
}
|
|
123
137
|
const res = await api.updateTestCase(testCaseId, body);
|
|
124
138
|
(0, ui_1.success)(`Updated test case ${res.testCase?._id || testCaseId}: ${Object.keys(body).join(', ')}.`);
|
|
@@ -127,6 +141,19 @@ async function simUpdateCommand(testCaseId, opts) {
|
|
|
127
141
|
(0, ui_1.handleError)(err);
|
|
128
142
|
}
|
|
129
143
|
}
|
|
144
|
+
async function simModelsCommand(opts) {
|
|
145
|
+
const api = (0, session_1.requireApi)();
|
|
146
|
+
try {
|
|
147
|
+
const res = await api.simulationModels();
|
|
148
|
+
if (opts.json)
|
|
149
|
+
return void console.log(JSON.stringify(res, null, 2));
|
|
150
|
+
const def = res.categories?.[0]?.defaultModel;
|
|
151
|
+
(0, ui_1.table)(['MODEL', 'DEFAULT'], (res.models || []).map((m) => [m.id, m.id === def ? 'yes' : '']));
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
(0, ui_1.handleError)(err);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
130
157
|
async function simDeleteCommand(testCaseId, opts) {
|
|
131
158
|
const api = (0, session_1.requireApi)();
|
|
132
159
|
try {
|
|
@@ -174,11 +201,17 @@ function resultTable(rows) {
|
|
|
174
201
|
(0, ui_1.table)(['TEST CASE', 'VERSION', 'RESULT', 'TURNS', 'SUMMARY'], rows.map((r) => [
|
|
175
202
|
r.testcaseId?.testcaseName || r.testcaseName || r.testcaseId || '',
|
|
176
203
|
r.versionId?.versionName || r.versionId || '',
|
|
177
|
-
r.
|
|
204
|
+
r.analysisStatus === 'failed'
|
|
205
|
+
? 'GRADING FAILED'
|
|
206
|
+
: r.isTestSucceed === undefined
|
|
207
|
+
? ''
|
|
208
|
+
: r.isTestSucceed
|
|
209
|
+
? 'PASS'
|
|
210
|
+
: 'FAIL',
|
|
178
211
|
r.total_turns ?? '',
|
|
179
212
|
String(r.testResultSummary || '').replace(/\s+/g, ' ').slice(0, 70),
|
|
180
213
|
]));
|
|
181
|
-
const passed = rows.filter((r) => r.isTestSucceed).length;
|
|
214
|
+
const passed = rows.filter((r) => r.analysisStatus !== 'failed' && r.isTestSucceed).length;
|
|
182
215
|
(0, ui_1.info)(`\n${passed}/${rows.length} passed.`);
|
|
183
216
|
}
|
|
184
217
|
async function simRunCommand(agentId, opts) {
|
|
@@ -213,7 +246,7 @@ async function simRunCommand(agentId, opts) {
|
|
|
213
246
|
if (!done) {
|
|
214
247
|
return (0, ui_1.handleError)(new Error(`Timed out with ${rows.length}${total ? `/${total}` : ''} results. The run is still going: cku sim results ${batchId} --wait`));
|
|
215
248
|
}
|
|
216
|
-
if (rows.some((r) => !r.isTestSucceed))
|
|
249
|
+
if (rows.some((r) => r.analysisStatus === 'failed' || !r.isTestSucceed))
|
|
217
250
|
process.exitCode = 1;
|
|
218
251
|
}
|
|
219
252
|
catch (err) {
|
|
@@ -236,6 +269,9 @@ async function simRunsListCommand(agentId, opts) {
|
|
|
236
269
|
r.epoch ?? '',
|
|
237
270
|
r.createdAt ? new Date(r.createdAt).toLocaleString('en-US') : '',
|
|
238
271
|
]));
|
|
272
|
+
if (typeof res.totalCost === 'number') {
|
|
273
|
+
console.log(`Total cost, all runs: ${res.totalCost > 0 ? res.totalCost.toFixed(3) : 'N/A'}`);
|
|
274
|
+
}
|
|
239
275
|
}
|
|
240
276
|
catch (err) {
|
|
241
277
|
(0, ui_1.handleError)(err);
|
package/dist/commands/update.js
CHANGED
|
@@ -36,6 +36,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.updateCommand = updateCommand;
|
|
37
37
|
const child_process_1 = require("child_process");
|
|
38
38
|
const ui_1 = require("../lib/ui");
|
|
39
|
+
const win = process.platform === 'win32';
|
|
40
|
+
const bin = (name) => (win ? `${name}.cmd` : name);
|
|
39
41
|
/**
|
|
40
42
|
* `cku update` — upgrade the CLI itself from npm, then refresh the skill pack
|
|
41
43
|
* (which lives in its own repo, so it may have moved on even if the CLI hasn't).
|
|
@@ -46,7 +48,7 @@ async function updateCommand(opts) {
|
|
|
46
48
|
try {
|
|
47
49
|
let legacyInstalled = false;
|
|
48
50
|
try {
|
|
49
|
-
(0, child_process_1.execFileSync)('npm', ['list', '--global', '@callkaro/cli', '--depth=0'], { stdio: 'ignore' });
|
|
51
|
+
(0, child_process_1.execFileSync)(bin('npm'), ['list', '--global', '@callkaro/cli', '--depth=0'], { stdio: 'ignore', shell: win });
|
|
50
52
|
legacyInstalled = true;
|
|
51
53
|
}
|
|
52
54
|
catch {
|
|
@@ -54,19 +56,24 @@ async function updateCommand(opts) {
|
|
|
54
56
|
}
|
|
55
57
|
if (legacyInstalled) {
|
|
56
58
|
(0, ui_1.info)('Removing legacy @callkaro/cli installation …');
|
|
57
|
-
(0, child_process_1.execFileSync)('npm', ['uninstall', '--global', '@callkaro/cli'], { stdio: 'ignore' });
|
|
59
|
+
(0, child_process_1.execFileSync)(bin('npm'), ['uninstall', '--global', '@callkaro/cli'], { stdio: 'ignore', shell: win });
|
|
58
60
|
}
|
|
59
|
-
(0, child_process_1.execFileSync)('npm', ['install', '--global', 'callkaro-cli-unofficial'], { stdio: 'ignore' });
|
|
61
|
+
(0, child_process_1.execFileSync)(bin('npm'), ['install', '--global', 'callkaro-cli-unofficial'], { stdio: 'ignore', shell: win });
|
|
60
62
|
}
|
|
61
63
|
catch {
|
|
62
|
-
(
|
|
63
|
-
|
|
64
|
+
if (win) {
|
|
65
|
+
(0, ui_1.fail)('npm could not update the CLI. Run PowerShell as Administrator, or re-run:\n npm install --global callkaro-cli-unofficial');
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
(0, ui_1.fail)('npm could not write to the global folder (it needs elevated permissions).');
|
|
69
|
+
(0, ui_1.info)('Re-run as:\n sudo cku update');
|
|
70
|
+
}
|
|
64
71
|
process.exitCode = 1;
|
|
65
72
|
return;
|
|
66
73
|
}
|
|
67
74
|
let version = '';
|
|
68
75
|
try {
|
|
69
|
-
version = (0, child_process_1.execFileSync)('cku', ['--version'], { encoding: 'utf8' }).trim();
|
|
76
|
+
version = (0, child_process_1.execFileSync)(bin('cku'), ['--version'], { encoding: 'utf8', shell: win }).trim();
|
|
70
77
|
}
|
|
71
78
|
catch {
|
|
72
79
|
/* not on PATH yet in this shell */
|